From 6063c1c53292b04c1d413101d43d1ae1defb7e30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?andr=C3=A9s=20gonz=C3=A1lez?= Date: Wed, 8 Apr 2026 17:48:05 +0200 Subject: [PATCH 001/162] :books:Clarify remote MCP availability in production (#8910) --- docs/mcp/index.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/mcp/index.md b/docs/mcp/index.md index d09b7db8b7a..c338faf96d1 100644 --- a/docs/mcp/index.md +++ b/docs/mcp/index.md @@ -110,6 +110,18 @@ If you just want to try Penpot MCP quickly, follow this path for the **hosted (r ### Remote MCP in 5 steps +
+ +### Important: remote MCP is not in production yet + +Remote MCP is not available yet in Penpot production (`design.penpot.app`). It is planned for an upcoming release (currently targeted around **2.16**). + +Right now, the remote MCP flow is available only in **testing environments** (for example, instances deployed from the `staging` branch: https://github.com/penpot/penpot/tree/staging). + +If you need MCP in production today, use the **Local MCP server** setup instead. See [Local MCP server](#local-mcp-server). + +
+ 1. #### Enable MCP in Penpot Go to **Your account → Integrations → MCP Server (Beta)** and enable the feature. @@ -286,6 +298,16 @@ In Penpot, open a file and connect the plugin from **File → MCP Server → Con Remote MCP is the easiest way to start using AI agents with Penpot. It's hosted for you, so you don't need to install or run anything on your machine. +
+ +### Availability note + +Remote MCP is currently available only in **testing environments**. It is not yet available in Penpot production (`design.penpot.app`) and is planned for an upcoming release (currently targeted around **2.16**). + +If you need MCP in production today, use the **Local MCP server** setup instead. See [Local MCP server](#local-mcp-server). + +
+ ### Install and activate From dfa45ec8d86ea878f056432efecce9026d22c55d Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 9 Apr 2026 09:10:44 +0200 Subject: [PATCH 002/162] :arrow_up: Update deps on root package.json --- package.json | 8 +- pnpm-lock.yaml | 404 ++++++++++++++++++++++++------------------------- 2 files changed, 206 insertions(+), 206 deletions(-) diff --git a/package.json b/package.json index a4e585946d4..c0351b106dc 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,9 @@ "fmt": "./scripts/fmt" }, "devDependencies": { - "@github/copilot": "^1.0.12", - "@types/node": "^20.12.7", - "esbuild": "^0.27.4", - "opencode-ai": "^1.3.17" + "@github/copilot": "^1.0.21", + "@types/node": "^25.5.2", + "esbuild": "^0.28.0", + "opencode-ai": "^1.4.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 976eca525e8..d06094e10f6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,480 +9,480 @@ importers: .: devDependencies: '@github/copilot': - specifier: ^1.0.12 - version: 1.0.12 + specifier: ^1.0.21 + version: 1.0.21 '@types/node': - specifier: ^20.12.7 - version: 20.19.37 + specifier: ^25.5.2 + version: 25.5.2 esbuild: - specifier: ^0.27.4 - version: 0.27.4 + specifier: ^0.28.0 + version: 0.28.0 opencode-ai: - specifier: ^1.3.17 - version: 1.3.17 + specifier: ^1.4.0 + version: 1.4.0 packages: - '@esbuild/aix-ppc64@0.27.4': - resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} + '@esbuild/aix-ppc64@0.28.0': + resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.27.4': - resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} + '@esbuild/android-arm64@0.28.0': + resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.27.4': - resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} + '@esbuild/android-arm@0.28.0': + resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.27.4': - resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} + '@esbuild/android-x64@0.28.0': + resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.27.4': - resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} + '@esbuild/darwin-arm64@0.28.0': + resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.27.4': - resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} + '@esbuild/darwin-x64@0.28.0': + resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.27.4': - resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} + '@esbuild/freebsd-arm64@0.28.0': + resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.4': - resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} + '@esbuild/freebsd-x64@0.28.0': + resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.27.4': - resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} + '@esbuild/linux-arm64@0.28.0': + resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.27.4': - resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} + '@esbuild/linux-arm@0.28.0': + resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.27.4': - resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} + '@esbuild/linux-ia32@0.28.0': + resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.27.4': - resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} + '@esbuild/linux-loong64@0.28.0': + resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.27.4': - resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} + '@esbuild/linux-mips64el@0.28.0': + resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.27.4': - resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} + '@esbuild/linux-ppc64@0.28.0': + resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.27.4': - resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} + '@esbuild/linux-riscv64@0.28.0': + resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.27.4': - resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} + '@esbuild/linux-s390x@0.28.0': + resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.27.4': - resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} + '@esbuild/linux-x64@0.28.0': + resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.27.4': - resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} + '@esbuild/netbsd-arm64@0.28.0': + resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.4': - resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} + '@esbuild/netbsd-x64@0.28.0': + resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.27.4': - resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} + '@esbuild/openbsd-arm64@0.28.0': + resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.4': - resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} + '@esbuild/openbsd-x64@0.28.0': + resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.27.4': - resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} + '@esbuild/openharmony-arm64@0.28.0': + resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.27.4': - resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} + '@esbuild/sunos-x64@0.28.0': + resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.27.4': - resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} + '@esbuild/win32-arm64@0.28.0': + resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.27.4': - resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} + '@esbuild/win32-ia32@0.28.0': + resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.27.4': - resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} + '@esbuild/win32-x64@0.28.0': + resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} engines: {node: '>=18'} cpu: [x64] os: [win32] - '@github/copilot-darwin-arm64@1.0.12': - resolution: {integrity: sha512-fjbwRIUZAH06Eyg5ZkfZXg8SVXpqI3HaFhtXZ803CZs9mfIgfOSR3URZxUnv7SIv6aI/7f6ws8RxKnPGavJ/tg==} + '@github/copilot-darwin-arm64@1.0.21': + resolution: {integrity: sha512-aB+s9ldTwcyCOYmzjcQ4SknV6g81z92T8aUJEJZBwOXOTBeWKAJtk16ooAKangZgdwuLgO3or1JUjx1FJAm5nQ==} cpu: [arm64] os: [darwin] hasBin: true - '@github/copilot-darwin-x64@1.0.12': - resolution: {integrity: sha512-/tJGJEEm8kpTW/sJRNnvhMSHKIHApNun14biuIkC5CXDqVgFakbKlckn/FlIkT48eEUysc0YbEatrHIDz/8XbA==} + '@github/copilot-darwin-x64@1.0.21': + resolution: {integrity: sha512-aNad81DOGuGShmaiFNIxBUSZLwte0dXmDYkGfAF9WJIgY4qP4A8CPWFoNr8//gY+4CwaIf9V+f/OC6k2BdECbw==} cpu: [x64] os: [darwin] hasBin: true - '@github/copilot-linux-arm64@1.0.12': - resolution: {integrity: sha512-4977LVJi3/9Yc+ivj+VKDVtHg0kT5yqOrN8F35/jgqerx4Mdtk1pOMlWztXxLicBHN4y2V7/EY/wc0WqFW0Zvg==} + '@github/copilot-linux-arm64@1.0.21': + resolution: {integrity: sha512-FL0NsCnHax4czHVv1S8iBqPLGZDhZ28N3+6nT29xWGhmjBWTkIofxLThKUPcyyMsfPTTxIlrdwWa8qQc5z2Q+g==} cpu: [arm64] os: [linux] hasBin: true - '@github/copilot-linux-x64@1.0.12': - resolution: {integrity: sha512-9QevJZD29PVltYDV4xHWbdN6ud/966clERL5Frh2+9D3+spaVDO1hFllzoFiEwD/M4f2GkSh7/fT3hV0LKl9Ag==} + '@github/copilot-linux-x64@1.0.21': + resolution: {integrity: sha512-S7pWVI16hesZtxYbIyfw+MHZpc5ESoGKUVr5Y+lZJNaM2340gJGPQzQwSpvKIRMLHRKI2hXLwciAnYeMFxE/Tg==} cpu: [x64] os: [linux] hasBin: true - '@github/copilot-win32-arm64@1.0.12': - resolution: {integrity: sha512-RLAbAsLniI8vA2utgZdIsvD8slZpz1fb8l6cmIiQvDE/BwQb2zNV9VepZ+CwzYtNx9ifxBtgIwYwUJq5bxeSaQ==} + '@github/copilot-win32-arm64@1.0.21': + resolution: {integrity: sha512-a9qc2Ku+XbyBkXCclbIvBbIVnECACTIWnPctmXWsQeSdeapGxgfHGux7y8hAFV5j6+nhCm6cnyEMS3rkZjAhdA==} cpu: [arm64] os: [win32] hasBin: true - '@github/copilot-win32-x64@1.0.12': - resolution: {integrity: sha512-4SYV09F4Sw20DAib1do26+ALZmCZrghzo+5e6IZbQOsm4B7NhBFaLpKFU+kEijfmWacLlh/at5CpGGGKlwlbcg==} + '@github/copilot-win32-x64@1.0.21': + resolution: {integrity: sha512-9klu+7NQ6tEyb8sibb0rsbimBivDrnNltZho10Bgbf1wh3o+erTjffXDjW9Zkyaw8lZA9Fz8bqhVkKntZq58Lg==} cpu: [x64] os: [win32] hasBin: true - '@github/copilot@1.0.12': - resolution: {integrity: sha512-GpmoJbs1ECyLLKtY4PcFzO8Cz6GgDTOKkrzwNdkirNdfsB+o6x0OOlFyrOdNXNPII7pk9+GcpIjF87sLwWzpPQ==} + '@github/copilot@1.0.21': + resolution: {integrity: sha512-P+nORjNKAtl92jYCG6Qr1Rsw2JoyScgeQSkIR6O2WB37WS5JVdA4ax1WVualMbfuc9V58CPHX6fwyNpkI89FkQ==} hasBin: true - '@types/node@20.19.37': - resolution: {integrity: sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==} + '@types/node@25.5.2': + resolution: {integrity: sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==} - esbuild@0.27.4: - resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} + esbuild@0.28.0: + resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} engines: {node: '>=18'} hasBin: true - opencode-ai@1.3.17: - resolution: {integrity: sha512-2Awq2za4kLPG9wxFHFmqcmoveTSTeyq7Q3GJ8PoQahjWU17yCjuyJUclouFULzaZgqA8atHOyT/3eUHigMc8Cw==} + opencode-ai@1.4.0: + resolution: {integrity: sha512-Cb5Vo5Rl1gvOIXC8gtgupwoa5+rufsp+6u5tIxIYLl5fReX+P2eugLSOkKH2KB5GC6BwxaEvapEZiPvQYsZSXA==} hasBin: true - opencode-darwin-arm64@1.3.17: - resolution: {integrity: sha512-aaMXeNQRPLdGPoxULFty1kxYxT2qPXCiqftYbLF2SQN9Xjq8BR3BjA766ncae1hdiDJJAe1CSpWDbobn5K+oyA==} + opencode-darwin-arm64@1.4.0: + resolution: {integrity: sha512-rXdrH1Oejb+220ZCzkd1P+tCP7IhLTyfRbUr89vzvEWVRueh0vr2hvyrGDVv9LAskZAt/hwY3Wnw9CzjtxocdQ==} cpu: [arm64] os: [darwin] - opencode-darwin-x64-baseline@1.3.17: - resolution: {integrity: sha512-ftEiCwzl6OjIqpXD075lHWHT1YKJjNDPvL1XlLDv86Wt4Noc818fl1lOWwg/LkNL04LoXD2oa3NGOJZYzd6STQ==} + opencode-darwin-x64-baseline@1.4.0: + resolution: {integrity: sha512-5xCXF8Xn9M2WQKZATc4llm9qrAc4JodmQj88Oibbz/rXIOr/A1ejXvaeqLOQkQyQweeEABlYXOf3eCiY5hx8Gw==} cpu: [x64] os: [darwin] - opencode-darwin-x64@1.3.17: - resolution: {integrity: sha512-fMlnOCtaMnwimdP81a3F7QK9GUwhrQnxaKuUZk31wYcGBGQKgSSdy2xK8CRLcaHEV8gLxSlcGJj7g4NTOrC9Tw==} + opencode-darwin-x64@1.4.0: + resolution: {integrity: sha512-PhBfT2EtPos7jcGBtVSz3+yKv2e1nQy1UrXiH4ILdSgwzroKU/0kMsRtWJeMPHIj1imUQmSVlnDcuxiCiCkozw==} cpu: [x64] os: [darwin] - opencode-linux-arm64-musl@1.3.17: - resolution: {integrity: sha512-clD6K35+pP60xLiqCJFTTTpDK2XFahOlSo8TQckXCvCnYYwMqdK9sOO7uzDHLNyPIGLKiYNZTxqVazuGnbGmYQ==} + opencode-linux-arm64-musl@1.4.0: + resolution: {integrity: sha512-1lc0Nj6OmtJF5NJn+AhV7rXOHzw+0p7rvXQu2iqd9V7DpUEbltyF6oiyuF54gBZjHpvSzFXu8a3YeTcuMEXdNA==} cpu: [arm64] os: [linux] - opencode-linux-arm64@1.3.17: - resolution: {integrity: sha512-gd4kndxNwYi9kINyrTItY35M7UZ4jAXMxbbdbFnUBFYI009uv4bgNofnZnVOAFfjc0/PpxSgdNn9eHDjlJEdJQ==} + opencode-linux-arm64@1.4.0: + resolution: {integrity: sha512-XEM3tP7DTrYDEYCe9jqC/xtgzPJpCZTfinc5DjcPuh2hx+iHCGSr9+qG7tRGeCyvD9ibAFewNtpco5Is49JCrg==} cpu: [arm64] os: [linux] - opencode-linux-x64-baseline-musl@1.3.17: - resolution: {integrity: sha512-BiNu5B6QfohG+KwNcY3YlKR465DNke0nknRqn3Ke2APp6GghuimlnyEKcji1brhZsdjdembc79KWfQcsHlYsyA==} + opencode-linux-x64-baseline-musl@1.4.0: + resolution: {integrity: sha512-URg1JXIUaNz0R4TLUT98rK2jozmh5ScAkkqxPK6LWj3XwJojJx23mJRNgLb26034PgNkUwXhrtdbnyPTSVlkqQ==} cpu: [x64] os: [linux] - opencode-linux-x64-baseline@1.3.17: - resolution: {integrity: sha512-OIp+jdr9Rus6kAVWgB8cuGMRPFVJdMwQvjOfprbgoM2KUIjgXKsXgyCmetKZIH/iadmVffjv7p6QrYWFDh6cBA==} + opencode-linux-x64-baseline@1.4.0: + resolution: {integrity: sha512-GocjLGNgs41PLgSVPWxT3Do0StZkDB9QF3e3VIIAGzPmOVcpTZLdDvJPkZdRbRGcVfUcSRGquBbBgvwK9Zsw4w==} cpu: [x64] os: [linux] - opencode-linux-x64-musl@1.3.17: - resolution: {integrity: sha512-/GfRB+vuadE6KAM0kxPQHno3ywxBfiRJp5uZLLKSGAEunXo9az1wkmSR97g4tnxHD4F59hjYOloK9XQQIDwgog==} + opencode-linux-x64-musl@1.4.0: + resolution: {integrity: sha512-yGb1uNO++BtkZ7X/LGLax9ppaEvsmn5s5GaAqcrYj/SyJA5cL2IYzEeMYRAsrb0b81fQCSq5SLEiWiMq1o59oA==} cpu: [x64] os: [linux] - opencode-linux-x64@1.3.17: - resolution: {integrity: sha512-FmoKpX+g78qi4MPvRMWZMZZYKVuH7qkNFXEqGUb0wtixvwuWYvqmUeF9D0GLM/rZnGA33sW6nCkro8aCuyR0Bw==} + opencode-linux-x64@1.4.0: + resolution: {integrity: sha512-Ops08slOBhHbKaYhERH8zMTjlM6mearVaA0udCDIx2fGqDbZRisoRyyI6Z44GPYBH02w8eGmvOvnF5fQYyq2fw==} cpu: [x64] os: [linux] - opencode-windows-arm64@1.3.17: - resolution: {integrity: sha512-gXZ+JKwCUZ9yjVilvnn6zg5vvRy0oPgqIO6qyfvXiLXV+UWJaSTlXl6/4CeXOkvvYeXhLdCtIFii2jbQJjHR3g==} + opencode-windows-arm64@1.4.0: + resolution: {integrity: sha512-47quWER7bCGRPWRXd3fsOyu5F/T4Y65FiS05kD+PYYV4iOJymlBQ34kpcJhNBOpQLYf9HSLbJ8AaJeb5dmUi+Q==} cpu: [arm64] os: [win32] - opencode-windows-x64-baseline@1.3.17: - resolution: {integrity: sha512-Q61MuJBTt+qLyClTEaqbCHh3Fivx0eZ1vHKlhEk7MfIdP/LoDbvSitNRUgtsU/C+ct5Y+c6JXOlxlaFFpqybeA==} + opencode-windows-x64-baseline@1.4.0: + resolution: {integrity: sha512-eGK9lF70XKzf9zBO7xil9+Vl7ZJUAgLK6bG6kug6RKxD6FsydY3Y6q/3tIW0+YZ0wyINOtEbTRfUHbO5TxV4FQ==} cpu: [x64] os: [win32] - opencode-windows-x64@1.3.17: - resolution: {integrity: sha512-+arPhczUa5NBH/thsKAxLmXgkB2WAxtj8Dd293GJZBBEXRhWF1jsXbLvGLY3qDBbvXm9XR7CkJqL1at344pQLw==} + opencode-windows-x64@1.4.0: + resolution: {integrity: sha512-DQ8CoxCsmFM38U1e73+hFuB6Wu0tbn6B4R7KwcL1JhvKvQaYYiukNfuLgcjjx5D7s81NP1SWlv6lw60wN0gq8g==} cpu: [x64] os: [win32] - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} snapshots: - '@esbuild/aix-ppc64@0.27.4': + '@esbuild/aix-ppc64@0.28.0': optional: true - '@esbuild/android-arm64@0.27.4': + '@esbuild/android-arm64@0.28.0': optional: true - '@esbuild/android-arm@0.27.4': + '@esbuild/android-arm@0.28.0': optional: true - '@esbuild/android-x64@0.27.4': + '@esbuild/android-x64@0.28.0': optional: true - '@esbuild/darwin-arm64@0.27.4': + '@esbuild/darwin-arm64@0.28.0': optional: true - '@esbuild/darwin-x64@0.27.4': + '@esbuild/darwin-x64@0.28.0': optional: true - '@esbuild/freebsd-arm64@0.27.4': + '@esbuild/freebsd-arm64@0.28.0': optional: true - '@esbuild/freebsd-x64@0.27.4': + '@esbuild/freebsd-x64@0.28.0': optional: true - '@esbuild/linux-arm64@0.27.4': + '@esbuild/linux-arm64@0.28.0': optional: true - '@esbuild/linux-arm@0.27.4': + '@esbuild/linux-arm@0.28.0': optional: true - '@esbuild/linux-ia32@0.27.4': + '@esbuild/linux-ia32@0.28.0': optional: true - '@esbuild/linux-loong64@0.27.4': + '@esbuild/linux-loong64@0.28.0': optional: true - '@esbuild/linux-mips64el@0.27.4': + '@esbuild/linux-mips64el@0.28.0': optional: true - '@esbuild/linux-ppc64@0.27.4': + '@esbuild/linux-ppc64@0.28.0': optional: true - '@esbuild/linux-riscv64@0.27.4': + '@esbuild/linux-riscv64@0.28.0': optional: true - '@esbuild/linux-s390x@0.27.4': + '@esbuild/linux-s390x@0.28.0': optional: true - '@esbuild/linux-x64@0.27.4': + '@esbuild/linux-x64@0.28.0': optional: true - '@esbuild/netbsd-arm64@0.27.4': + '@esbuild/netbsd-arm64@0.28.0': optional: true - '@esbuild/netbsd-x64@0.27.4': + '@esbuild/netbsd-x64@0.28.0': optional: true - '@esbuild/openbsd-arm64@0.27.4': + '@esbuild/openbsd-arm64@0.28.0': optional: true - '@esbuild/openbsd-x64@0.27.4': + '@esbuild/openbsd-x64@0.28.0': optional: true - '@esbuild/openharmony-arm64@0.27.4': + '@esbuild/openharmony-arm64@0.28.0': optional: true - '@esbuild/sunos-x64@0.27.4': + '@esbuild/sunos-x64@0.28.0': optional: true - '@esbuild/win32-arm64@0.27.4': + '@esbuild/win32-arm64@0.28.0': optional: true - '@esbuild/win32-ia32@0.27.4': + '@esbuild/win32-ia32@0.28.0': optional: true - '@esbuild/win32-x64@0.27.4': + '@esbuild/win32-x64@0.28.0': optional: true - '@github/copilot-darwin-arm64@1.0.12': + '@github/copilot-darwin-arm64@1.0.21': optional: true - '@github/copilot-darwin-x64@1.0.12': + '@github/copilot-darwin-x64@1.0.21': optional: true - '@github/copilot-linux-arm64@1.0.12': + '@github/copilot-linux-arm64@1.0.21': optional: true - '@github/copilot-linux-x64@1.0.12': + '@github/copilot-linux-x64@1.0.21': optional: true - '@github/copilot-win32-arm64@1.0.12': + '@github/copilot-win32-arm64@1.0.21': optional: true - '@github/copilot-win32-x64@1.0.12': + '@github/copilot-win32-x64@1.0.21': optional: true - '@github/copilot@1.0.12': + '@github/copilot@1.0.21': optionalDependencies: - '@github/copilot-darwin-arm64': 1.0.12 - '@github/copilot-darwin-x64': 1.0.12 - '@github/copilot-linux-arm64': 1.0.12 - '@github/copilot-linux-x64': 1.0.12 - '@github/copilot-win32-arm64': 1.0.12 - '@github/copilot-win32-x64': 1.0.12 - - '@types/node@20.19.37': + '@github/copilot-darwin-arm64': 1.0.21 + '@github/copilot-darwin-x64': 1.0.21 + '@github/copilot-linux-arm64': 1.0.21 + '@github/copilot-linux-x64': 1.0.21 + '@github/copilot-win32-arm64': 1.0.21 + '@github/copilot-win32-x64': 1.0.21 + + '@types/node@25.5.2': dependencies: - undici-types: 6.21.0 + undici-types: 7.18.2 - esbuild@0.27.4: + esbuild@0.28.0: optionalDependencies: - '@esbuild/aix-ppc64': 0.27.4 - '@esbuild/android-arm': 0.27.4 - '@esbuild/android-arm64': 0.27.4 - '@esbuild/android-x64': 0.27.4 - '@esbuild/darwin-arm64': 0.27.4 - '@esbuild/darwin-x64': 0.27.4 - '@esbuild/freebsd-arm64': 0.27.4 - '@esbuild/freebsd-x64': 0.27.4 - '@esbuild/linux-arm': 0.27.4 - '@esbuild/linux-arm64': 0.27.4 - '@esbuild/linux-ia32': 0.27.4 - '@esbuild/linux-loong64': 0.27.4 - '@esbuild/linux-mips64el': 0.27.4 - '@esbuild/linux-ppc64': 0.27.4 - '@esbuild/linux-riscv64': 0.27.4 - '@esbuild/linux-s390x': 0.27.4 - '@esbuild/linux-x64': 0.27.4 - '@esbuild/netbsd-arm64': 0.27.4 - '@esbuild/netbsd-x64': 0.27.4 - '@esbuild/openbsd-arm64': 0.27.4 - '@esbuild/openbsd-x64': 0.27.4 - '@esbuild/openharmony-arm64': 0.27.4 - '@esbuild/sunos-x64': 0.27.4 - '@esbuild/win32-arm64': 0.27.4 - '@esbuild/win32-ia32': 0.27.4 - '@esbuild/win32-x64': 0.27.4 - - opencode-ai@1.3.17: + '@esbuild/aix-ppc64': 0.28.0 + '@esbuild/android-arm': 0.28.0 + '@esbuild/android-arm64': 0.28.0 + '@esbuild/android-x64': 0.28.0 + '@esbuild/darwin-arm64': 0.28.0 + '@esbuild/darwin-x64': 0.28.0 + '@esbuild/freebsd-arm64': 0.28.0 + '@esbuild/freebsd-x64': 0.28.0 + '@esbuild/linux-arm': 0.28.0 + '@esbuild/linux-arm64': 0.28.0 + '@esbuild/linux-ia32': 0.28.0 + '@esbuild/linux-loong64': 0.28.0 + '@esbuild/linux-mips64el': 0.28.0 + '@esbuild/linux-ppc64': 0.28.0 + '@esbuild/linux-riscv64': 0.28.0 + '@esbuild/linux-s390x': 0.28.0 + '@esbuild/linux-x64': 0.28.0 + '@esbuild/netbsd-arm64': 0.28.0 + '@esbuild/netbsd-x64': 0.28.0 + '@esbuild/openbsd-arm64': 0.28.0 + '@esbuild/openbsd-x64': 0.28.0 + '@esbuild/openharmony-arm64': 0.28.0 + '@esbuild/sunos-x64': 0.28.0 + '@esbuild/win32-arm64': 0.28.0 + '@esbuild/win32-ia32': 0.28.0 + '@esbuild/win32-x64': 0.28.0 + + opencode-ai@1.4.0: optionalDependencies: - opencode-darwin-arm64: 1.3.17 - opencode-darwin-x64: 1.3.17 - opencode-darwin-x64-baseline: 1.3.17 - opencode-linux-arm64: 1.3.17 - opencode-linux-arm64-musl: 1.3.17 - opencode-linux-x64: 1.3.17 - opencode-linux-x64-baseline: 1.3.17 - opencode-linux-x64-baseline-musl: 1.3.17 - opencode-linux-x64-musl: 1.3.17 - opencode-windows-arm64: 1.3.17 - opencode-windows-x64: 1.3.17 - opencode-windows-x64-baseline: 1.3.17 + opencode-darwin-arm64: 1.4.0 + opencode-darwin-x64: 1.4.0 + opencode-darwin-x64-baseline: 1.4.0 + opencode-linux-arm64: 1.4.0 + opencode-linux-arm64-musl: 1.4.0 + opencode-linux-x64: 1.4.0 + opencode-linux-x64-baseline: 1.4.0 + opencode-linux-x64-baseline-musl: 1.4.0 + opencode-linux-x64-musl: 1.4.0 + opencode-windows-arm64: 1.4.0 + opencode-windows-x64: 1.4.0 + opencode-windows-x64-baseline: 1.4.0 - opencode-darwin-arm64@1.3.17: + opencode-darwin-arm64@1.4.0: optional: true - opencode-darwin-x64-baseline@1.3.17: + opencode-darwin-x64-baseline@1.4.0: optional: true - opencode-darwin-x64@1.3.17: + opencode-darwin-x64@1.4.0: optional: true - opencode-linux-arm64-musl@1.3.17: + opencode-linux-arm64-musl@1.4.0: optional: true - opencode-linux-arm64@1.3.17: + opencode-linux-arm64@1.4.0: optional: true - opencode-linux-x64-baseline-musl@1.3.17: + opencode-linux-x64-baseline-musl@1.4.0: optional: true - opencode-linux-x64-baseline@1.3.17: + opencode-linux-x64-baseline@1.4.0: optional: true - opencode-linux-x64-musl@1.3.17: + opencode-linux-x64-musl@1.4.0: optional: true - opencode-linux-x64@1.3.17: + opencode-linux-x64@1.4.0: optional: true - opencode-windows-arm64@1.3.17: + opencode-windows-arm64@1.4.0: optional: true - opencode-windows-x64-baseline@1.3.17: + opencode-windows-x64-baseline@1.4.0: optional: true - opencode-windows-x64@1.3.17: + opencode-windows-x64@1.4.0: optional: true - undici-types@6.21.0: {} + undici-types@7.18.2: {} From 388775413e62669893da9a5a565d3b3aca9e3dba Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 9 Apr 2026 09:07:28 +0000 Subject: [PATCH 003/162] :bug: Fix path drawing preview passing shape instead of content to next-node In `preview-next-point`, `st/get-path` was called without extra keys, which returns the full Shape record. That value was then passed directly to `path/next-node` as its `content` argument. `path/next-node` delegates to `impl/path-data`, which only accepts a `PathData` instance, `nil`, or a sequential collection of segments. A Shape record matches none of those cases, so `path-data` threw "unexpected data" every time the user moved the mouse while drawing a path. The fix is to call `(st/get-path state :content)` so that only the `:content` field (a `PathData` instance) is extracted and forwarded to `path/next-node`. --- frontend/src/app/main/data/workspace/path/drawing.cljs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/main/data/workspace/path/drawing.cljs b/frontend/src/app/main/data/workspace/path/drawing.cljs index 19923b52641..9ea648aba7e 100644 --- a/frontend/src/app/main/data/workspace/path/drawing.cljs +++ b/frontend/src/app/main/data/workspace/path/drawing.cljs @@ -58,12 +58,12 @@ last-point (get-in state [:workspace-local :edit-path id :last-point]) position (cond-> (gpt/point x y) fix-angle? (path.helpers/position-fixed-angle last-point)) - shape (st/get-path state) + content (st/get-path state :content) {:keys [last-point prev-handler]} (get-in state [:workspace-local :edit-path id]) - segment (path/next-node shape position last-point prev-handler)] + segment (path/next-node content position last-point prev-handler)] (assoc-in state [:workspace-local :edit-path id :preview] segment))))) (defn add-node From 9785a13e67f021936dc39f65fced87460ecdcfdb Mon Sep 17 00:00:00 2001 From: Marek Hrabe Date: Fri, 10 Apr 2026 11:22:20 +0200 Subject: [PATCH 004/162] :bug: Add webp export format to plugin types (#8870) * :bug: Add webp export format to plugin types Align plugin API typings with runtime export support by including 'webp' in 'Export.type' and updating the exported formats documentation. Signed-off-by: Marek Hrabe * :books: Add plugin-types changelog entry for missing webp export format Signed-off-by: Marek Hrabe --------- Signed-off-by: Marek Hrabe Co-authored-by: Andrey Antukh --- plugins/CHANGELOG.md | 1 + plugins/libs/plugin-types/index.d.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index a5d5faff84a..5d0fb45dec7 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -1,6 +1,7 @@ ## 1.5.0 (Unreleased) - **plugin-types**: Added a flags subcontexts with the flag `naturalChildrenOrdering` +- **plugin-types**: Fix missing `webp` export format in `Export.type` ## 1.4.2 (2026-01-21) diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts index f1b1595aec0..e1e2b87f8e3 100644 --- a/plugins/libs/plugin-types/index.d.ts +++ b/plugins/libs/plugin-types/index.d.ts @@ -1534,9 +1534,9 @@ export interface EventsMap { */ export interface Export { /** - * Type of the file to export. Can be one of the following values: png, jpeg, svg, pdf + * Type of the file to export. Can be one of the following values: png, jpeg, webp, svg, pdf */ - type: 'png' | 'jpeg' | 'svg' | 'pdf'; + type: 'png' | 'jpeg' | 'webp' | 'svg' | 'pdf'; /** * For bitmap formats represent the scale of the original size to resize the export */ From ef6eeb56938f2e1583754b781936470a9dd13449 Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Fri, 10 Apr 2026 11:23:03 +0200 Subject: [PATCH 005/162] :bug: Fix variants corner cases with selrect and points (#8882) Co-authored-by: Andrey Antukh --- CHANGES.md | 8 + common/src/app/common/logic/libraries.cljc | 56 ++- .../logic/variants_switch_test.cljc | 467 +++++++++++++++++- 3 files changed, 523 insertions(+), 8 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index be1795e06f6..6cd8b74ec30 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,13 @@ # CHANGELOG +## 2.14.3 (Unreleased) + +### :sparkles: New features & Enhancements + +### :bug: Bugs fixed + +- Fix component "broken" after switch variant [Taiga #12984](https://tree.taiga.io/project/penpot/issue/12984) + ## 2.14.2 ### :sparkles: New features & Enhancements diff --git a/common/src/app/common/logic/libraries.cljc b/common/src/app/common/logic/libraries.cljc index a162561d1a3..8dbb3970f2e 100644 --- a/common/src/app/common/logic/libraries.cljc +++ b/common/src/app/common/logic/libraries.cljc @@ -2006,14 +2006,17 @@ (defn- switch-fixed-layout-geom-change-value [prev-shape ; The shape before the switch current-shape ; The shape after the switch (a clean copy) + origin-shape ; The original shape attr] ;; When there is a layout with fixed h or v sizing, we need ;; to keep the width/height (and recalculate selrect and points) (let [prev-width (-> prev-shape :selrect :width) current-width (-> current-shape :selrect :width) + origin-width (-> origin-shape :selrect :width) prev-height (-> prev-shape :selrect :height) current-height (-> current-shape :selrect :height) + origin-height (-> origin-shape :selrect :height) x (-> current-shape :selrect :x) y (-> current-shape :selrect :y) @@ -2024,10 +2027,16 @@ final-width (if (= :fix h-sizing) current-width - prev-width) + (if (= origin-width current-width) + prev-width ;; same-size: preserve override + current-width)) ;; different-size: use new component's + final-height (if (= :fix v-sizing) current-height - prev-height) + (if (= origin-height current-height) + prev-height ;; same-size: preserve override + current-height)) ;; different-size: use new component's + selrect (assoc (:selrect current-shape) :width final-width :height final-height @@ -2056,6 +2065,25 @@ (or (:transform current-shape) (gmt/matrix))))))) +(defn- equal-geometry? + "Returns true when the value of `attr` in `shape` is considered equal + to the corresponding value in `origin-shape`, ignoring positional + displacement (x/y). + For :selrect we compare width/height only; + for :points we normalise each vector so the first point is the + origin before comparing." + [shape origin-shape attr] + (or (and (= attr :selrect) + (= (-> shape :selrect :width) (-> origin-shape :selrect :width)) + (= (-> shape :selrect :height) (-> origin-shape :selrect :height))) + (and (= attr :points) + (let [normalize-pts (fn [pts] + (when (seq pts) + (let [f (first pts)] + (mapv #(gpt/subtract % f) pts))))] + (= (normalize-pts (get shape :points)) + (normalize-pts (get origin-shape :points))))))) + (defn update-attrs-on-switch "Copy attributes that have changed in the shape previous to the switch @@ -2092,8 +2120,9 @@ ;; If the values are already equal, don't copy them (= (get previous-shape attr) (get current-shape attr)) - ;; If the value is the same as the origin, don't copy it - (= (get previous-shape attr) (get origin-ref-shape attr)) + ;; If :selrect/:points values are already equal ignoring displacement, + ;; don't copy them + (equal-geometry? previous-shape origin-ref-shape attr) ;; If the attr is not touched, don't copy it (not (touched attr-group)) @@ -2143,8 +2172,21 @@ skip-operations? (or skip-operations? ;; If we are going to reset the position data, skip the selrect attr - (and reset-pos-data? (= attr :selrect))) - + (and reset-pos-data? (= attr :selrect)) + ;; Avoid copying composite geometry attrs (:selrect/:points) when the + ;; variant dimensions differ but neither sizing is :fix. Without this, + ;; :width/:height are correctly skipped by the check above + ;; but :selrect/:points would still carry the old override dimensions, + ;; leaving the shape in an inconsistent state. When :fix sizing is + ;; present, switch-fixed-layout-geom-change-value handles the composite + ;; attrs and must NOT be bypassed. Path shapes are also handled + ;; separately via switch-path-change-value. + (and (contains? #{:selrect :points} attr) + (not path-change?) + (not (or (= :fix (:layout-item-h-sizing previous-shape)) + (= :fix (:layout-item-v-sizing previous-shape)))) + (or (not= (get origin-ref-shape :width) (get current-shape :width)) + (not= (get origin-ref-shape :height) (get current-shape :height))))) attr-val (when-not skip-operations? (cond @@ -2168,7 +2210,7 @@ (and (or (= :fix (:layout-item-h-sizing previous-shape)) (= :fix (:layout-item-v-sizing previous-shape))) (contains? #{:points :selrect :width :height} attr)) - (switch-fixed-layout-geom-change-value previous-shape current-shape attr) + (switch-fixed-layout-geom-change-value previous-shape current-shape origin-ref-shape attr) :else (get previous-shape attr))) diff --git a/common/test/common_tests/logic/variants_switch_test.cljc b/common/test/common_tests/logic/variants_switch_test.cljc index d49764a3897..f01da5f2680 100644 --- a/common/test/common_tests/logic/variants_switch_test.cljc +++ b/common/test/common_tests/logic/variants_switch_test.cljc @@ -2257,4 +2257,469 @@ ;; or if it needs recalculation, the test validates the behavior (t/is (or (nil? old-position-data) (nil? new-position-data) - (not= old-position-data new-position-data))))) \ No newline at end of file + (not= old-position-data new-position-data))))) + +;; ============================================================ +;; SELRECT CONSISTENCY TESTS +;; These tests verify that after a variant switch, the composite +;; geometry attributes (:selrect, :points) stay consistent with +;; the scalar attributes (:width, :height) that are kept. +;; ============================================================ + +(t/deftest test-switch-selrect-consistent-no-sizing-different-widths + ;; When no :fix sizing and variants have different widths, + ;; :width is correctly skipped (stays at new component width), + ;; but :selrect was being copied from the old shape, leaving + ;; selrect.width inconsistent with :width. This test verifies the fix. + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + (thv/add-variant-with-child + :v01 :c01 :m01 :c02 :m02 :r01 :r02 + {:child1-params {:width 100 :height 50} + :child2-params {:width 200 :height 50}}) + + (thc/instantiate-component :c01 + :copy01 + :children-labels [:copy-r01])) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + rect01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; Override width AND selrect consistently (simulating a real resize) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id rect01)} + (fn [shape] + (let [new-width 150 + sr (:selrect shape) + new-sr (-> sr + (assoc :width new-width) + (assoc :x2 (+ (:x1 sr) new-width)))] + (-> shape + (assoc :width new-width) + (assoc :selrect new-sr)))) + (:objects page) + {}) + + file (thf/apply-changes file changes) + page (thf/current-page file) + rect01 (get-in page [:objects (:id rect01)]) + + ;; ==== Action + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + rect02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; The rect had the width override before the switch + (t/is (= (:width rect01) 150)) + (t/is (= (get-in rect01 [:selrect :width]) 150)) + ;; Since the variants have different widths (100 vs 200), the override is not preserved + (t/is (= (:width rect02') 200)) + ;; The selrect must be consistent with :width + (t/is (= (get-in rect02' [:selrect :width]) 200)))) + +(t/deftest test-switch-selrect-consistent-no-sizing-different-heights + ;; Same as above but for height. + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + (thv/add-variant-with-child + :v01 :c01 :m01 :c02 :m02 :r01 :r02 + {:child1-params {:width 50 :height 100} + :child2-params {:width 50 :height 200}}) + + (thc/instantiate-component :c01 + :copy01 + :children-labels [:copy-r01])) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + rect01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; Override height AND selrect consistently + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id rect01)} + (fn [shape] + (let [new-height 150 + sr (:selrect shape) + new-sr (-> sr + (assoc :height new-height) + (assoc :y2 (+ (:y1 sr) new-height)))] + (-> shape + (assoc :height new-height) + (assoc :selrect new-sr)))) + (:objects page) + {}) + + file (thf/apply-changes file changes) + page (thf/current-page file) + rect01 (get-in page [:objects (:id rect01)]) + + ;; ==== Action + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + rect02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; The rect had the height override before the switch + (t/is (= (:height rect01) 150)) + (t/is (= (get-in rect01 [:selrect :height]) 150)) + ;; Since the variants have different heights (100 vs 200), the override is not preserved + (t/is (= (:height rect02') 200)) + ;; The selrect must be consistent with :height + (t/is (= (get-in rect02' [:selrect :height]) 200)))) + +(t/deftest test-switch-with-v-sizing-fix-selrect-consistent-different-widths + ;; mixed-sizing scenario: v-sizing=:fix but variants differ in WIDTH. + ;; switch-fixed-layout-geom-change-value is triggered (because v-sizing=:fix). + ;; Without the fix, the function returned prev-width for the non-:fix dimension, + ;; leaving selrect.width inconsistent with :width. + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + (thv/add-variant-with-child + :v01 :c01 :m01 :c02 :m02 :r01 :r02 + {:child1-params {:width 100 :height 50 :layout-item-v-sizing :fix} + :child2-params {:width 200 :height 50 :layout-item-v-sizing :fix}}) + + (thc/instantiate-component :c01 + :copy01 + :children-labels [:copy-r01])) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + rect01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; Override width AND selrect consistently + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id rect01)} + (fn [shape] + (let [new-width 150 + sr (:selrect shape) + new-sr (-> sr + (assoc :width new-width) + (assoc :x2 (+ (:x1 sr) new-width)))] + (-> shape + (assoc :width new-width) + (assoc :selrect new-sr)))) + (:objects page) + {}) + + file (thf/apply-changes file changes) + page (thf/current-page file) + rect01 (get-in page [:objects (:id rect01)]) + + ;; ==== Action + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + rect02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; The rect had the width override before the switch + (t/is (= (:width rect01) 150)) + (t/is (= (get-in rect01 [:selrect :width]) 150)) + ;; Since the variants have different widths (100 vs 200), the override is not preserved + ;; (v-sizing=:fix does not affect the horizontal dimension) + (t/is (= (:width rect02') 200)) + ;; The selrect must be consistent with :width + (t/is (= (get-in rect02' [:selrect :width]) 200)) + ;; v-sizing is preserved + (t/is (= (:layout-item-v-sizing rect02') :fix)))) + +(t/deftest test-switch-with-h-sizing-fix-selrect-consistent-different-heights + ;; mixed-sizing scenario: h-sizing=:fix but variants differ in HEIGHT. + ;; switch-fixed-layout-geom-change-value is triggered (because h-sizing=:fix). + ;; Without the fix, the function returned prev-height for the non-:fix dimension, + ;; leaving selrect.height inconsistent with :height. + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + (thv/add-variant-with-child + :v01 :c01 :m01 :c02 :m02 :r01 :r02 + {:child1-params {:width 50 :height 100 :layout-item-h-sizing :fix} + :child2-params {:width 50 :height 200 :layout-item-h-sizing :fix}}) + + (thc/instantiate-component :c01 + :copy01 + :children-labels [:copy-r01])) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + rect01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; Override height AND selrect consistently + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id rect01)} + (fn [shape] + (let [new-height 150 + sr (:selrect shape) + new-sr (-> sr + (assoc :height new-height) + (assoc :y2 (+ (:y1 sr) new-height)))] + (-> shape + (assoc :height new-height) + (assoc :selrect new-sr)))) + (:objects page) + {}) + + file (thf/apply-changes file changes) + page (thf/current-page file) + rect01 (get-in page [:objects (:id rect01)]) + + ;; ==== Action + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + rect02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; The rect had the height override before the switch + (t/is (= (:height rect01) 150)) + (t/is (= (get-in rect01 [:selrect :height]) 150)) + ;; Since the variants have different heights (100 vs 200), the override is not preserved + ;; (h-sizing=:fix does not affect the vertical dimension) + (t/is (= (:height rect02') 200)) + ;; The selrect must be consistent with :height + (t/is (= (get-in rect02' [:selrect :height]) 200)) + ;; h-sizing is preserved + (t/is (= (:layout-item-h-sizing rect02') :fix)))) + +;; ============================================================ +;; FIXED-SIZING: "SAME-SIZE → PRESERVE OVERRIDE" PATH TESTS +;; These tests exercise the branch inside switch-fixed-layout-geom-change-value +;; where variants share the same value in the non-:fix dimension: +;; (if (= origin-dim current-dim) prev-dim current-dim) +;; When origin-dim == current-dim the user's override for that dimension +;; must be preserved after the switch. +;; ============================================================ + +(t/deftest test-switch-with-h-sizing-fix-same-height-override-preserved + ;; h-sizing=:fix, variants have SAME height (non-:fix dim, same-size). + ;; switch-fixed-layout-geom-change-value must return prev-height for the + ;; non-:fix dimension because origin-height == current-height. + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + (thv/add-variant-with-child + :v01 :c01 :m01 :c02 :m02 :r01 :r02 + {:child1-params {:width 100 :height 50 :layout-item-h-sizing :fix} + :child2-params {:width 200 :height 50 :layout-item-h-sizing :fix}}) + (thc/instantiate-component :c01 + :copy01 + :children-labels [:copy-r01])) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + rect01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; Override height (the non-:fix dimension) and selrect consistently + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id rect01)} + (fn [shape] + (let [new-height 75 + sr (:selrect shape) + new-sr (-> sr + (assoc :height new-height) + (assoc :y2 (+ (:y1 sr) new-height)))] + (-> shape + (assoc :height new-height) + (assoc :selrect new-sr)))) + (:objects page) + {}) + + file (thf/apply-changes file changes) + page (thf/current-page file) + rect01 (get-in page [:objects (:id rect01)]) + + ;; ==== Action + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + rect02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; The rect had the height override 75 before the switch + (t/is (= (:height rect01) 75)) + ;; h-sizing=:fix means width always takes the new component's value + (t/is (= (:width rect02') 200)) + ;; Height (non-:fix dim) is preserved because both variants have same height (50) + (t/is (= (:height rect02') 75)) + ;; selrect must be consistent with the preserved height + (t/is (= (get-in rect02' [:selrect :height]) 75)) + (t/is (= (get-in rect02' [:selrect :width]) 200)) + ;; h-sizing is preserved + (t/is (= (:layout-item-h-sizing rect02') :fix)))) + +(t/deftest test-switch-with-v-sizing-fix-same-width-override-preserved + ;; v-sizing=:fix, variants have SAME width (non-:fix dim, same-size). + ;; switch-fixed-layout-geom-change-value must return prev-width for the + ;; non-:fix dimension because origin-width == current-width. + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + (thv/add-variant-with-child + :v01 :c01 :m01 :c02 :m02 :r01 :r02 + {:child1-params {:width 100 :height 50 :layout-item-v-sizing :fix} + :child2-params {:width 100 :height 100 :layout-item-v-sizing :fix}}) + (thc/instantiate-component :c01 + :copy01 + :children-labels [:copy-r01])) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + rect01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; Override width (the non-:fix dimension) and selrect consistently + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id rect01)} + (fn [shape] + (let [new-width 150 + sr (:selrect shape) + new-sr (-> sr + (assoc :width new-width) + (assoc :x2 (+ (:x1 sr) new-width)))] + (-> shape + (assoc :width new-width) + (assoc :selrect new-sr)))) + (:objects page) + {}) + + file (thf/apply-changes file changes) + page (thf/current-page file) + rect01 (get-in page [:objects (:id rect01)]) + + ;; ==== Action + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + rect02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; The rect had the width override 150 before the switch + (t/is (= (:width rect01) 150)) + ;; Width (non-:fix dim) is preserved because both variants have same width (100) + (t/is (= (:width rect02') 150)) + ;; selrect must be consistent with the preserved width + (t/is (= (get-in rect02' [:selrect :width]) 150)) + ;; v-sizing=:fix means height always takes the new component's value + (t/is (= (:height rect02') 100)) + (t/is (= (get-in rect02' [:selrect :height]) 100)) + ;; v-sizing is preserved + (t/is (= (:layout-item-v-sizing rect02') :fix)))) + +(t/deftest test-switch-with-both-sizing-fix-overrides-discarded + ;; When both h-sizing=:fix and v-sizing=:fix, switch-fixed-layout-geom-change-value + ;; always uses current-width and current-height (the new component's values). + ;; Both width and height overrides are discarded because :fix always + ;; defers to the new component's dimension regardless of same-size or not. + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + (thv/add-variant-with-child + :v01 :c01 :m01 :c02 :m02 :r01 :r02 + {:child1-params {:width 100 :height 50 + :layout-item-h-sizing :fix + :layout-item-v-sizing :fix} + :child2-params {:width 200 :height 100 + :layout-item-h-sizing :fix + :layout-item-v-sizing :fix}}) + (thc/instantiate-component :c01 + :copy01 + :children-labels [:copy-r01])) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + rect01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; Override both width and height (and selrect) consistently + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id rect01)} + (fn [shape] + (let [new-width 150 + new-height 75 + sr (:selrect shape) + new-sr (-> sr + (assoc :width new-width) + (assoc :height new-height) + (assoc :x2 (+ (:x1 sr) new-width)) + (assoc :y2 (+ (:y1 sr) new-height)))] + (-> shape + (assoc :width new-width) + (assoc :height new-height) + (assoc :selrect new-sr)))) + (:objects page) + {}) + + file (thf/apply-changes file changes) + page (thf/current-page file) + rect01 (get-in page [:objects (:id rect01)]) + + ;; ==== Action + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + rect02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; The rect had both overrides before the switch + (t/is (= (:width rect01) 150)) + (t/is (= (:height rect01) 75)) + ;; With both sizing :fix, both dimensions take the new component's values + (t/is (= (:width rect02') 200)) + (t/is (= (:height rect02') 100)) + ;; selrect must be consistent + (t/is (= (get-in rect02' [:selrect :width]) 200)) + (t/is (= (get-in rect02' [:selrect :height]) 100)) + (t/is (= (:layout-item-h-sizing rect02') :fix)) + (t/is (= (:layout-item-v-sizing rect02') :fix)))) + +(t/deftest test-switch-same-size-variants-geometry-override-preserved + ;; When both variants have IDENTICAL dimensions (width=100, height=50), + ;; the guard that skips :selrect/:points must NOT fire + ;; (its condition `(or (not= origin.width current.width) ...)` is false). + ;; A geometry override should therefore be carried through correctly. + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + (thv/add-variant-with-child + :v01 :c01 :m01 :c02 :m02 :r01 :r02 + {:child1-params {:width 100 :height 50} + :child2-params {:width 100 :height 50}}) ; same size! + (thc/instantiate-component :c01 + :copy01 + :children-labels [:copy-r01])) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + rect01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; Override width AND selrect consistently (simulating a real resize) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id rect01)} + (fn [shape] + (let [new-width 150 + sr (:selrect shape) + new-sr (-> sr + (assoc :width new-width) + (assoc :x2 (+ (:x1 sr) new-width)))] + (-> shape + (assoc :width new-width) + (assoc :selrect new-sr)))) + (:objects page) + {}) + + file (thf/apply-changes file changes) + page (thf/current-page file) + rect01 (get-in page [:objects (:id rect01)]) + + ;; ==== Action + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + rect02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; The rect had the width override 150 before the switch + (t/is (= (:width rect01) 150)) + (t/is (= (get-in rect01 [:selrect :width]) 150)) + ;; Both variants are identical in size (100x50), so the override IS preserved + (t/is (= (:width rect02') 150)) + ;; The guard must not have suppressed :selrect — it should be consistent + (t/is (= (get-in rect02' [:selrect :width]) 150)))) \ No newline at end of file From a403175d5c447bfbc4354a2d8d032ddbc8a4a618 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 13 Apr 2026 11:34:15 +0200 Subject: [PATCH 006/162] :bug: Fix TypeError in sd-token-uuid when resolving tokens interactively (#8929) The backtrace-tokens-tree function used a namespaced keyword :temp/id which clj->js converted to the JS property "temp/id". The sd-token-uuid function then tried to access .id on the sd-token top-level object, which was undefined, causing "Cannot read properties of undefined (reading uuid)". Fix by using the existing token :id instead of generating a temporary one, and read it from sd-token.original (matching sd-token-name pattern). --- common/src/app/common/types/tokens_lib.cljc | 8 ++--- .../src/app/main/data/style_dictionary.cljs | 35 ++++++++++++------- .../tokens/style_dictionary_test.cljs | 26 ++++++++++++++ 3 files changed, 51 insertions(+), 18 deletions(-) diff --git a/common/src/app/common/types/tokens_lib.cljc b/common/src/app/common/types/tokens_lib.cljc index 573dac181dc..5c392f2db97 100644 --- a/common/src/app/common/types/tokens_lib.cljc +++ b/common/src/app/common/types/tokens_lib.cljc @@ -485,17 +485,15 @@ (defn backtrace-tokens-tree "Convert tokens into a nested tree with their name as the path. - Generates a uuid per token to backtrace a token from an external source (StyleDictionary). + Uses the existing token :id to backtrace a token from an external source (StyleDictionary). The backtrace can't be the name as the name might not exist when the user is creating a token." [tokens] (reduce (fn [acc [_ token]] - (let [temp-id (random-uuid) - token (assoc token :temp/id temp-id) - path (get-token-path token)] + (let [path (get-token-path token)] (-> acc (assoc-in (concat [:tokens-tree] path) token) - (assoc-in [:ids temp-id] token)))) + (assoc-in [:ids (:id token)] token)))) {:tokens-tree {} :ids {}} tokens)) diff --git a/frontend/src/app/main/data/style_dictionary.cljs b/frontend/src/app/main/data/style_dictionary.cljs index 63a076f93f0..48320fd9a01 100644 --- a/frontend/src/app/main/data/style_dictionary.cljs +++ b/frontend/src/app/main/data/style_dictionary.cljs @@ -551,7 +551,7 @@ (.. sd-token -original -name)) (defn sd-token-uuid [^js sd-token] - (uuid (.-uuid (.-id ^js sd-token)))) + (uuid (.-uuid (.. sd-token -original -id)))) (defn resolve-tokens [tokens] @@ -560,15 +560,23 @@ (defn resolve-tokens-interactive "Interactive check of resolving tokens. - Uses a ids map to backtrace the original token from the resolved StyleDictionary token. - - We have to pass in all tokens from all sets in the entire library to style dictionary - so we know if references are missing / to resolve them and possibly show interactive previews (in the tokens form) to the user. - - Since we're using the :name path as the identifier we might be throwing away or overriding tokens in the tree that we pass to StyleDictionary. - - So to get back the original token from the resolved sd-token (see my updates for what an sd-token is) we include a temporary :id for the token that we pass to StyleDictionary, - this way after the resolving computation we can restore any token, even clashing ones with the same :name path by just looking up that :id in the ids map." + Uses a ids map to backtrace the original token from the resolved + StyleDictionary token. + + We have to pass in all tokens from all sets in the entire library to + style dictionary so we know if references are missing / to resolve + them and possibly show interactive previews (in the tokens form) to + the user. + + Since we're using the :name path as the identifier we might be + throwing away or overriding tokens in the tree that we pass to + StyleDictionary. + + So to get back the original token from the resolved sd-token (see my + updates for what an sd-token is) we include a temporary :id for the + token that we pass to StyleDictionary, this way after the resolving + computation we can restore any token, even clashing ones with the + same :name path by just looking up that :id in the ids map." [tokens] (let [{:keys [tokens-tree ids]} (ctob/backtrace-tokens-tree tokens)] (resolve-tokens-tree tokens-tree #(get ids (sd-token-uuid %))))) @@ -584,10 +592,11 @@ (defonce !tokens-cache (atom nil)) (defn use-resolved-tokens - "The StyleDictionary process function is async, so we can't use resolved values directly. + "The StyleDictionary process function is async, so we can't use + resolved values directly. - This hook will return the unresolved tokens as state until they are processed, - then the state will be updated with the resolved tokens." + This hook will return the unresolved tokens as state until they are + processed, then the state will be updated with the resolved tokens." [tokens & {:keys [cache-atom interactive?] :or {cache-atom !tokens-cache} :as config}] diff --git a/frontend/test/frontend_tests/tokens/style_dictionary_test.cljs b/frontend/test/frontend_tests/tokens/style_dictionary_test.cljs index bf8aad0c352..1f1609f3444 100644 --- a/frontend/test/frontend_tests/tokens/style_dictionary_test.cljs +++ b/frontend/test/frontend_tests/tokens/style_dictionary_test.cljs @@ -57,3 +57,29 @@ (t/is (= :error.token/number-too-large (get-in resolved-tokens ["borderRadius.largeFn" :errors 0 :error/code]))) (done)))))))) + +(t/deftest resolve-tokens-interactive-test + (t/async + done + (t/testing "resolves tokens interactively using backtrace ids map" + (let [tokens (-> (ctob/make-tokens-lib) + (ctob/add-set (ctob/make-token-set :id (cthi/new-id! :core-set) + :name "core")) + (ctob/add-token (cthi/id :core-set) + (ctob/make-token {:name "borderRadius.sm" + :value "12px" + :type :border-radius})) + (ctob/add-token (cthi/id :core-set) + (ctob/make-token {:value "{borderRadius.sm} * 2" + :name "borderRadius.md" + :type :border-radius})) + (ctob/get-all-tokens-map))] + (-> (sd/resolve-tokens-interactive tokens) + (rx/sub! + (fn [resolved-tokens] + (t/is (= 12 (get-in resolved-tokens ["borderRadius.sm" :resolved-value]))) + (t/is (= "px" (get-in resolved-tokens ["borderRadius.sm" :unit]))) + (t/is (= 24 (get-in resolved-tokens ["borderRadius.md" :resolved-value]))) + (t/is (= "px" (get-in resolved-tokens ["borderRadius.md" :unit]))) + (done)))))))) + From e511576f664f50110789df113a26fa1391f57b1e Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 9 Apr 2026 08:58:00 +0000 Subject: [PATCH 007/162] :bug: Normalize PathData coordinates to safe integer bounds on read Add normalize-coord helper function that clamps coordinate values to max-safe-int and min-safe-int bounds when reading segments from PathData binary buffer. Applies normalization to read-segment, impl-walk, impl-reduce, and impl-lookup functions to ensure coordinates remain within safe bounds. Add corresponding test to verify out-of-bounds coordinates are properly clamped when reading PathData. Signed-off-by: Andrey Antukh --- common/src/app/common/types/path/impl.cljc | 114 ++++++++++-------- .../common_tests/types/path_data_test.cljc | 58 +++++++++ 2 files changed, 119 insertions(+), 53 deletions(-) diff --git a/common/src/app/common/types/path/impl.cljc b/common/src/app/common/types/path/impl.cljc index 2db3fcb2e94..3eafdee042e 100644 --- a/common/src/app/common/types/path/impl.cljc +++ b/common/src/app/common/types/path/impl.cljc @@ -30,6 +30,18 @@ #?(:clj (set! *warn-on-reflection* true)) (def ^:const SEGMENT-U8-SIZE 28) + +(defn- normalize-coord + "Normalize a coordinate value to be within safe integer bounds. + Clamps values greater than max-safe-int to max-safe-int, + and values less than min-safe-int to min-safe-int. + Always returns a double." + [v] + (cond + (> v sm/max-safe-int) (double sm/max-safe-int) + (< v sm/min-safe-int) (double sm/min-safe-int) + :else (double v))) + (def ^:const SEGMENT-U32-SIZE (/ SEGMENT-U8-SIZE 4)) (defprotocol IPathData @@ -121,12 +133,12 @@ (if (< index size) (let [offset (* index SEGMENT-U8-SIZE) type (buf/read-short buffer offset) - c1x (buf/read-float buffer (+ offset 4)) - c1y (buf/read-float buffer (+ offset 8)) - c2x (buf/read-float buffer (+ offset 12)) - c2y (buf/read-float buffer (+ offset 16)) - x (buf/read-float buffer (+ offset 20)) - y (buf/read-float buffer (+ offset 24)) + c1x (normalize-coord (buf/read-float buffer (+ offset 4))) + c1y (normalize-coord (buf/read-float buffer (+ offset 8))) + c2x (normalize-coord (buf/read-float buffer (+ offset 12))) + c2y (normalize-coord (buf/read-float buffer (+ offset 16))) + x (normalize-coord (buf/read-float buffer (+ offset 20))) + y (normalize-coord (buf/read-float buffer (+ offset 24))) type (case type 1 :move-to 2 :line-to @@ -148,12 +160,12 @@ (if (< index size) (let [offset (* index SEGMENT-U8-SIZE) type (buf/read-short buffer offset) - c1x (buf/read-float buffer (+ offset 4)) - c1y (buf/read-float buffer (+ offset 8)) - c2x (buf/read-float buffer (+ offset 12)) - c2y (buf/read-float buffer (+ offset 16)) - x (buf/read-float buffer (+ offset 20)) - y (buf/read-float buffer (+ offset 24)) + c1x (normalize-coord (buf/read-float buffer (+ offset 4))) + c1y (normalize-coord (buf/read-float buffer (+ offset 8))) + c2x (normalize-coord (buf/read-float buffer (+ offset 12))) + c2y (normalize-coord (buf/read-float buffer (+ offset 16))) + x (normalize-coord (buf/read-float buffer (+ offset 20))) + y (normalize-coord (buf/read-float buffer (+ offset 24))) type (case type 1 :move-to 2 :line-to @@ -172,12 +184,12 @@ [buffer index f] (let [offset (* index SEGMENT-U8-SIZE) type (buf/read-short buffer offset) - c1x (buf/read-float buffer (+ offset 4)) - c1y (buf/read-float buffer (+ offset 8)) - c2x (buf/read-float buffer (+ offset 12)) - c2y (buf/read-float buffer (+ offset 16)) - x (buf/read-float buffer (+ offset 20)) - y (buf/read-float buffer (+ offset 24)) + c1x (normalize-coord (buf/read-float buffer (+ offset 4))) + c1y (normalize-coord (buf/read-float buffer (+ offset 8))) + c2x (normalize-coord (buf/read-float buffer (+ offset 12))) + c2y (normalize-coord (buf/read-float buffer (+ offset 16))) + x (normalize-coord (buf/read-float buffer (+ offset 20))) + y (normalize-coord (buf/read-float buffer (+ offset 24))) type (case type 1 :move-to 2 :line-to @@ -252,31 +264,31 @@ (let [offset (* index SEGMENT-U8-SIZE) type (buf/read-short buffer offset)] (case (long type) - 1 (let [x (buf/read-float buffer (+ offset 20)) - y (buf/read-float buffer (+ offset 24))] + 1 (let [x (normalize-coord (buf/read-float buffer (+ offset 20))) + y (normalize-coord (buf/read-float buffer (+ offset 24)))] {:command :move-to - :params {:x (double x) - :y (double y)}}) + :params {:x x + :y y}}) - 2 (let [x (buf/read-float buffer (+ offset 20)) - y (buf/read-float buffer (+ offset 24))] + 2 (let [x (normalize-coord (buf/read-float buffer (+ offset 20))) + y (normalize-coord (buf/read-float buffer (+ offset 24)))] {:command :line-to - :params {:x (double x) - :y (double y)}}) - - 3 (let [c1x (buf/read-float buffer (+ offset 4)) - c1y (buf/read-float buffer (+ offset 8)) - c2x (buf/read-float buffer (+ offset 12)) - c2y (buf/read-float buffer (+ offset 16)) - x (buf/read-float buffer (+ offset 20)) - y (buf/read-float buffer (+ offset 24))] + :params {:x x + :y y}}) + + 3 (let [c1x (normalize-coord (buf/read-float buffer (+ offset 4))) + c1y (normalize-coord (buf/read-float buffer (+ offset 8))) + c2x (normalize-coord (buf/read-float buffer (+ offset 12))) + c2y (normalize-coord (buf/read-float buffer (+ offset 16))) + x (normalize-coord (buf/read-float buffer (+ offset 20))) + y (normalize-coord (buf/read-float buffer (+ offset 24)))] {:command :curve-to - :params {:x (double x) - :y (double y) - :c1x (double c1x) - :c1y (double c1y) - :c2x (double c2x) - :c2y (double c2y)}}) + :params {:x x + :y y + :c1x c1x + :c1y c1y + :c2x c2x + :c2y c2y}}) 4 {:command :close-path :params {}} @@ -666,8 +678,6 @@ (defn from-plain "Create a PathData instance from plain data structures" [segments] - (assert (check-plain-content segments)) - (let [total (count segments) buffer (buf/allocate (* total SEGMENT-U8-SIZE))] (loop [index 0] @@ -677,30 +687,28 @@ (case (get segment :command) :move-to (let [params (get segment :params) - x (float (get params :x)) - y (float (get params :y))] + x (normalize-coord (get params :x)) + y (normalize-coord (get params :y))] (buf/write-short buffer offset 1) (buf/write-float buffer (+ offset 20) x) (buf/write-float buffer (+ offset 24) y)) :line-to (let [params (get segment :params) - x (float (get params :x)) - y (float (get params :y))] - + x (normalize-coord (get params :x)) + y (normalize-coord (get params :y))] (buf/write-short buffer offset 2) (buf/write-float buffer (+ offset 20) x) (buf/write-float buffer (+ offset 24) y)) :curve-to (let [params (get segment :params) - x (float (get params :x)) - y (float (get params :y)) - c1x (float (get params :c1x x)) - c1y (float (get params :c1y y)) - c2x (float (get params :c2x x)) - c2y (float (get params :c2y y))] - + x (normalize-coord (get params :x)) + y (normalize-coord (get params :y)) + c1x (normalize-coord (get params :c1x x)) + c1y (normalize-coord (get params :c1y y)) + c2x (normalize-coord (get params :c2x x)) + c2y (normalize-coord (get params :c2y y))] (buf/write-short buffer offset 3) (buf/write-float buffer (+ offset 4) c1x) (buf/write-float buffer (+ offset 8) c1y) diff --git a/common/test/common_tests/types/path_data_test.cljc b/common/test/common_tests/types/path_data_test.cljc index 252334b4598..e4d2881b183 100644 --- a/common/test/common_tests/types/path_data_test.cljc +++ b/common/test/common_tests/types/path_data_test.cljc @@ -13,6 +13,7 @@ [app.common.geom.rect :as grc] [app.common.math :as mth] [app.common.pprint :as pp] + [app.common.schema :as sm] [app.common.transit :as trans] [app.common.types.path :as path] [app.common.types.path.bool :as path.bool] @@ -1418,3 +1419,60 @@ ;; Verify first and last entries specifically (t/is (= :move-to (first seq-types))) (t/is (= :close-path (last seq-types)))))) + +(t/deftest path-data-read-normalizes-out-of-bounds-coordinates + (let [max-safe (double sm/max-safe-int) + min-safe (double sm/min-safe-int) + ;; Create content with values exceeding safe bounds + content-with-out-of-bounds + [{:command :move-to :params {:x (+ max-safe 1000.0) :y (- min-safe 1000.0)}} + {:command :line-to :params {:x (- min-safe 500.0) :y (+ max-safe 500.0)}} + {:command :curve-to :params + {:c1x (+ max-safe 200.0) :c1y (- min-safe 200.0) + :c2x (+ max-safe 300.0) :c2y (- min-safe 300.0) + :x (+ max-safe 400.0) :y (- min-safe 400.0)}} + {:command :close-path :params {}}] + + ;; Create PathData from the content + pdata (path/content content-with-out-of-bounds) + + ;; Read it back + result (vec pdata)] + + (t/testing "Coordinates exceeding max-safe-int are clamped to max-safe-int" + (let [move-to (first result) + line-to (second result)] + (t/is (= max-safe (:x (:params move-to))) "x in move-to should be clamped to max-safe-int") + (t/is (= min-safe (:y (:params move-to))) "y in move-to should be clamped to min-safe-int") + (t/is (= min-safe (:x (:params line-to))) "x in line-to should be clamped to min-safe-int") + (t/is (= max-safe (:y (:params line-to))) "y in line-to should be clamped to max-safe-int"))) + + (t/testing "Curve-to coordinates are clamped" + (let [curve-to (nth result 2)] + (t/is (= max-safe (:c1x (:params curve-to))) "c1x should be clamped") + (t/is (= min-safe (:c1y (:params curve-to))) "c1y should be clamped") + (t/is (= max-safe (:c2x (:params curve-to))) "c2x should be clamped") + (t/is (= min-safe (:c2y (:params curve-to))) "c2y should be clamped") + (t/is (= max-safe (:x (:params curve-to))) "x should be clamped") + (t/is (= min-safe (:y (:params curve-to))) "y should be clamped"))) + + (t/testing "-lookup normalizes coordinates" + (let [move-to (path.impl/-lookup pdata 0 (fn [_ _ _ _ _ x y] {:x x :y y}))] + (t/is (= max-safe (:x move-to)) "lookup x should be clamped") + (t/is (= min-safe (:y move-to)) "lookup y should be clamped"))) + + (t/testing "-walk normalizes coordinates" + (let [coords (path.impl/-walk pdata + (fn [_ _ _ _ _ x y] + (when (and x y) {:x x :y y})) + [])] + (t/is (= max-safe (:x (first coords))) "walk first x should be clamped") + (t/is (= min-safe (:y (first coords))) "walk first y should be clamped"))) + + (t/testing "-reduce normalizes coordinates" + (let [[move-res] (path.impl/-reduce pdata + (fn [acc _ _ _ _ _ _ x y] + (if (and x y) (conj acc {:x x :y y}) acc)) + [])] + (t/is (= max-safe (:x move-res)) "reduce first x should be clamped") + (t/is (= min-safe (:y move-res)) "reduce first y should be clamped"))))) From 2eaf117b56c0f1ae0b3b407c2983fb4930486e2c Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 9 Apr 2026 13:59:34 +0000 Subject: [PATCH 008/162] :bug: Fix swapped arguments in CLJS PathData -nth with default The CLJS implementation of PathData's -nth protocol method had swapped arguments in the 3-arity version (with default value). The call (d/in-range? i size) should be (d/in-range? size i) to match the CLJ implementation. With swapped args, valid indices always returned the default value, and invalid indices attempted out-of-bounds buffer reads. --- common/src/app/common/types/path/impl.cljc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/app/common/types/path/impl.cljc b/common/src/app/common/types/path/impl.cljc index 3eafdee042e..bf3586fb0db 100644 --- a/common/src/app/common/types/path/impl.cljc +++ b/common/src/app/common/types/path/impl.cljc @@ -474,7 +474,7 @@ nil)) (-nth [_ i default] - (if (d/in-range? i size) + (if (d/in-range? size i) (read-segment buffer i) default)) From 8d1906f56e02dfa9be5739710bd541151182a0e9 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 9 Apr 2026 13:59:42 +0000 Subject: [PATCH 009/162] :bug: Fix ^:cosnt typo to ^:const on bool-group-style-properties The metadata key was misspelled as :cosnt instead of :const, preventing the compiler from recognizing the Var as a compile-time constant. --- common/src/app/common/types/path.cljc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/src/app/common/types/path.cljc b/common/src/app/common/types/path.cljc index 757b9f1e959..f3b7c635abe 100644 --- a/common/src/app/common/types/path.cljc +++ b/common/src/app/common/types/path.cljc @@ -23,7 +23,7 @@ #?(:clj (set! *warn-on-reflection* true)) -(def ^:cosnt bool-group-style-properties bool/group-style-properties) +(def ^:const bool-group-style-properties bool/group-style-properties) (def ^:const bool-style-properties bool/style-properties) (defn get-default-bool-fills @@ -79,7 +79,7 @@ (defn close-subpaths "Given a content, searches a path for possible subpaths that can create closed loops and merge them; then return the transformed path - conten as PathData instance" + content as PathData instance" [content] (-> (subpath/close-subpaths content) (impl/from-plain))) From d6045c80a10e63f240b45829641128a861cae1eb Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 9 Apr 2026 14:00:04 +0000 Subject: [PATCH 010/162] :lipstick: Fix docstrings and clarify filter expression in path namespaces - Fix 'conten' typo to 'content' in path.cljc docstring - Fix 'curvle' typo to 'curve' in shape_to_path.cljc docstring - Replace confusing XOR-style filter with readable (contains? #{:line-to :curve-to} ...) in bool.cljc - Align handler-indices and opposite-index docstrings with matching API in path.cljc --- common/src/app/common/types/path/segment.cljc | 4 ++-- common/src/app/common/types/path/shape_to_path.cljc | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/common/src/app/common/types/path/segment.cljc b/common/src/app/common/types/path/segment.cljc index 9eb36d7a12c..bcbbe8eedac 100644 --- a/common/src/app/common/types/path/segment.cljc +++ b/common/src/app/common/types/path/segment.cljc @@ -62,7 +62,7 @@ (map (fn [[index _]] index)))) (defn handler-indices - "Return an index where the key is the positions and the values the handlers" + "Returns [[index prefix] ...] of all handlers associated with point." [content point] (->> (d/with-prev content) (d/enumerate) @@ -76,7 +76,7 @@ []))))) (defn opposite-index - "Calculates the opposite index given a prefix and an index" + "Calculates the opposite handler index given a content, index and prefix." [content index prefix] (let [point (if (= prefix :c2) diff --git a/common/src/app/common/types/path/shape_to_path.cljc b/common/src/app/common/types/path/shape_to_path.cljc index 8641ee556eb..cc0f0c90607 100644 --- a/common/src/app/common/types/path/shape_to_path.cljc +++ b/common/src/app/common/types/path/shape_to_path.cljc @@ -32,7 +32,7 @@ (d/without-keys shape dissoc-attrs)) (defn- make-corner-arc - "Creates a curvle corner for border radius" + "Creates a curve corner for border radius" [from to corner radius] (let [x (case corner :top-left (:x from) From cbe9d315990cf88d2a17a42ba6d5c4b9862f79a7 Mon Sep 17 00:00:00 2001 From: Luis de Dios Date: Mon, 13 Apr 2026 11:59:19 +0200 Subject: [PATCH 011/162] :bug: Fix dashboard navigation tabs overlap with content when scrolling (#8937) Co-authored-by: Andrey Antukh --- CHANGES.md | 2 ++ frontend/src/app/main/ui/dashboard/deleted.scss | 1 + 2 files changed, 3 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 6cd8b74ec30..fdf35b5cbd0 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -7,6 +7,8 @@ ### :bug: Bugs fixed - Fix component "broken" after switch variant [Taiga #12984](https://tree.taiga.io/project/penpot/issue/12984) +- Fix dashboard navigation tabs overlap with projects content when scrolling [Taiga #13962](https://tree.taiga.io/project/penpot/issue/13962) + ## 2.14.2 diff --git a/frontend/src/app/main/ui/dashboard/deleted.scss b/frontend/src/app/main/ui/dashboard/deleted.scss index 3d8d5bf8c75..8a04eda993b 100644 --- a/frontend/src/app/main/ui/dashboard/deleted.scss +++ b/frontend/src/app/main/ui/dashboard/deleted.scss @@ -51,6 +51,7 @@ padding: var(--sp-xxl) var(--sp-xxl) var(--sp-s) var(--sp-xxl); position: sticky; top: 0; + z-index: $z-index-100; } .nav-inside { From 443fb6074330e79c5265942848c878e631768bfe Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 13 Apr 2026 09:09:03 +0200 Subject: [PATCH 012/162] :bug: Fix highlight on frames after rename (#8938) --- frontend/src/app/main/ui/workspace/viewport/widgets.cljs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/viewport/widgets.cljs b/frontend/src/app/main/ui/workspace/viewport/widgets.cljs index 9d101982a77..104a409dd3d 100644 --- a/frontend/src/app/main/ui/workspace/viewport/widgets.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/widgets.cljs @@ -162,14 +162,16 @@ (let [name-input (mf/ref-val ref) name (str/trim (dom/get-value name-input))] (reset! edition* false) - (st/emit! (dw/end-rename-shape frame-id name))))) + (st/emit! (dw/end-rename-shape frame-id name)) + (on-frame-leave frame-id)))) cancel-edit (mf/use-fn (mf/deps frame-id) (fn [] (reset! edition* false) - (st/emit! (dw/end-rename-shape frame-id nil)))) + (st/emit! (dw/end-rename-shape frame-id nil)) + (on-frame-leave frame-id))) on-key-down (mf/use-fn From 9c44f5bf656463f7af5132200886208ac4a5ba82 Mon Sep 17 00:00:00 2001 From: Aitor Moreno Date: Mon, 13 Apr 2026 10:00:56 +0200 Subject: [PATCH 013/162] :bug: Fix text editor v1 focus not being handled correctly (#8942) --- CHANGES.md | 1 + frontend/src/app/main/data/workspace/texts.cljs | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index fdf35b5cbd0..dc2d14a60bc 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -8,6 +8,7 @@ - Fix component "broken" after switch variant [Taiga #12984](https://tree.taiga.io/project/penpot/issue/12984) - Fix dashboard navigation tabs overlap with projects content when scrolling [Taiga #13962](https://tree.taiga.io/project/penpot/issue/13962) +- Fix text editor v1 focus [Taiga #13961](https://tree.taiga.io/project/penpot/issue/13961) ## 2.14.2 diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index a7d6a9d4ca9..f08c942c941 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -81,7 +81,13 @@ (effect [_ state _] (let [editor (:workspace-editor state) element (when editor (.-element editor))] - (when (and element (.-focus element)) + (cond + ;; V1 (DraftEditor) + (.-focus editor) + (ts/schedule #(.focus ^js editor)) + + ;; V2 + (and element (.-focus element)) (ts/schedule #(.focus ^js element))))))) (defn gen-name From 28f65fec9155be03a4fe94a95dc7aae09b731262 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 13 Apr 2026 12:15:17 +0200 Subject: [PATCH 014/162] :books: Update changelog --- CHANGES.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index dc2d14a60bc..58231d2543b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -4,11 +4,19 @@ ### :sparkles: New features & Enhancements +- Add webp export format to plugin types [Github #8870](https://github.com/penpot/penpot/pull/8870) + ### :bug: Bugs fixed - Fix component "broken" after switch variant [Taiga #12984](https://tree.taiga.io/project/penpot/issue/12984) +- Fix variants corner cases with selrect and points [Github #8882](https://github.com/penpot/penpot/pull/8882) - Fix dashboard navigation tabs overlap with projects content when scrolling [Taiga #13962](https://tree.taiga.io/project/penpot/issue/13962) - Fix text editor v1 focus [Taiga #13961](https://tree.taiga.io/project/penpot/issue/13961) +- Fix highlight on frames after rename [Github #8938](https://github.com/penpot/penpot/pull/8938) +- Fix TypeError in sd-token-uuid when resolving tokens interactively [Github #8929](https://github.com/penpot/penpot/pull/8929) +- Fix path drawing preview passing shape instead of content to next-node +- Fix swapped arguments in CLJS PathData `-nth` with default +- Normalize PathData coordinates to safe integer bounds on read ## 2.14.2 From 0fc2050526558ef69480db72bc9b9c16a1188318 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 13 Apr 2026 15:00:47 +0200 Subject: [PATCH 015/162] :arrow_up: Update deps on root package.json --- package.json | 2 +- pnpm-lock.yaml | 106 ++++++++++++++++++++++++------------------------- 2 files changed, 54 insertions(+), 54 deletions(-) diff --git a/package.json b/package.json index c0351b106dc..26baa0d9891 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,6 @@ "@github/copilot": "^1.0.21", "@types/node": "^25.5.2", "esbuild": "^0.28.0", - "opencode-ai": "^1.4.0" + "opencode-ai": "^1.4.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d06094e10f6..276b2891e24 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ importers: specifier: ^0.28.0 version: 0.28.0 opencode-ai: - specifier: ^1.4.0 - version: 1.4.0 + specifier: ^1.4.3 + version: 1.4.3 packages: @@ -227,67 +227,67 @@ packages: engines: {node: '>=18'} hasBin: true - opencode-ai@1.4.0: - resolution: {integrity: sha512-Cb5Vo5Rl1gvOIXC8gtgupwoa5+rufsp+6u5tIxIYLl5fReX+P2eugLSOkKH2KB5GC6BwxaEvapEZiPvQYsZSXA==} + opencode-ai@1.4.3: + resolution: {integrity: sha512-WwCSrLgJiS+sLIWoi9pa62vAw3l6VI3a+ShhjDDMUJBBG2FxU18xEhk8xhEedLMKyHo1p0nwD41+iKZ1y+rdAw==} hasBin: true - opencode-darwin-arm64@1.4.0: - resolution: {integrity: sha512-rXdrH1Oejb+220ZCzkd1P+tCP7IhLTyfRbUr89vzvEWVRueh0vr2hvyrGDVv9LAskZAt/hwY3Wnw9CzjtxocdQ==} + opencode-darwin-arm64@1.4.3: + resolution: {integrity: sha512-d/MT28Is5yhdFY+36AqKc5r31zx8lXTQIYblfn5R8kdhamXijZVGdD0pHl3eJc1ZolUHNwzg2B+IqV22uyU9GQ==} cpu: [arm64] os: [darwin] - opencode-darwin-x64-baseline@1.4.0: - resolution: {integrity: sha512-5xCXF8Xn9M2WQKZATc4llm9qrAc4JodmQj88Oibbz/rXIOr/A1ejXvaeqLOQkQyQweeEABlYXOf3eCiY5hx8Gw==} + opencode-darwin-x64-baseline@1.4.3: + resolution: {integrity: sha512-WTqf7WBNRZcv6pClqnN4F7X/T/osgcPGikNHkHUSLszKWg9flqz7Z68kHR4i9ae8Bn3ke9MQRgzRdOt2PgLL0w==} cpu: [x64] os: [darwin] - opencode-darwin-x64@1.4.0: - resolution: {integrity: sha512-PhBfT2EtPos7jcGBtVSz3+yKv2e1nQy1UrXiH4ILdSgwzroKU/0kMsRtWJeMPHIj1imUQmSVlnDcuxiCiCkozw==} + opencode-darwin-x64@1.4.3: + resolution: {integrity: sha512-8FUHeybVmaCYt4S2YmWcf32o/xa/ahCfI258bpWssrzs7Xg51JgUB/Csoble0I1mH7RpW39SKy/hHUtHGuJfJg==} cpu: [x64] os: [darwin] - opencode-linux-arm64-musl@1.4.0: - resolution: {integrity: sha512-1lc0Nj6OmtJF5NJn+AhV7rXOHzw+0p7rvXQu2iqd9V7DpUEbltyF6oiyuF54gBZjHpvSzFXu8a3YeTcuMEXdNA==} + opencode-linux-arm64-musl@1.4.3: + resolution: {integrity: sha512-3Ej2klaep8+fxcc44UyEuRpb/UFiNkwfzIDLIST83hFUtjzprjpTRqg6zHmOfzyfjNAaNpB4VZw6e9y3mGBpiQ==} cpu: [arm64] os: [linux] - opencode-linux-arm64@1.4.0: - resolution: {integrity: sha512-XEM3tP7DTrYDEYCe9jqC/xtgzPJpCZTfinc5DjcPuh2hx+iHCGSr9+qG7tRGeCyvD9ibAFewNtpco5Is49JCrg==} + opencode-linux-arm64@1.4.3: + resolution: {integrity: sha512-9jpVSOEF7TX3gPPAHVAsBT9XEO3LgYafI+IUmOzbBB9CDiVVNJw6JmEffmSpSxY4nkAh322xnMbNjVGEyXQBRA==} cpu: [arm64] os: [linux] - opencode-linux-x64-baseline-musl@1.4.0: - resolution: {integrity: sha512-URg1JXIUaNz0R4TLUT98rK2jozmh5ScAkkqxPK6LWj3XwJojJx23mJRNgLb26034PgNkUwXhrtdbnyPTSVlkqQ==} + opencode-linux-x64-baseline-musl@1.4.3: + resolution: {integrity: sha512-aned/3FQTHXXQv2PPKDprJwQaQkoadriQ6AByGhRl6/bHhSkhkiVl6cHHvYMKxYEwN4bVOydWhasfgm/xru/xw==} cpu: [x64] os: [linux] - opencode-linux-x64-baseline@1.4.0: - resolution: {integrity: sha512-GocjLGNgs41PLgSVPWxT3Do0StZkDB9QF3e3VIIAGzPmOVcpTZLdDvJPkZdRbRGcVfUcSRGquBbBgvwK9Zsw4w==} + opencode-linux-x64-baseline@1.4.3: + resolution: {integrity: sha512-HpzdgYaI90qqt0WokcyBhadgFQ0EYMhq4TZ4EcaSPuZTssS2Drb6kp70Si54uOJL/MUAdc9+E0BYYIAdOJ6h1g==} cpu: [x64] os: [linux] - opencode-linux-x64-musl@1.4.0: - resolution: {integrity: sha512-yGb1uNO++BtkZ7X/LGLax9ppaEvsmn5s5GaAqcrYj/SyJA5cL2IYzEeMYRAsrb0b81fQCSq5SLEiWiMq1o59oA==} + opencode-linux-x64-musl@1.4.3: + resolution: {integrity: sha512-ibUevyDxVrwkp6FWu8UBCBsrzlKDT/uEug2NHCKaHIwo9uwVf5zsL/0ueHYqmH14SHK+M6wzWewYk6WuW9f0zQ==} cpu: [x64] os: [linux] - opencode-linux-x64@1.4.0: - resolution: {integrity: sha512-Ops08slOBhHbKaYhERH8zMTjlM6mearVaA0udCDIx2fGqDbZRisoRyyI6Z44GPYBH02w8eGmvOvnF5fQYyq2fw==} + opencode-linux-x64@1.4.3: + resolution: {integrity: sha512-RS6TsDqTUrW5sefxD1KD9Xy9mSYGXAlr2DlGrdi8vNm9e/Bt4r4u557VB7f/Uj2CxTt2Gf7OWl08ZoPlxMJ5Gg==} cpu: [x64] os: [linux] - opencode-windows-arm64@1.4.0: - resolution: {integrity: sha512-47quWER7bCGRPWRXd3fsOyu5F/T4Y65FiS05kD+PYYV4iOJymlBQ34kpcJhNBOpQLYf9HSLbJ8AaJeb5dmUi+Q==} + opencode-windows-arm64@1.4.3: + resolution: {integrity: sha512-2ViH17WpIQbRVfQaOBMi49pu73gqTQYT/4/WxFjShmRagX40/KkG18fhvyDAZrBKfkhPtdwgFsFxMSYP9F6QCQ==} cpu: [arm64] os: [win32] - opencode-windows-x64-baseline@1.4.0: - resolution: {integrity: sha512-eGK9lF70XKzf9zBO7xil9+Vl7ZJUAgLK6bG6kug6RKxD6FsydY3Y6q/3tIW0+YZ0wyINOtEbTRfUHbO5TxV4FQ==} + opencode-windows-x64-baseline@1.4.3: + resolution: {integrity: sha512-SWYDli9SAKQd/pS/hVfuq1KEsc+gnAJdv+YtBmxaHOw57y0euqLwbGFUYFq78GAMGt/RnTYWZIEUbRK/ZiX3UA==} cpu: [x64] os: [win32] - opencode-windows-x64@1.4.0: - resolution: {integrity: sha512-DQ8CoxCsmFM38U1e73+hFuB6Wu0tbn6B4R7KwcL1JhvKvQaYYiukNfuLgcjjx5D7s81NP1SWlv6lw60wN0gq8g==} + opencode-windows-x64@1.4.3: + resolution: {integrity: sha512-UxmKDIw3t4XHST6JSUWHmSrCGIEK1LRTAOpO82HBC3XkIjH78gVIeauRR6RULjWAApmy9I1C3TukO2sDUi7Gvw==} cpu: [x64] os: [win32] @@ -434,55 +434,55 @@ snapshots: '@esbuild/win32-ia32': 0.28.0 '@esbuild/win32-x64': 0.28.0 - opencode-ai@1.4.0: + opencode-ai@1.4.3: optionalDependencies: - opencode-darwin-arm64: 1.4.0 - opencode-darwin-x64: 1.4.0 - opencode-darwin-x64-baseline: 1.4.0 - opencode-linux-arm64: 1.4.0 - opencode-linux-arm64-musl: 1.4.0 - opencode-linux-x64: 1.4.0 - opencode-linux-x64-baseline: 1.4.0 - opencode-linux-x64-baseline-musl: 1.4.0 - opencode-linux-x64-musl: 1.4.0 - opencode-windows-arm64: 1.4.0 - opencode-windows-x64: 1.4.0 - opencode-windows-x64-baseline: 1.4.0 + opencode-darwin-arm64: 1.4.3 + opencode-darwin-x64: 1.4.3 + opencode-darwin-x64-baseline: 1.4.3 + opencode-linux-arm64: 1.4.3 + opencode-linux-arm64-musl: 1.4.3 + opencode-linux-x64: 1.4.3 + opencode-linux-x64-baseline: 1.4.3 + opencode-linux-x64-baseline-musl: 1.4.3 + opencode-linux-x64-musl: 1.4.3 + opencode-windows-arm64: 1.4.3 + opencode-windows-x64: 1.4.3 + opencode-windows-x64-baseline: 1.4.3 - opencode-darwin-arm64@1.4.0: + opencode-darwin-arm64@1.4.3: optional: true - opencode-darwin-x64-baseline@1.4.0: + opencode-darwin-x64-baseline@1.4.3: optional: true - opencode-darwin-x64@1.4.0: + opencode-darwin-x64@1.4.3: optional: true - opencode-linux-arm64-musl@1.4.0: + opencode-linux-arm64-musl@1.4.3: optional: true - opencode-linux-arm64@1.4.0: + opencode-linux-arm64@1.4.3: optional: true - opencode-linux-x64-baseline-musl@1.4.0: + opencode-linux-x64-baseline-musl@1.4.3: optional: true - opencode-linux-x64-baseline@1.4.0: + opencode-linux-x64-baseline@1.4.3: optional: true - opencode-linux-x64-musl@1.4.0: + opencode-linux-x64-musl@1.4.3: optional: true - opencode-linux-x64@1.4.0: + opencode-linux-x64@1.4.3: optional: true - opencode-windows-arm64@1.4.0: + opencode-windows-arm64@1.4.3: optional: true - opencode-windows-x64-baseline@1.4.0: + opencode-windows-x64-baseline@1.4.3: optional: true - opencode-windows-x64@1.4.0: + opencode-windows-x64@1.4.3: optional: true undici-types@7.18.2: {} From f656266e5c38622fb8f01c925d93bf8a7c698e1d Mon Sep 17 00:00:00 2001 From: raguirref Date: Wed, 8 Apr 2026 11:30:19 -0600 Subject: [PATCH 016/162] :sparkles: Fix builder bool and media handling Fixes three concrete builder issues in common/files/builder:\n- Use bool type from shape when selecting style source for difference bools\n- Persist :strokes correctly (fix typo :stroks)\n- Validate add-file-media params after assigning default id\n\nAlso adds regression tests in common-tests.files-builder-test and registers them in runner. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: raguirref --- common/src/app/common/files/builder.cljc | 12 ++-- .../test/common_tests/files_builder_test.cljc | 72 +++++++++++++++++++ common/test/common_tests/runner.cljc | 2 + dev_server.pid | 1 + 4 files changed, 81 insertions(+), 6 deletions(-) create mode 100644 common/test/common_tests/files_builder_test.cljc create mode 100644 dev_server.pid diff --git a/common/src/app/common/files/builder.cljc b/common/src/app/common/files/builder.cljc index 4354986b8dd..84c0381f0da 100644 --- a/common/src/app/common/files/builder.cljc +++ b/common/src/app/common/files/builder.cljc @@ -356,7 +356,7 @@ :code :empty-children :hint "expected a group with at least one shape for creating a bool")) - (let [head (if (= type :difference) + (let [head (if (= (:bool-type bool-shape) :difference) (first children) (last children)) fills (if (and (contains? head :svg-attrs) (empty? (:fills head))) @@ -364,7 +364,7 @@ (get head :fills))] (-> bool-shape (assoc :fills fills) - (assoc :stroks (get head :strokes)))))) + (assoc :strokes (get head :strokes)))))) (defn add-bool [state params] @@ -573,10 +573,10 @@ file-id (get state ::current-file-id) - {:keys [id width height name]} - (-> params - (update :id default-uuid) - (check-add-file-media params))] + {:keys [id width height name]} + (-> params + (update :id default-uuid) + (check-add-file-media))] (-> state (update ::blobs assoc media-id blob) diff --git a/common/test/common_tests/files_builder_test.cljc b/common/test/common_tests/files_builder_test.cljc new file mode 100644 index 00000000000..23dd6c78ccd --- /dev/null +++ b/common/test/common_tests/files_builder_test.cljc @@ -0,0 +1,72 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns common-tests.files-builder-test + (:require + [app.common.files.builder :as fb] + [app.common.uuid :as uuid] + [clojure.test :as t])) + +(defn- stroke + [color] + [{:stroke-style :solid + :stroke-alignment :inner + :stroke-width 1 + :stroke-color color + :stroke-opacity 1}]) + +(t/deftest add-bool-uses-difference-head-style + (let [file-id (uuid/next) + page-id (uuid/next) + group-id (uuid/next) + child-a (uuid/next) + child-b (uuid/next) + state (-> (fb/create-state) + (fb/add-file {:id file-id :name "Test file"}) + (fb/add-page {:id page-id :name "Page 1"}) + (fb/add-group {:id group-id :name "Group A"}) + (fb/add-shape {:id child-a + :type :rect + :name "A" + :x 0 + :y 0 + :width 10 + :height 10 + :strokes (stroke "#ff0000")}) + (fb/add-shape {:id child-b + :type :rect + :name "B" + :x 20 + :y 0 + :width 10 + :height 10 + :strokes (stroke "#00ff00")}) + (fb/close-group) + (fb/add-bool {:group-id group-id + :type :difference})) + bool (fb/get-shape state group-id)] + (t/is (= :bool (:type bool))) + (t/is (= (stroke "#ff0000") (:strokes bool))))) + +(t/deftest add-file-media-validates-and-persists-media + (let [file-id (uuid/next) + page-id (uuid/next) + image-id (uuid/next) + state (-> (fb/create-state) + (fb/add-file {:id file-id :name "Test file"}) + (fb/add-page {:id page-id :name "Page 1"}) + (fb/add-file-media {:id image-id + :name "Image" + :width 128 + :height 64} + (fb/map->BlobWrapper {:mtype "image/png" + :size 42 + :blob nil}))) + media (get-in state [::fb/file-media image-id])] + (t/is (= image-id (::fb/last-id state))) + (t/is (= "Image" (:name media))) + (t/is (= 128 (:width media))) + (t/is (= 64 (:height media))))) diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index b8a9fc89349..489e71f7efc 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -11,6 +11,7 @@ [common-tests.colors-test] [common-tests.data-test] [common-tests.files-changes-test] + [common-tests.files-builder-test] [common-tests.files-migrations-test] [common-tests.geom-align-test] [common-tests.geom-bounds-map-test] @@ -82,6 +83,7 @@ 'common-tests.colors-test 'common-tests.data-test 'common-tests.files-changes-test + 'common-tests.files-builder-test 'common-tests.files-migrations-test 'common-tests.geom-align-test 'common-tests.geom-bounds-map-test diff --git a/dev_server.pid b/dev_server.pid new file mode 100644 index 00000000000..a8cd695ffa0 --- /dev/null +++ b/dev_server.pid @@ -0,0 +1 @@ +31390 From 94c6045dd99f2c09a3a26a492a60c9f21a1dcd25 Mon Sep 17 00:00:00 2001 From: raguirref Date: Wed, 8 Apr 2026 11:30:31 -0600 Subject: [PATCH 017/162] :fire: Remove accidental dev_server.pid Remove unrelated local pid file that was accidentally included in previous commit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: raguirref --- dev_server.pid | 1 - 1 file changed, 1 deletion(-) delete mode 100644 dev_server.pid diff --git a/dev_server.pid b/dev_server.pid deleted file mode 100644 index a8cd695ffa0..00000000000 --- a/dev_server.pid +++ /dev/null @@ -1 +0,0 @@ -31390 From e46b34efc7122178fc16b6789cc55d92a90519d5 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 13 Apr 2026 15:41:38 +0200 Subject: [PATCH 018/162] :paperclip: Fix formatting issues --- common/src/app/common/files/builder.cljc | 8 ++++---- common/test/common_tests/runner.cljc | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/common/src/app/common/files/builder.cljc b/common/src/app/common/files/builder.cljc index 84c0381f0da..cc3dd118792 100644 --- a/common/src/app/common/files/builder.cljc +++ b/common/src/app/common/files/builder.cljc @@ -573,10 +573,10 @@ file-id (get state ::current-file-id) - {:keys [id width height name]} - (-> params - (update :id default-uuid) - (check-add-file-media))] + {:keys [id width height name]} + (-> params + (update :id default-uuid) + (check-add-file-media))] (-> state (update ::blobs assoc media-id blob) diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index 489e71f7efc..6df82430771 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -10,8 +10,8 @@ [common-tests.buffer-test] [common-tests.colors-test] [common-tests.data-test] - [common-tests.files-changes-test] [common-tests.files-builder-test] + [common-tests.files-changes-test] [common-tests.files-migrations-test] [common-tests.geom-align-test] [common-tests.geom-bounds-map-test] From c39609b99154a5654dacd46d9c5d81fe1142c271 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 10:48:30 +0200 Subject: [PATCH 019/162] :recycle: Use shared singleton containers for React portals (#8957) Refactor use-portal-container to allocate one persistent
per logical category (:modal, :popup, :tooltip, :default) instead of creating a new div for every component instance. This keeps the DOM clean with at most four fixed portal containers and eliminates the arbitrary growth of empty
elements on document.body while preserving the removeChild race condition fix. --- .../src/app/main/ui/ds/tooltip/tooltip.cljs | 2 +- frontend/src/app/main/ui/hooks.cljs | 38 ++++++++++++++----- frontend/src/app/main/ui/modal.cljs | 2 +- .../tokens/management/context_menu.cljs | 2 +- .../tokens/management/node_context_menu.cljs | 2 +- .../tokens/themes/theme_selector.cljs | 2 +- 6 files changed, 33 insertions(+), 15 deletions(-) diff --git a/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs b/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs index 4751d81dcfe..05246f7f23c 100644 --- a/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs +++ b/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs @@ -160,7 +160,7 @@ tooltip-ref (mf/use-ref nil) - container (hooks/use-portal-container) + container (hooks/use-portal-container :tooltip) id (d/nilv id internal-id) diff --git a/frontend/src/app/main/ui/hooks.cljs b/frontend/src/app/main/ui/hooks.cljs index 42560cd8fe5..ae8ebd30d59 100644 --- a/frontend/src/app/main/ui/hooks.cljs +++ b/frontend/src/app/main/ui/hooks.cljs @@ -380,17 +380,35 @@ state)) +(defn- get-or-create-portal-container + "Returns the singleton container div for the given category, creating + and appending it to document.body on first access." + [category] + (let [body (dom/get-body) + id (str "portal-container-" category)] + (or (dom/query body (str "#" id)) + (let [container (dom/create-element "div")] + (dom/set-attribute! container "id" id) + (dom/append-child! body container) + container)))) + (defn use-portal-container - "Creates a dedicated div container for React portals. The container - is appended to document.body on mount and removed on cleanup, preventing - removeChild race conditions when multiple portals target the same body." - [] - (let [container (mf/use-memo #(dom/create-element "div"))] - (mf/with-effect [] - (let [body (dom/get-body)] - (dom/append-child! body container) - #(dom/remove-child! body container))) - container)) + "Returns a shared singleton container div for React portals, identified + by a logical category. Available categories: + + :modal — modal dialogs + :popup — popups, dropdowns, context menus + :tooltip — tooltips + :default — general portal use (default) + + All portals in the same category share one
on document.body, + keeping the DOM clean and avoiding removeChild race conditions." + ([] + (use-portal-container :default)) + ([category] + (let [category (name category)] + (mf/with-memo [category] + (get-or-create-portal-container category))))) (defn use-dynamic-grid-item-width ([] (use-dynamic-grid-item-width nil)) diff --git a/frontend/src/app/main/ui/modal.cljs b/frontend/src/app/main/ui/modal.cljs index 5df1cc3daa9..6e9b1df7d45 100644 --- a/frontend/src/app/main/ui/modal.cljs +++ b/frontend/src/app/main/ui/modal.cljs @@ -84,7 +84,7 @@ (mf/defc modal-container* {::mf/props :obj} [] - (let [container (hooks/use-portal-container)] + (let [container (hooks/use-portal-container :modal)] (when-let [modal (mf/deref ref:modal)] (mf/portal (mf/html [:> modal-wrapper* {:data modal :key (dm/str (:id modal))}]) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs index ab0dc6326da..c870baf9fb4 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs @@ -517,7 +517,7 @@ dropdown-direction-change* (mf/use-ref 0) top (+ (get-in mdata [:position :y]) 5) left (+ (get-in mdata [:position :x]) 5) - container (hooks/use-portal-container)] + container (hooks/use-portal-container :popup)] (mf/use-effect (mf/deps is-open?) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs b/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs index d37e628d025..f150240cf15 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs @@ -36,7 +36,7 @@ dropdown-direction-change* (mf/use-ref 0) top (+ (get-in mdata [:position :y]) 5) left (+ (get-in mdata [:position :x]) 5) - container (hooks/use-portal-container) + container (hooks/use-portal-container :popup) delete-node (mf/use-fn (mf/deps mdata) diff --git a/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs b/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs index a8687c97195..d688588e2fb 100644 --- a/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs @@ -114,7 +114,7 @@ :is-open? true :rect rect)))))) - container (hooks/use-portal-container)] + container (hooks/use-portal-container :popup)] [:div {:on-click on-open-dropdown :disabled (not can-edit?) From 62f34546079a9f5c738e08cf2ec9fbaba6125feb Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 12:33:10 +0200 Subject: [PATCH 020/162] :wrench: Backport ci configuration changes from develop --- .github/workflows/build-docker-devenv.yml | 6 +- .github/workflows/build-docker.yml | 18 ++--- .github/workflows/commit-checker.yml | 3 + .github/workflows/plugins-deploy-api-doc.yml | 2 +- .github/workflows/plugins-deploy-package.yml | 2 +- .github/workflows/plugins-deploy-packages.yml | 2 +- .../workflows/plugins-deploy-styles-doc.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/tests-mcp.yml | 6 +- .github/workflows/tests.yml | 69 +++++++------------ frontend/scripts/test-e2e | 2 +- 11 files changed, 50 insertions(+), 64 deletions(-) diff --git a/.github/workflows/build-docker-devenv.yml b/.github/workflows/build-docker-devenv.yml index d48e401a869..3ba45267a5b 100644 --- a/.github/workflows/build-docker-devenv.yml +++ b/.github/workflows/build-docker-devenv.yml @@ -19,16 +19,16 @@ jobs: uses: actions/checkout@v6 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to Docker Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.PUB_DOCKER_USERNAME }} password: ${{ secrets.PUB_DOCKER_PASSWORD }} - name: Build and push DevEnv Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 env: DOCKER_IMAGE: 'penpotapp/devenv' with: diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index ff6375b13e6..18ac6aec9f3 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -63,10 +63,10 @@ jobs: popd - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to Docker Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ secrets.DOCKER_REGISTRY }} username: ${{ secrets.DOCKER_USERNAME }} @@ -76,14 +76,14 @@ jobs: # images from DockerHub for unregistered users. # https://docs.docker.com/docker-hub/usage/ - name: Login to DockerHub Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.PUB_DOCKER_USERNAME }} password: ${{ secrets.PUB_DOCKER_PASSWORD }} - name: Extract metadata (tags, labels) id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: frontend @@ -95,7 +95,7 @@ jobs: bundle_version=${{ steps.bundles.outputs.bundle_version }} - name: Build and push Backend Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 env: DOCKER_IMAGE: 'backend' BUNDLE_PATH: './bundle-backend' @@ -110,7 +110,7 @@ jobs: cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max - name: Build and push Frontend Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 env: DOCKER_IMAGE: 'frontend' BUNDLE_PATH: './bundle-frontend' @@ -125,7 +125,7 @@ jobs: cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max - name: Build and push Exporter Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 env: DOCKER_IMAGE: 'exporter' BUNDLE_PATH: './bundle-exporter' @@ -140,7 +140,7 @@ jobs: cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max - name: Build and push Storybook Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 env: DOCKER_IMAGE: 'storybook' BUNDLE_PATH: './bundle-storybook' @@ -155,7 +155,7 @@ jobs: cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max - name: Build and push MCP Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 env: DOCKER_IMAGE: 'mcp' BUNDLE_PATH: './bundle-mcp' diff --git a/.github/workflows/commit-checker.yml b/.github/workflows/commit-checker.yml index f7126a40cb0..a80e6e4cc09 100644 --- a/.github/workflows/commit-checker.yml +++ b/.github/workflows/commit-checker.yml @@ -6,12 +6,14 @@ on: - edited - reopened - synchronize + - ready_for_review pull_request_target: types: - opened - edited - reopened - synchronize + - ready_for_review push: branches: - main @@ -20,6 +22,7 @@ on: jobs: check-commit-message: + if: ${{ !github.event.pull_request.draft }} name: Check Commit Message runs-on: ubuntu-latest steps: diff --git a/.github/workflows/plugins-deploy-api-doc.yml b/.github/workflows/plugins-deploy-api-doc.yml index 815553749d9..51be85e45e7 100644 --- a/.github/workflows/plugins-deploy-api-doc.yml +++ b/.github/workflows/plugins-deploy-api-doc.yml @@ -62,7 +62,7 @@ jobs: run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT - name: Cache pnpm store - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ${{ steps.pnpm-store.outputs.STORE_PATH }} key: ${{ runner.os }}-pnpm-${{ hashFiles('plugins/pnpm-lock.yaml') }} diff --git a/.github/workflows/plugins-deploy-package.yml b/.github/workflows/plugins-deploy-package.yml index f8f558569d5..137ba6f7fa5 100644 --- a/.github/workflows/plugins-deploy-package.yml +++ b/.github/workflows/plugins-deploy-package.yml @@ -62,7 +62,7 @@ jobs: run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT - name: Cache pnpm store - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ${{ steps.pnpm-store.outputs.STORE_PATH }} key: ${{ runner.os }}-pnpm-${{ hashFiles('plugins/pnpm-lock.yaml') }} diff --git a/.github/workflows/plugins-deploy-packages.yml b/.github/workflows/plugins-deploy-packages.yml index 01f92849725..943e4b790dc 100644 --- a/.github/workflows/plugins-deploy-packages.yml +++ b/.github/workflows/plugins-deploy-packages.yml @@ -38,7 +38,7 @@ jobs: steps: - uses: actions/checkout@v6 - id: filter - uses: dorny/paths-filter@v3 + uses: dorny/paths-filter@v4 with: filters: | colors_to_tokens: diff --git a/.github/workflows/plugins-deploy-styles-doc.yml b/.github/workflows/plugins-deploy-styles-doc.yml index 9fbcac880e3..47f0d1cc24d 100644 --- a/.github/workflows/plugins-deploy-styles-doc.yml +++ b/.github/workflows/plugins-deploy-styles-doc.yml @@ -60,7 +60,7 @@ jobs: run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT - name: Cache pnpm store - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ${{ steps.pnpm-store.outputs.STORE_PATH }} key: ${{ runner.os }}-pnpm-${{ hashFiles('plugins/pnpm-lock.yaml') }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 538cd9d5a09..21c0eb6de2b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -93,7 +93,7 @@ jobs: # --- Create GitHub release --- - name: Create GitHub release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: diff --git a/.github/workflows/tests-mcp.yml b/.github/workflows/tests-mcp.yml index 9f2a4ed5898..0ab2909b723 100644 --- a/.github/workflows/tests-mcp.yml +++ b/.github/workflows/tests-mcp.yml @@ -10,6 +10,7 @@ on: types: - opened - synchronize + - ready_for_review paths: - 'mcp/**' @@ -24,8 +25,9 @@ on: - 'mcp/**' jobs: - test: - name: "Test" + test-mcp: + if: ${{ !github.event.pull_request.draft }} + name: "Test MCP" runs-on: penpot-runner-02 container: penpotapp/devenv:latest diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 472fc366569..afcffb0ae71 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,6 +9,7 @@ on: types: - opened - synchronize + - ready_for_review push: branches: - develop @@ -20,6 +21,7 @@ concurrency: jobs: lint: + if: ${{ !github.event.pull_request.draft }} name: "Linter" runs-on: penpot-runner-02 container: penpotapp/devenv:latest @@ -79,6 +81,7 @@ jobs: pnpm run lint test-common: + if: ${{ !github.event.pull_request.draft }} name: "Common Tests" runs-on: penpot-runner-02 container: penpotapp/devenv:latest @@ -93,6 +96,7 @@ jobs: ./scripts/test test-plugins: + if: ${{ !github.event.pull_request.draft }} name: Plugins Runtime Linter & Tests runs-on: penpot-runner-02 container: penpotapp/devenv:latest @@ -143,6 +147,7 @@ jobs: run: pnpm run build:styles-example test-frontend: + if: ${{ !github.event.pull_request.draft }} name: "Frontend Tests" runs-on: penpot-runner-02 container: penpotapp/devenv:latest @@ -164,6 +169,7 @@ jobs: ./scripts/test-components test-render-wasm: + if: ${{ !github.event.pull_request.draft }} name: "Render WASM Tests" runs-on: penpot-runner-02 container: penpotapp/devenv:latest @@ -188,6 +194,7 @@ jobs: ./test test-backend: + if: ${{ !github.event.pull_request.draft }} name: "Backend Tests" runs-on: penpot-runner-02 container: penpotapp/devenv:latest @@ -227,6 +234,7 @@ jobs: clojure -M:dev:test --reporter kaocha.report/documentation test-library: + if: ${{ !github.event.pull_request.draft }} name: "Library Tests" runs-on: penpot-runner-02 container: penpotapp/devenv:latest @@ -241,6 +249,7 @@ jobs: ./scripts/test build-integration: + if: ${{ !github.event.pull_request.draft }} name: "Build Integration Bundle" runs-on: penpot-runner-02 container: penpotapp/devenv:latest @@ -255,14 +264,14 @@ jobs: ./scripts/build - name: Store Bundle Cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: key: "integration-bundle-${{ github.sha }}" path: frontend/resources/public - test-integration-1: - name: "Integration Tests 1/4" + if: ${{ !github.event.pull_request.draft }} + name: "Integration Tests 1/3" runs-on: penpot-runner-02 container: penpotapp/devenv:latest needs: build-integration @@ -272,7 +281,7 @@ jobs: uses: actions/checkout@v6 - name: Restore Cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: key: "integration-bundle-${{ github.sha }}" path: frontend/resources/public @@ -280,10 +289,10 @@ jobs: - name: Run Tests working-directory: ./frontend run: | - ./scripts/test-e2e --shard="1/4"; + ./scripts/test-e2e --shard="1/3"; - name: Upload test result - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 if: always() with: name: integration-tests-result-1 @@ -292,7 +301,8 @@ jobs: retention-days: 3 test-integration-2: - name: "Integration Tests 2/4" + if: ${{ !github.event.pull_request.draft }} + name: "Integration Tests 2/3" runs-on: penpot-runner-02 container: penpotapp/devenv:latest needs: build-integration @@ -302,7 +312,7 @@ jobs: uses: actions/checkout@v6 - name: Restore Cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: key: "integration-bundle-${{ github.sha }}" path: frontend/resources/public @@ -310,10 +320,10 @@ jobs: - name: Run Tests working-directory: ./frontend run: | - ./scripts/test-e2e --shard="2/4"; + ./scripts/test-e2e --shard="2/3"; - name: Upload test result - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 if: always() with: name: integration-tests-result-2 @@ -322,7 +332,8 @@ jobs: retention-days: 3 test-integration-3: - name: "Integration Tests 3/4" + if: ${{ !github.event.pull_request.draft }} + name: "Integration Tests 3/3" runs-on: penpot-runner-02 container: penpotapp/devenv:latest needs: build-integration @@ -332,7 +343,7 @@ jobs: uses: actions/checkout@v6 - name: Restore Cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: key: "integration-bundle-${{ github.sha }}" path: frontend/resources/public @@ -340,43 +351,13 @@ jobs: - name: Run Tests working-directory: ./frontend run: | - ./scripts/test-e2e --shard="3/4"; + ./scripts/test-e2e --shard="3/3"; - name: Upload test result - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 if: always() with: name: integration-tests-result-3 path: frontend/test-results/ overwrite: true retention-days: 3 - - test-integration-4: - name: "Integration Tests 4/4" - runs-on: penpot-runner-02 - container: penpotapp/devenv:latest - needs: build-integration - - steps: - - name: Checkout Repository - uses: actions/checkout@v6 - - - name: Restore Cache - uses: actions/cache/restore@v4 - with: - key: "integration-bundle-${{ github.sha }}" - path: frontend/resources/public - - - name: Run Tests - working-directory: ./frontend - run: | - ./scripts/test-e2e --shard="4/4"; - - - name: Upload test result - uses: actions/upload-artifact@v4 - if: always() - with: - name: integration-tests-result-4 - path: frontend/test-results/ - overwrite: true - retention-days: 3 diff --git a/frontend/scripts/test-e2e b/frontend/scripts/test-e2e index dd25bed989b..fca7cf941e5 100755 --- a/frontend/scripts/test-e2e +++ b/frontend/scripts/test-e2e @@ -5,4 +5,4 @@ SCRIPT_DIR=$(dirname $0); set -ex $SCRIPT_DIR/setup; -pnpm run test:e2e -x --workers=2 --reporter=list "$@"; +pnpm run test:e2e -x --workers=1 --reporter=list "$@"; From 18f0ad246f44f442fa4b02fbd433c1bc2bf96cb4 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 13 Apr 2026 13:24:54 +0000 Subject: [PATCH 021/162] :bug: Fix parse-long crash when index query param is duplicated in URL lambdaisland/uri's query-string->map uses :multikeys :duplicates by default: a key that appears once yields a plain string, but the same key repeated yields a vector. cljs.core/parse-long only accepts strings and therefore threw "Expected string, got: object" whenever a URL contained a duplicate 'index' parameter. Add rt/get-query-param to app.main.router. The helper returns the scalar value of a query param key, taking the last element when the value is a sequential (i.e. the key was repeated). Use it at every call site that feeds a query-param value into parse-long, in both app.main.ui (page*) and app.main.data.viewer. --- frontend/src/app/main/data/viewer.cljs | 14 +++++++------- frontend/src/app/main/router.cljs | 10 ++++++++++ frontend/src/app/main/ui.cljs | 2 +- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/frontend/src/app/main/data/viewer.cljs b/frontend/src/app/main/data/viewer.cljs index c2d42d680c6..c1f6083d133 100644 --- a/frontend/src/app/main/data/viewer.cljs +++ b/frontend/src/app/main/data/viewer.cljs @@ -204,7 +204,7 @@ (watch [_ state _] (let [route (:route state) qparams (:query-params route) - index (some-> (:index qparams) parse-long) + index (some-> (rt/get-query-param qparams :index) parse-long) frame-id (some-> (:frame-id qparams) uuid/parse)] (rx/merge (rx/of (case (:zoom qparams) @@ -301,7 +301,7 @@ (update [_ state] (let [params (rt/get-params state) page-id (some-> (:page-id params) uuid/parse) - index (some-> (:index params) parse-long) + index (some-> (rt/get-query-param params :index) parse-long) frames (dm/get-in state [:viewer :pages page-id :frames]) index (min (or index 0) (max 0 (dec (count frames)))) @@ -325,7 +325,7 @@ (let [params (rt/get-params state) page-id (some-> (:page-id params) uuid/parse) - index (some-> (:index params) parse-long) + index (some-> (rt/get-query-param params :index) parse-long) frames (dm/get-in state [:viewer :pages page-id :frames]) index (min (or index 0) (max 0 (dec (count frames)))) @@ -399,7 +399,7 @@ ptk/WatchEvent (watch [_ state _] (let [params (rt/get-params state) - index (some-> params :index parse-long)] + index (some-> (rt/get-query-param params :index) parse-long)] (when (pos? index) (rx/of (dcmt/close-thread) @@ -415,7 +415,7 @@ ptk/WatchEvent (watch [_ state _] (let [params (rt/get-params state) - index (some-> params :index parse-long) + index (some-> (rt/get-query-param params :index) parse-long) page-id (some-> params :page-id uuid/parse) total (count (get-in state [:viewer :pages page-id :frames]))] @@ -530,7 +530,7 @@ (let [route (:route state) qparams (:query-params route) page-id (some-> (:page-id qparams) uuid/parse) - index (some-> (:index qparams) parse-long) + index (some-> (rt/get-query-param qparams :index) parse-long) frames (get-in state [:viewer :pages page-id :frames]) frame (get frames index)] (cond-> state @@ -744,7 +744,7 @@ (let [route (:route state) qparams (:query-params route) page-id (some-> (:page-id qparams) uuid/parse) - index (some-> (:index qparams) parse-long) + index (some-> (rt/get-query-param qparams :index) parse-long) objects (get-in state [:viewer :pages page-id :objects]) frame-id (get-in state [:viewer :pages page-id :frames index :id]) diff --git a/frontend/src/app/main/router.cljs b/frontend/src/app/main/router.cljs index 1e234e8af1c..405c8b66641 100644 --- a/frontend/src/app/main/router.cljs +++ b/frontend/src/app/main/router.cljs @@ -136,6 +136,16 @@ [state] (dm/get-in state [:route :params :query])) +(defn get-query-param + "Safely extracts a scalar value for a query param key from a params + map. When the same key appears multiple times in a URL, + query-string->map returns a vector for that key; this function + always returns a single (last) element in that case, so downstream + consumers such as parse-long always receive a plain string or nil." + [params k] + (let [v (get params k)] + (if (sequential? v) (peek v) v))) + (defn nav-back [] (ptk/reify ::nav-back diff --git a/frontend/src/app/main/ui.cljs b/frontend/src/app/main/ui.cljs index 3b00fe0d50a..1a03943ba4b 100644 --- a/frontend/src/app/main/ui.cljs +++ b/frontend/src/app/main/ui.cljs @@ -277,7 +277,7 @@ :viewer (let [params (get params :query) - index (some-> (:index params) parse-long) + index (some-> (rt/get-query-param params :index) parse-long) share-id (some-> (:share-id params) uuid/parse*) section (or (some-> (:section params) keyword) :interactions) From 6c90ba1582e4b5bab58232dae438a4fd701032eb Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 13 Apr 2026 12:43:13 +0000 Subject: [PATCH 022/162] :bug: Fix move-files allowing same project as target when multiple files selected The 'Move to' menu in the dashboard file context menu only filtered out the first selected file's project from the available target list. When multiple files from different projects were selected, the other files' projects still appeared as valid targets, causing a 400 'cant-move-to-same-project' backend error. Now all selected files' project IDs are collected and excluded from the available target projects. --- frontend/src/app/main/ui/dashboard/file_menu.cljs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/main/ui/dashboard/file_menu.cljs b/frontend/src/app/main/ui/dashboard/file_menu.cljs index dfecbc779bd..06f7b29c363 100644 --- a/frontend/src/app/main/ui/dashboard/file_menu.cljs +++ b/frontend/src/app/main/ui/dashboard/file_menu.cljs @@ -78,7 +78,8 @@ current-team (get teams current-team-id) other-teams (remove #(= (:id %) current-team-id) (vals teams)) - current-projects (remove #(= (:id %) (:project-id file)) + file-project-ids (into #{} (map :project-id) files) + current-projects (remove #(contains? file-project-ids (:id %)) (:projects current-team)) on-new-tab From 7b0ea5968dc18e65c08e8262ba0199920f498214 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 12:30:22 +0000 Subject: [PATCH 023/162] :ambulance: Fix typo :podition in swap-shapes grid cell The key :podition was used instead of :position when updating the id-from cell in swap-shapes, silently discarding the position value and leaving the cell's :position as nil after every swap. Signed-off-by: Andrey Antukh --- common/src/app/common/types/shape/layout.cljc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/app/common/types/shape/layout.cljc b/common/src/app/common/types/shape/layout.cljc index 384029a688c..8ed7306c619 100644 --- a/common/src/app/common/types/shape/layout.cljc +++ b/common/src/app/common/types/shape/layout.cljc @@ -1439,7 +1439,7 @@ (update-in [:layout-grid-cells id-from] assoc :shapes (:shapes cell-to) - :podition (:position cell-to)) + :position (:position cell-to)) (update-in [:layout-grid-cells id-to] assoc :shapes (:shapes cell-from) From 08ca56166714a29bf7a4e2ea1435c7370091954c Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 12:30:30 +0000 Subject: [PATCH 024/162] :bug: Add better nil handling in interpolate-gradient when offset exceeds stops When no gradient stop satisfies (<= offset (:offset %)), d/index-of-pred returns nil. The previous code called (dec nil) in the start binding before the nil check, throwing a NullPointerException/ClassCastException. Guard the start binding with a cond that handles nil before attempting dec. Signed-off-by: Andrey Antukh --- common/src/app/common/types/color.cljc | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/common/src/app/common/types/color.cljc b/common/src/app/common/types/color.cljc index c4532c4ac04..ae56250d969 100644 --- a/common/src/app/common/types/color.cljc +++ b/common/src/app/common/types/color.cljc @@ -720,8 +720,10 @@ (defn- offset-spread [from to num] - (->> (range 0 num) - (map #(mth/precision (+ from (* (/ (- to from) (dec num)) %)) 2)))) + (if (<= num 1) + [from] + (->> (range 0 num) + (map #(mth/precision (+ from (* (/ (- to from) (dec num)) %)) 2))))) (defn uniform-spread? "Checks if the gradient stops are spread uniformly" @@ -750,6 +752,9 @@ (defn interpolate-gradient [stops offset] (let [idx (d/index-of-pred stops #(<= offset (:offset %))) - start (if (= idx 0) (first stops) (get stops (dec idx))) + start (cond + (nil? idx) (last stops) + (= idx 0) (first stops) + :else (get stops (dec idx))) end (if (nil? idx) (last stops) (get stops idx))] (interpolate-color start end offset))) From ff41d08e3c726a08b447066630d17ecc6769a22a Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 12:30:35 +0000 Subject: [PATCH 025/162] :bug: Fix stale accumulator in get-children-in-instance recursion get-children-rec passed the original children vector to each recursive call instead of the updated one that already includes the current shape. This caused descendant results to be accumulated from the wrong starting point, losing intermediate shapes. Pass children' (which includes the current shape) into every recursive call. Signed-off-by: Andrey Antukh --- common/src/app/common/types/container.cljc | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/common/src/app/common/types/container.cljc b/common/src/app/common/types/container.cljc index 324528854ba..8bb0e7d9695 100644 --- a/common/src/app/common/types/container.cljc +++ b/common/src/app/common/types/container.cljc @@ -106,8 +106,9 @@ (let [shape (get objects id)] (if (and (ctk/instance-head? shape) (seq children)) children - (into (conj children shape) - (mapcat #(get-children-rec children %) (:shapes shape))))))] + (let [children' (conj children shape)] + (into children' + (mapcat #(get-children-rec children' %) (:shapes shape)))))))] (get-children-rec [] id))) (defn get-component-shape From c30c85ff077179627401fc831b229362b5ca3064 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 12:30:50 +0000 Subject: [PATCH 026/162] :bug: Remove duplicate font-weight-keys in typography-keys union font-weight-keys was listed twice in the set/union call for typography-keys, a copy-paste error. The duplicate entry has no functional effect (sets deduplicate), but it is misleading and suggests a missing key such as font-style-keys in its place. Signed-off-by: Andrey Antukh --- common/src/app/common/types/token.cljc | 1 - 1 file changed, 1 deletion(-) diff --git a/common/src/app/common/types/token.cljc b/common/src/app/common/types/token.cljc index e3e541da33e..c3bb2b266d3 100644 --- a/common/src/app/common/types/token.cljc +++ b/common/src/app/common/types/token.cljc @@ -345,7 +345,6 @@ (def typography-keys (set/union font-family-keys font-size-keys font-weight-keys - font-weight-keys letter-spacing-keys line-height-keys text-case-keys From 8253738f01c9709fef18e35a5aac412690a438ee Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 12:34:38 +0000 Subject: [PATCH 027/162] :bug: Fix reversed `get` args in convert-dtcg-shadow-composite \`(get "type" shadow)\` always returns nil because the map and key arguments were swapped. The correct call is \`(get shadow "type")\`, which allows the legacy innerShadow detection to work correctly. Update the test expectation accordingly. Signed-off-by: Andrey Antukh --- common/src/app/common/types/tokens_lib.cljc | 2 +- common/test/common_tests/types/tokens_lib_test.cljc | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/common/src/app/common/types/tokens_lib.cljc b/common/src/app/common/types/tokens_lib.cljc index 5c392f2db97..8ab9c6bcd03 100644 --- a/common/src/app/common/types/tokens_lib.cljc +++ b/common/src/app/common/types/tokens_lib.cljc @@ -1637,7 +1637,7 @@ Will return a value that matches this schema: [value] (let [process-shadow (fn [shadow] (if (map? shadow) - (let [legacy-shadow-type (get "type" shadow)] + (let [legacy-shadow-type (get shadow "type")] (-> shadow (set/rename-keys {"x" :offset-x "offsetX" :offset-x diff --git a/common/test/common_tests/types/tokens_lib_test.cljc b/common/test/common_tests/types/tokens_lib_test.cljc index 150ffcfb08d..e8c8a52ae53 100644 --- a/common/test/common_tests/types/tokens_lib_test.cljc +++ b/common/test/common_tests/types/tokens_lib_test.cljc @@ -1918,7 +1918,7 @@ (let [token (ctob/get-token-by-name lib "shadow-test" "test.shadow-with-type")] (t/is (some? token)) (t/is (= :shadow (:type token))) - (t/is (= [{:offset-x "0", :offset-y "4px", :blur "8px", :spread "0", :color "rgba(0,0,0,0.2)", :inset false}] + (t/is (= [{:offset-x "0", :offset-y "4px", :blur "8px", :spread "0", :color "rgba(0,0,0,0.2)", :inset true}] (:value token))))) (t/testing "shadow token with description" From 8b08c8ecc974eb805fcd59bfa7008eb6c6a58a4e Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 12:35:21 +0000 Subject: [PATCH 028/162] :bug: Fix wrong mapcat call in collect-main-shapes `(mapcat collect-main-shapes children objects)` passes `objects` as a second parallel collection instead of threading it as the second argument to `collect-main-shapes` for each child. Fix by using an anonymous fn: `(mapcat #(collect-main-shapes % objects) children)`. Signed-off-by: Andrey Antukh --- common/src/app/common/types/container.cljc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/app/common/types/container.cljc b/common/src/app/common/types/container.cljc index 8bb0e7d9695..b72ca11179d 100644 --- a/common/src/app/common/types/container.cljc +++ b/common/src/app/common/types/container.cljc @@ -441,7 +441,7 @@ (if (ctk/main-instance? shape) [shape] (if-let [children (cfh/get-children objects (:id shape))] - (mapcat collect-main-shapes children objects) + (mapcat #(collect-main-shapes % objects) children) []))) (defn get-component-from-shape From 2b67e114b6ce894d3d74d6106c4ef4e630fda687 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 12:37:01 +0000 Subject: [PATCH 029/162] :bug: Fix inside-layout? passing id instead of shape to frame-shape? `(cfh/frame-shape? current-id)` passes a UUID to the single-arity overload of `frame-shape?`, which expects a shape map; it always returns false. Fix by passing `current` (the resolved shape) instead. Update the test to assert the correct behaviour. Signed-off-by: Andrey Antukh --- common/src/app/common/types/shape/layout.cljc | 2 +- common/test/common_tests/types/shape_layout_test.cljc | 8 ++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/common/src/app/common/types/shape/layout.cljc b/common/src/app/common/types/shape/layout.cljc index 8ed7306c619..caea9d5f913 100644 --- a/common/src/app/common/types/shape/layout.cljc +++ b/common/src/app/common/types/shape/layout.cljc @@ -262,7 +262,7 @@ (or (nil? current) (= current-id parent-id)) false - (cfh/frame-shape? current-id) + (cfh/frame-shape? current) (:layout current) :else diff --git a/common/test/common_tests/types/shape_layout_test.cljc b/common/test/common_tests/types/shape_layout_test.cljc index d677ed5d09f..62935b21dc1 100644 --- a/common/test/common_tests/types/shape_layout_test.cljc +++ b/common/test/common_tests/types/shape_layout_test.cljc @@ -186,13 +186,9 @@ flex (make-flex-frame :parent-id root-id) child (make-shape :parent-id (:id flex))] - ;; Note: inside-layout? calls (cfh/frame-shape? current-id) with a UUID id, - ;; but frame-shape? checks (:type uuid) which is nil for a UUID value. - ;; The function therefore always returns false regardless of structure. - ;; These tests document the actual (not the intended) behavior. - (t/testing "returns false when child is under a flex frame" + (t/testing "returns true when child is under a flex frame" (let [objects {root-id root (:id flex) flex (:id child) child}] - (t/is (not (layout/inside-layout? objects child))))) + (t/is (layout/inside-layout? objects child)))) (t/testing "returns false for root shape" (let [objects {root-id root (:id flex) flex (:id child) child}] From 6da39bc9c74539697b8d07162fa03d329976a6e7 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 12:41:00 +0000 Subject: [PATCH 030/162] :bug: Fix ObjectsMap CLJS negative cache keyed on 'key' fn instead of 'k' In the CLJS -lookup implementation, when a key is absent from data the negative cache entry was stored under 'key' (the built-in map-entry key function) rather than the 'k' parameter. As a result every subsequent lookup of any missing key bypassed the cache and repeated the full lookup path, making the negative-cache optimization entirely ineffective. Signed-off-by: Andrey Antukh --- common/src/app/common/types/objects_map.cljc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/app/common/types/objects_map.cljc b/common/src/app/common/types/objects_map.cljc index d08330765ce..3604961f117 100644 --- a/common/src/app/common/types/objects_map.cljc +++ b/common/src/app/common/types/objects_map.cljc @@ -278,7 +278,7 @@ (set! (.-cache this) (c/-assoc cache k v)) v) (do - (set! (.-cache this) (assoc cache key nil)) + (set! (.-cache this) (assoc cache k nil)) nil)))) (-lookup [this k not-found] From 30931839b5d136f1e543665061a47d7f4fee27c6 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 12:43:32 +0000 Subject: [PATCH 031/162] :bug: Fix reversed d/in-range? args in CLJS Fills -nth with default In the ClojureScript Fills deftype, the two-arity -nth implementation called (d/in-range? i size) but the signature is (d/in-range? size i). This meant -nth always fell through to the default value for any valid index when called with an explicit default, since i < size is the condition but the args were swapped. The no-default -nth sibling on line 378 and both CLJ nth impls on lines 286 and 291 had the correct argument order. Signed-off-by: Andrey Antukh --- common/src/app/common/types/fills/impl.cljc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/app/common/types/fills/impl.cljc b/common/src/app/common/types/fills/impl.cljc index 06475d183f6..b429c67b9ce 100644 --- a/common/src/app/common/types/fills/impl.cljc +++ b/common/src/app/common/types/fills/impl.cljc @@ -380,7 +380,7 @@ nil)) (-nth [_ i default] - (if (d/in-range? i size) + (if (d/in-range? size i) (read-fill dbuffer mbuffer i) default)) From caac452cd4ddc45b368bfda8764453baf81da6de Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 12:44:58 +0000 Subject: [PATCH 032/162] :bug: Fix wrong extremity point in calculate-extremities for line-to In the :line-to branch of calculate-extremities, move-p (the subpath start point) was being added to the extremities set instead of from-p (the actual previous point). For all line segments beyond the first one in a subpath this produced an incorrect bounding-box start point. The :curve-to branch correctly used from-p; align :line-to to match. Signed-off-by: Andrey Antukh --- common/src/app/common/types/path/segment.cljc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/app/common/types/path/segment.cljc b/common/src/app/common/types/path/segment.cljc index bcbbe8eedac..45fc1ba2bb6 100644 --- a/common/src/app/common/types/path/segment.cljc +++ b/common/src/app/common/types/path/segment.cljc @@ -812,7 +812,7 @@ :line-to (recur (cond-> points (and from-p to-p) - (-> (conj! move-p) + (-> (conj! from-p) (conj! to-p))) (not-empty (subvec content 1)) to-p From db7c6465681435e180bf0d4c148fd2e324d64aa9 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 12:48:07 +0000 Subject: [PATCH 033/162] :sparkles: Add missing tests for session bug fixes and uniform-spread? Add indexed-access-with-default in fill_test.cljc to cover the two-arity (nth fills i default) form on both valid and out-of-range indices, directly exercising the CLJS Fills -nth path fixed in 593cf125. Add segment-content->selrect-multi-line in path_data_test.cljc to cover content->selrect on a subpath with multiple consecutive line-to commands where move-p diverges from from-p, confirming the bounding box matches both the expected coordinates and the reference implementation; this guards the calculate-extremities fix in bb5a04c7. Add types-uniform-spread? in colors_test.cljc to cover app.common.types.color/uniform-spread?, which had no dedicated tests. Exercises the uniform case (via uniform-spread), the two-stop edge case, wrong-offset detection, and wrong-color detection. Signed-off-by: Andrey Antukh --- common/test/common_tests/colors_test.cljc | 21 ++++++++++++++++ common/test/common_tests/types/fill_test.cljc | 15 +++++++++++ .../common_tests/types/path_data_test.cljc | 25 +++++++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/common/test/common_tests/colors_test.cljc b/common/test/common_tests/colors_test.cljc index de505fd5408..aa1edd450af 100644 --- a/common/test/common_tests/colors_test.cljc +++ b/common/test/common_tests/colors_test.cljc @@ -426,6 +426,27 @@ {:color "#ffffff" :opacity 1.0 :offset 1.0}]] (t/is (false? (c/uniform-spread? stops))))) +(t/deftest types-uniform-spread? + (t/testing "uniformly spread stops are detected as uniform" + (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} + c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} + stops (colors/uniform-spread c1 c2 3)] + (t/is (true? (colors/uniform-spread? stops))))) + (t/testing "two-stop gradient is uniform by definition" + (let [stops [{:color "#ff0000" :opacity 1.0 :offset 0.0} + {:color "#0000ff" :opacity 1.0 :offset 1.0}]] + (t/is (true? (colors/uniform-spread? stops))))) + (t/testing "stops with wrong offset are not uniform" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#888888" :opacity 0.5 :offset 0.3} + {:color "#ffffff" :opacity 1.0 :offset 1.0}]] + (t/is (false? (colors/uniform-spread? stops))))) + (t/testing "stops with correct offset but wrong color are not uniform" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#aaaaaa" :opacity 0.5 :offset 0.5} + {:color "#ffffff" :opacity 1.0 :offset 1.0}]] + (t/is (false? (colors/uniform-spread? stops)))))) + (t/deftest ac-interpolate-gradient (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} {:color "#ffffff" :opacity 1.0 :offset 1.0}]] diff --git a/common/test/common_tests/types/fill_test.cljc b/common/test/common_tests/types/fill_test.cljc index 308778bcc13..f9968e8aed7 100644 --- a/common/test/common_tests/types/fill_test.cljc +++ b/common/test/common_tests/types/fill_test.cljc @@ -207,3 +207,18 @@ fill1 (nth fills1 1)] (t/is (nil? fill1)) (t/is (equivalent-fill? fill0 sample-fill-6)))) + +(t/deftest indexed-access-with-default + (t/testing "nth with default returns fill for valid index" + ;; Regression: CLJS -nth with default had reversed d/in-range? args, + ;; so it always fell through to the default even for valid indices. + (let [fills (types.fills/from-plain [sample-fill-6]) + sentinel ::not-found + result (nth fills 0 sentinel)] + (t/is (not= sentinel result)) + (t/is (equivalent-fill? result sample-fill-6)))) + (t/testing "nth with default returns default for out-of-range index" + (let [fills (types.fills/from-plain [sample-fill-6]) + sentinel ::not-found] + (t/is (= sentinel (nth fills 1 sentinel))) + (t/is (= sentinel (nth fills -1 sentinel)))))) diff --git a/common/test/common_tests/types/path_data_test.cljc b/common/test/common_tests/types/path_data_test.cljc index e4d2881b183..6dc7fa5207c 100644 --- a/common/test/common_tests/types/path_data_test.cljc +++ b/common/test/common_tests/types/path_data_test.cljc @@ -973,6 +973,31 @@ (t/is (mth/close? 10.0 (:x2 rect) 0.1)) (t/is (mth/close? 10.0 (:y2 rect) 0.1)))) +(t/deftest segment-content->selrect-multi-line + ;; Regression: calculate-extremities used move-p instead of from-p in + ;; the :line-to branch. For a subpath with multiple consecutive line-to + ;; commands, the selrect must still match the reference implementation. + (let [;; A subpath that starts away from the origin and has three + ;; line-to segments so that move-p diverges from from-p for the + ;; later segments. + segments [{:command :move-to :params {:x 5.0 :y 5.0}} + {:command :line-to :params {:x 15.0 :y 0.0}} + {:command :line-to :params {:x 20.0 :y 8.0}} + {:command :line-to :params {:x 10.0 :y 12.0}}] + content (path/content segments) + rect (path.segment/content->selrect content) + ref-pts (calculate-extremities segments)] + + ;; Bounding box must enclose all four vertices exactly. + (t/is (some? rect)) + (t/is (mth/close? 5.0 (:x1 rect) 0.1)) + (t/is (mth/close? 0.0 (:y1 rect) 0.1)) + (t/is (mth/close? 20.0 (:x2 rect) 0.1)) + (t/is (mth/close? 12.0 (:y2 rect) 0.1)) + + ;; Must agree with the reference implementation. + (t/is (= ref-pts (calculate-extremities content))))) + (t/deftest segment-content-center (let [content (path/content sample-content-square) center (path.segment/content-center content)] From 1e0f10814ed65b32d09cf6943983b3210c3425a6 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 19:04:04 +0000 Subject: [PATCH 034/162] :fire: Remove duplicate gradient helpers from app.common.colors The five functions interpolate-color, offset-spread, uniform-spread?, uniform-spread, and interpolate-gradient duplicated the canonical implementations in app.common.types.color. The copies in colors.cljc also contained two bugs: a division-by-zero in offset-spread when num=1, and a crash on nil idx in interpolate-gradient. All production callers already use app.common.types.color. The duplicate tests that exercised the old copies are removed; their coverage is absorbed into expanded tests under the types-* suite, including a new nil-idx guard test and a single-stop no-crash test. Signed-off-by: Andrey Antukh --- common/src/app/common/colors.cljc | 59 -------------- common/test/common_tests/colors_test.cljc | 94 ++++++++++++----------- 2 files changed, 50 insertions(+), 103 deletions(-) diff --git a/common/src/app/common/colors.cljc b/common/src/app/common/colors.cljc index e16acf94a36..ab7c7e2a76c 100644 --- a/common/src/app/common/colors.cljc +++ b/common/src/app/common/colors.cljc @@ -487,62 +487,3 @@ b (+ (* bh 100) (* bv 10))] (compare a b))) -(defn interpolate-color - [c1 c2 offset] - (cond - (<= offset (:offset c1)) (assoc c1 :offset offset) - (>= offset (:offset c2)) (assoc c2 :offset offset) - - :else - (let [tr-offset (/ (- offset (:offset c1)) (- (:offset c2) (:offset c1))) - [r1 g1 b1] (hex->rgb (:color c1)) - [r2 g2 b2] (hex->rgb (:color c2)) - a1 (:opacity c1) - a2 (:opacity c2) - r (+ r1 (* (- r2 r1) tr-offset)) - g (+ g1 (* (- g2 g1) tr-offset)) - b (+ b1 (* (- b2 b1) tr-offset)) - a (+ a1 (* (- a2 a1) tr-offset))] - {:color (rgb->hex [r g b]) - :opacity a - :r r - :g g - :b b - :alpha a - :offset offset}))) - -(defn- offset-spread - [from to num] - (->> (range 0 num) - (map #(mth/precision (+ from (* (/ (- to from) (dec num)) %)) 2)))) - -(defn uniform-spread? - "Checks if the gradient stops are spread uniformly" - [stops] - (let [cs (count stops) - from (first stops) - to (last stops) - expect-vals (offset-spread (:offset from) (:offset to) cs) - - calculate-expected - (fn [expected-offset stop] - (and (mth/close? (:offset stop) expected-offset) - (let [ec (interpolate-color from to expected-offset)] - (and (= (:color ec) (:color stop)) - (= (:opacity ec) (:opacity stop))))))] - (->> (map calculate-expected expect-vals stops) - (every? true?)))) - -(defn uniform-spread - "Assign an uniform spread to the offset values for the gradient" - [from to num-stops] - (->> (offset-spread (:offset from) (:offset to) num-stops) - (mapv (fn [offset] - (interpolate-color from to offset))))) - -(defn interpolate-gradient - [stops offset] - (let [idx (d/index-of-pred stops #(<= offset (:offset %))) - start (if (= idx 0) (first stops) (get stops (dec idx))) - end (if (nil? idx) (last stops) (get stops idx))] - (interpolate-color start end offset))) diff --git a/common/test/common_tests/colors_test.cljc b/common/test/common_tests/colors_test.cljc index aa1edd450af..21f6af5bef1 100644 --- a/common/test/common_tests/colors_test.cljc +++ b/common/test/common_tests/colors_test.cljc @@ -387,44 +387,41 @@ (t/is (= 0.25 (c/reduce-range 0.3 4))) (t/is (= 0.0 (c/reduce-range 0.0 10)))) -;; --- Gradient helpers +;; --- Gradient helpers (app.common.types.color) -(t/deftest ac-interpolate-color - (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} - c2 {:color "#ffffff" :opacity 1.0 :offset 1.0}] - ;; At c1's offset → c1 with updated offset - (let [result (c/interpolate-color c1 c2 0.0)] +(t/deftest types-interpolate-color + (t/testing "at c1 offset returns c1 color" + (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} + c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} + result (colors/interpolate-color c1 c2 0.0)] (t/is (= "#000000" (:color result))) - (t/is (= 0.0 (:opacity result)))) - ;; At c2's offset → c2 with updated offset - (let [result (c/interpolate-color c1 c2 1.0)] + (t/is (= 0.0 (:opacity result))))) + (t/testing "at c2 offset returns c2 color" + (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} + c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} + result (colors/interpolate-color c1 c2 1.0)] (t/is (= "#ffffff" (:color result))) - (t/is (= 1.0 (:opacity result)))) - ;; At midpoint → gray - (let [result (c/interpolate-color c1 c2 0.5)] + (t/is (= 1.0 (:opacity result))))) + (t/testing "at midpoint returns interpolated gray" + (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} + c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} + result (colors/interpolate-color c1 c2 0.5)] (t/is (= "#7f7f7f" (:color result))) (t/is (mth/close? (:opacity result) 0.5))))) -(t/deftest ac-uniform-spread - (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} - c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} - stops (c/uniform-spread c1 c2 3)] - (t/is (= 3 (count stops))) - (t/is (= 0.0 (:offset (first stops)))) - (t/is (mth/close? 0.5 (:offset (second stops)))) - (t/is (= 1.0 (:offset (last stops)))))) - -(t/deftest ac-uniform-spread? - (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} - c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} - stops (c/uniform-spread c1 c2 3)] - ;; A uniformly spread result should pass the predicate - (t/is (true? (c/uniform-spread? stops)))) - ;; Manual non-uniform stops should not pass - (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} - {:color "#888888" :opacity 0.5 :offset 0.3} - {:color "#ffffff" :opacity 1.0 :offset 1.0}]] - (t/is (false? (c/uniform-spread? stops))))) +(t/deftest types-uniform-spread + (t/testing "produces correct count and offsets" + (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} + c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} + stops (colors/uniform-spread c1 c2 3)] + (t/is (= 3 (count stops))) + (t/is (= 0.0 (:offset (first stops)))) + (t/is (mth/close? 0.5 (:offset (second stops)))) + (t/is (= 1.0 (:offset (last stops)))))) + (t/testing "single stop returns a vector of one element (no division by zero)" + (let [c1 {:color "#ff0000" :opacity 1.0 :offset 0.0} + stops (colors/uniform-spread c1 c1 1)] + (t/is (= 1 (count stops)))))) (t/deftest types-uniform-spread? (t/testing "uniformly spread stops are detected as uniform" @@ -447,16 +444,25 @@ {:color "#ffffff" :opacity 1.0 :offset 1.0}]] (t/is (false? (colors/uniform-spread? stops)))))) -(t/deftest ac-interpolate-gradient - (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} - {:color "#ffffff" :opacity 1.0 :offset 1.0}]] - ;; At start - (let [result (c/interpolate-gradient stops 0.0)] - (t/is (= "#000000" (:color result)))) - ;; At end - (let [result (c/interpolate-gradient stops 1.0)] - (t/is (= "#ffffff" (:color result)))) - ;; In the middle - (let [result (c/interpolate-gradient stops 0.5)] - (t/is (= "#7f7f7f" (:color result)))))) +(t/deftest types-interpolate-gradient + (t/testing "at start offset returns first stop color" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#ffffff" :opacity 1.0 :offset 1.0}] + result (colors/interpolate-gradient stops 0.0)] + (t/is (= "#000000" (:color result))))) + (t/testing "at end offset returns last stop color" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#ffffff" :opacity 1.0 :offset 1.0}] + result (colors/interpolate-gradient stops 1.0)] + (t/is (= "#ffffff" (:color result))))) + (t/testing "at midpoint returns interpolated gray" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#ffffff" :opacity 1.0 :offset 1.0}] + result (colors/interpolate-gradient stops 0.5)] + (t/is (= "#7f7f7f" (:color result))))) + (t/testing "offset beyond last stop returns last stop color (nil idx guard)" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#ffffff" :opacity 1.0 :offset 0.5}] + result (colors/interpolate-gradient stops 1.0)] + (t/is (= "#ffffff" (:color result)))))) From 6d1d0445884c21312aadc46fd3893e84b805d9a3 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 19:16:19 +0000 Subject: [PATCH 035/162] :recycle: Move app.common.types.color tests to their own namespace Tests that exercise app.common.types.color were living inside common-tests.colors-test alongside the app.common.colors tests. Move them to common-tests.types.color-test so the test namespace mirrors the source namespace structure, consistent with the rest of the types/ test suite. The [app.common.types.color :as colors] require is removed from colors_test.cljc; the new file is registered in runner.cljc. Signed-off-by: Andrey Antukh --- common/test/common_tests/colors_test.cljc | 162 ----------------- common/test/common_tests/runner.cljc | 2 + .../test/common_tests/types/color_test.cljc | 166 ++++++++++++++++++ 3 files changed, 168 insertions(+), 162 deletions(-) create mode 100644 common/test/common_tests/types/color_test.cljc diff --git a/common/test/common_tests/colors_test.cljc b/common/test/common_tests/colors_test.cljc index 21f6af5bef1..7d6b0f0e3d7 100644 --- a/common/test/common_tests/colors_test.cljc +++ b/common/test/common_tests/colors_test.cljc @@ -9,91 +9,8 @@ #?(:cljs [goog.color :as gcolors]) [app.common.colors :as c] [app.common.math :as mth] - [app.common.types.color :as colors] [clojure.test :as t])) -(t/deftest valid-hex-color - (t/is (false? (colors/valid-hex-color? nil))) - (t/is (false? (colors/valid-hex-color? ""))) - (t/is (false? (colors/valid-hex-color? "#"))) - (t/is (false? (colors/valid-hex-color? "#qqqqqq"))) - (t/is (true? (colors/valid-hex-color? "#aaa"))) - (t/is (false? (colors/valid-hex-color? "#aaaa"))) - (t/is (true? (colors/valid-hex-color? "#fabada")))) - -(t/deftest valid-rgb-color - (t/is (false? (colors/valid-rgb-color? nil))) - (t/is (false? (colors/valid-rgb-color? ""))) - (t/is (false? (colors/valid-rgb-color? "()"))) - (t/is (true? (colors/valid-rgb-color? "(255, 30, 30)"))) - (t/is (true? (colors/valid-rgb-color? "rgb(255, 30, 30)")))) - -(t/deftest rgb-to-str - (t/is (= "rgb(1,2,3)" (colors/rgb->str [1 2 3]))) - (t/is (= "rgba(1,2,3,4)" (colors/rgb->str [1 2 3 4])))) - -(t/deftest rgb-to-hsv - ;; (prn (colors/rgb->hsv [1 2 3])) - ;; (prn (gcolors/rgbToHsv 1 2 3)) - (t/is (= [210.0 0.6666666666666666 3.0] (colors/rgb->hsv [1.0 2.0 3.0]))) - #?(:cljs (t/is (= (colors/rgb->hsv [1 2 3]) (vec (gcolors/rgbToHsv 1 2 3)))))) - -(t/deftest hsv-to-rgb - (t/is (= [1 2 3] - (colors/hsv->rgb [210 0.6666666666666666 3]))) - #?(:cljs - (t/is (= (colors/hsv->rgb [210 0.6666666666666666 3]) - (vec (gcolors/hsvToRgb 210 0.6666666666666666 3)))))) - -(t/deftest rgb-to-hex - (t/is (= "#010203" (colors/rgb->hex [1 2 3])))) - -(t/deftest hex-to-rgb - (t/is (= [0 0 0] (colors/hex->rgb "#kkk"))) - (t/is (= [1 2 3] (colors/hex->rgb "#010203")))) - -(t/deftest format-hsla - (t/is (= "210, 50%, 0.78%, 1" (colors/format-hsla [210.0 0.5 0.00784313725490196 1]))) - (t/is (= "220, 5%, 30%, 0.8" (colors/format-hsla [220.0 0.05 0.3 0.8])))) - -(t/deftest format-rgba - (t/is (= "210, 199, 12, 0.08" (colors/format-rgba [210 199 12 0.08]))) - (t/is (= "210, 199, 12, 1" (colors/format-rgba [210 199 12 1])))) - -(t/deftest rgb-to-hsl - (t/is (= [210.0 0.5 0.00784313725490196] (colors/rgb->hsl [1 2 3]))) - #?(:cljs (t/is (= (colors/rgb->hsl [1 2 3]) - (vec (gcolors/rgbToHsl 1 2 3)))))) - -(t/deftest hsl-to-rgb - (t/is (= [1 2 3] (colors/hsl->rgb [210.0 0.5 0.00784313725490196]))) - (t/is (= [210.0 0.5 0.00784313725490196] (colors/rgb->hsl [1 2 3]))) - #?(:cljs (t/is (= (colors/hsl->rgb [210 0.5 0.00784313725490196]) - (vec (gcolors/hslToRgb 210 0.5 0.00784313725490196)))))) - -(t/deftest expand-hex - (t/is (= "aaaaaa" (colors/expand-hex "a"))) - (t/is (= "aaaaaa" (colors/expand-hex "aa"))) - (t/is (= "aaaaaa" (colors/expand-hex "aaa"))) - (t/is (= "aaaa" (colors/expand-hex "aaaa")))) - -(t/deftest prepend-hash - (t/is "#aaa" (colors/prepend-hash "aaa")) - (t/is "#aaa" (colors/prepend-hash "#aaa"))) - -(t/deftest remove-hash - (t/is "aaa" (colors/remove-hash "aaa")) - (t/is "aaa" (colors/remove-hash "#aaa"))) - -(t/deftest color-string-pred - (t/is (true? (colors/color-string? "#aaa"))) - (t/is (true? (colors/color-string? "(10,10,10)"))) - (t/is (true? (colors/color-string? "rgb(10,10,10)"))) - (t/is (true? (colors/color-string? "magenta"))) - (t/is (false? (colors/color-string? nil))) - (t/is (false? (colors/color-string? ""))) - (t/is (false? (colors/color-string? "kkkkkk")))) - ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; app.common.colors tests ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -387,82 +304,3 @@ (t/is (= 0.25 (c/reduce-range 0.3 4))) (t/is (= 0.0 (c/reduce-range 0.0 10)))) -;; --- Gradient helpers (app.common.types.color) - -(t/deftest types-interpolate-color - (t/testing "at c1 offset returns c1 color" - (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} - c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} - result (colors/interpolate-color c1 c2 0.0)] - (t/is (= "#000000" (:color result))) - (t/is (= 0.0 (:opacity result))))) - (t/testing "at c2 offset returns c2 color" - (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} - c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} - result (colors/interpolate-color c1 c2 1.0)] - (t/is (= "#ffffff" (:color result))) - (t/is (= 1.0 (:opacity result))))) - (t/testing "at midpoint returns interpolated gray" - (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} - c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} - result (colors/interpolate-color c1 c2 0.5)] - (t/is (= "#7f7f7f" (:color result))) - (t/is (mth/close? (:opacity result) 0.5))))) - -(t/deftest types-uniform-spread - (t/testing "produces correct count and offsets" - (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} - c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} - stops (colors/uniform-spread c1 c2 3)] - (t/is (= 3 (count stops))) - (t/is (= 0.0 (:offset (first stops)))) - (t/is (mth/close? 0.5 (:offset (second stops)))) - (t/is (= 1.0 (:offset (last stops)))))) - (t/testing "single stop returns a vector of one element (no division by zero)" - (let [c1 {:color "#ff0000" :opacity 1.0 :offset 0.0} - stops (colors/uniform-spread c1 c1 1)] - (t/is (= 1 (count stops)))))) - -(t/deftest types-uniform-spread? - (t/testing "uniformly spread stops are detected as uniform" - (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} - c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} - stops (colors/uniform-spread c1 c2 3)] - (t/is (true? (colors/uniform-spread? stops))))) - (t/testing "two-stop gradient is uniform by definition" - (let [stops [{:color "#ff0000" :opacity 1.0 :offset 0.0} - {:color "#0000ff" :opacity 1.0 :offset 1.0}]] - (t/is (true? (colors/uniform-spread? stops))))) - (t/testing "stops with wrong offset are not uniform" - (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} - {:color "#888888" :opacity 0.5 :offset 0.3} - {:color "#ffffff" :opacity 1.0 :offset 1.0}]] - (t/is (false? (colors/uniform-spread? stops))))) - (t/testing "stops with correct offset but wrong color are not uniform" - (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} - {:color "#aaaaaa" :opacity 0.5 :offset 0.5} - {:color "#ffffff" :opacity 1.0 :offset 1.0}]] - (t/is (false? (colors/uniform-spread? stops)))))) - -(t/deftest types-interpolate-gradient - (t/testing "at start offset returns first stop color" - (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} - {:color "#ffffff" :opacity 1.0 :offset 1.0}] - result (colors/interpolate-gradient stops 0.0)] - (t/is (= "#000000" (:color result))))) - (t/testing "at end offset returns last stop color" - (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} - {:color "#ffffff" :opacity 1.0 :offset 1.0}] - result (colors/interpolate-gradient stops 1.0)] - (t/is (= "#ffffff" (:color result))))) - (t/testing "at midpoint returns interpolated gray" - (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} - {:color "#ffffff" :opacity 1.0 :offset 1.0}] - result (colors/interpolate-gradient stops 0.5)] - (t/is (= "#7f7f7f" (:color result))))) - (t/testing "offset beyond last stop returns last stop color (nil idx guard)" - (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} - {:color "#ffffff" :opacity 1.0 :offset 0.5}] - result (colors/interpolate-gradient stops 1.0)] - (t/is (= "#ffffff" (:color result)))))) - diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index 6df82430771..2d9a216cbc5 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -54,6 +54,7 @@ [common-tests.text-test] [common-tests.time-test] [common-tests.types.absorb-assets-test] + [common-tests.types.color-test] [common-tests.types.components-test] [common-tests.types.container-test] [common-tests.types.fill-test] @@ -126,6 +127,7 @@ 'common-tests.text-test 'common-tests.time-test 'common-tests.types.absorb-assets-test + 'common-tests.types.color-test 'common-tests.types.components-test 'common-tests.types.container-test 'common-tests.types.fill-test diff --git a/common/test/common_tests/types/color_test.cljc b/common/test/common_tests/types/color_test.cljc new file mode 100644 index 00000000000..9a3ab00ac91 --- /dev/null +++ b/common/test/common_tests/types/color_test.cljc @@ -0,0 +1,166 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns common-tests.types.color-test + (:require + [app.common.math :as mth] + [app.common.types.color :as colors] + [clojure.test :as t])) + +;; --- Predicates + +(t/deftest valid-hex-color + (t/is (false? (colors/valid-hex-color? nil))) + (t/is (false? (colors/valid-hex-color? ""))) + (t/is (false? (colors/valid-hex-color? "#"))) + (t/is (false? (colors/valid-hex-color? "#qqqqqq"))) + (t/is (true? (colors/valid-hex-color? "#aaa"))) + (t/is (false? (colors/valid-hex-color? "#aaaa"))) + (t/is (true? (colors/valid-hex-color? "#fabada")))) + +(t/deftest valid-rgb-color + (t/is (false? (colors/valid-rgb-color? nil))) + (t/is (false? (colors/valid-rgb-color? ""))) + (t/is (false? (colors/valid-rgb-color? "()"))) + (t/is (true? (colors/valid-rgb-color? "(255, 30, 30)"))) + (t/is (true? (colors/valid-rgb-color? "rgb(255, 30, 30)")))) + +;; --- Conversions + +(t/deftest rgb-to-str + (t/is (= "rgb(1,2,3)" (colors/rgb->str [1 2 3]))) + (t/is (= "rgba(1,2,3,4)" (colors/rgb->str [1 2 3 4])))) + +(t/deftest rgb-to-hsv + (t/is (= [210.0 0.6666666666666666 3.0] (colors/rgb->hsv [1.0 2.0 3.0])))) + +(t/deftest hsv-to-rgb + (t/is (= [1 2 3] + (colors/hsv->rgb [210 0.6666666666666666 3])))) + +(t/deftest rgb-to-hex + (t/is (= "#010203" (colors/rgb->hex [1 2 3])))) + +(t/deftest hex-to-rgb + (t/is (= [0 0 0] (colors/hex->rgb "#kkk"))) + (t/is (= [1 2 3] (colors/hex->rgb "#010203")))) + +(t/deftest format-hsla + (t/is (= "210, 50%, 0.78%, 1" (colors/format-hsla [210.0 0.5 0.00784313725490196 1]))) + (t/is (= "220, 5%, 30%, 0.8" (colors/format-hsla [220.0 0.05 0.3 0.8])))) + +(t/deftest format-rgba + (t/is (= "210, 199, 12, 0.08" (colors/format-rgba [210 199 12 0.08]))) + (t/is (= "210, 199, 12, 1" (colors/format-rgba [210 199 12 1])))) + +(t/deftest rgb-to-hsl + (t/is (= [210.0 0.5 0.00784313725490196] (colors/rgb->hsl [1 2 3])))) + +(t/deftest hsl-to-rgb + (t/is (= [1 2 3] (colors/hsl->rgb [210.0 0.5 0.00784313725490196]))) + (t/is (= [210.0 0.5 0.00784313725490196] (colors/rgb->hsl [1 2 3])))) + +(t/deftest expand-hex + (t/is (= "aaaaaa" (colors/expand-hex "a"))) + (t/is (= "aaaaaa" (colors/expand-hex "aa"))) + (t/is (= "aaaaaa" (colors/expand-hex "aaa"))) + (t/is (= "aaaa" (colors/expand-hex "aaaa")))) + +(t/deftest prepend-hash + (t/is "#aaa" (colors/prepend-hash "aaa")) + (t/is "#aaa" (colors/prepend-hash "#aaa"))) + +(t/deftest remove-hash + (t/is "aaa" (colors/remove-hash "aaa")) + (t/is "aaa" (colors/remove-hash "#aaa"))) + +(t/deftest color-string-pred + (t/is (true? (colors/color-string? "#aaa"))) + (t/is (true? (colors/color-string? "(10,10,10)"))) + (t/is (true? (colors/color-string? "rgb(10,10,10)"))) + (t/is (true? (colors/color-string? "magenta"))) + (t/is (false? (colors/color-string? nil))) + (t/is (false? (colors/color-string? ""))) + (t/is (false? (colors/color-string? "kkkkkk")))) + +;; --- Gradient helpers + +(t/deftest interpolate-color + (t/testing "at c1 offset returns c1 color" + (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} + c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} + result (colors/interpolate-color c1 c2 0.0)] + (t/is (= "#000000" (:color result))) + (t/is (= 0.0 (:opacity result))))) + (t/testing "at c2 offset returns c2 color" + (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} + c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} + result (colors/interpolate-color c1 c2 1.0)] + (t/is (= "#ffffff" (:color result))) + (t/is (= 1.0 (:opacity result))))) + (t/testing "at midpoint returns interpolated gray" + (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} + c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} + result (colors/interpolate-color c1 c2 0.5)] + (t/is (= "#7f7f7f" (:color result))) + (t/is (mth/close? (:opacity result) 0.5))))) + +(t/deftest uniform-spread + (t/testing "produces correct count and offsets" + (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} + c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} + stops (colors/uniform-spread c1 c2 3)] + (t/is (= 3 (count stops))) + (t/is (= 0.0 (:offset (first stops)))) + (t/is (mth/close? 0.5 (:offset (second stops)))) + (t/is (= 1.0 (:offset (last stops)))))) + (t/testing "single stop returns a vector of one element (no division by zero)" + (let [c1 {:color "#ff0000" :opacity 1.0 :offset 0.0} + stops (colors/uniform-spread c1 c1 1)] + (t/is (= 1 (count stops)))))) + +(t/deftest uniform-spread? + (t/testing "uniformly spread stops are detected as uniform" + (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} + c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} + stops (colors/uniform-spread c1 c2 3)] + (t/is (true? (colors/uniform-spread? stops))))) + (t/testing "two-stop gradient is uniform by definition" + (let [stops [{:color "#ff0000" :opacity 1.0 :offset 0.0} + {:color "#0000ff" :opacity 1.0 :offset 1.0}]] + (t/is (true? (colors/uniform-spread? stops))))) + (t/testing "stops with wrong offset are not uniform" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#888888" :opacity 0.5 :offset 0.3} + {:color "#ffffff" :opacity 1.0 :offset 1.0}]] + (t/is (false? (colors/uniform-spread? stops))))) + (t/testing "stops with correct offset but wrong color are not uniform" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#aaaaaa" :opacity 0.5 :offset 0.5} + {:color "#ffffff" :opacity 1.0 :offset 1.0}]] + (t/is (false? (colors/uniform-spread? stops)))))) + +(t/deftest interpolate-gradient + (t/testing "at start offset returns first stop color" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#ffffff" :opacity 1.0 :offset 1.0}] + result (colors/interpolate-gradient stops 0.0)] + (t/is (= "#000000" (:color result))))) + (t/testing "at end offset returns last stop color" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#ffffff" :opacity 1.0 :offset 1.0}] + result (colors/interpolate-gradient stops 1.0)] + (t/is (= "#ffffff" (:color result))))) + (t/testing "at midpoint returns interpolated gray" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#ffffff" :opacity 1.0 :offset 1.0}] + result (colors/interpolate-gradient stops 0.5)] + (t/is (= "#7f7f7f" (:color result))))) + (t/testing "offset beyond last stop returns last stop color (nil idx guard)" + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#ffffff" :opacity 1.0 :offset 0.5}] + result (colors/interpolate-gradient stops 1.0)] + (t/is (= "#ffffff" (:color result)))))) From a2e6abcb72b13843c82a7d88f6e56e166f259e3f Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 20:03:39 +0000 Subject: [PATCH 036/162] :bug: Fix spurious argument to dissoc in patch-object The patch-object function was calling (dissoc object key value) when handling nil values. Since dissoc treats each argument after the map as a key to remove, this was also removing nil as a key from the map. The correct call is (dissoc object key). --- common/src/app/common/data.cljc | 2 +- common/test/common_tests/data_test.cljc | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 4cb6cedc600..7f59c14ad65 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -377,7 +377,7 @@ (assoc object key nil) (nil? value) - (dissoc object key value) + (dissoc object key) :else (assoc object key value))) diff --git a/common/test/common_tests/data_test.cljc b/common/test/common_tests/data_test.cljc index 726fc8f3778..873f0bb7d1d 100644 --- a/common/test/common_tests/data_test.cljc +++ b/common/test/common_tests/data_test.cljc @@ -445,6 +445,8 @@ (t/is (= {:a {:x 10 :y 2}} (d/patch-object {:a {:x 1 :y 2}} {:a {:x 10}}))) ;; nested nil removes nested key (t/is (= {:a {:y 2}} (d/patch-object {:a {:x 1 :y 2}} {:a {:x nil}}))) + ;; nil value removes only the specified key, not other keys + (t/is (= {nil 0 :b 2} (d/patch-object {nil 0 :a 1 :b 2} {:a nil}))) ;; transducer arity (1-arg returns a fn) (let [f (d/patch-object {:a 99})] (t/is (= {:a 99 :b 2} (f {:a 1 :b 2}))))) From 057c6ddc0df47331de9d7dd7977317e8e54cb1e6 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 20:04:36 +0000 Subject: [PATCH 037/162] :bug: Fix deep-mapm double-applying mfn on leaf entries The deep-mapm function was applying the mapping function twice on leaf entries (non-map, non-vector values): once when destructuring the entry, and again on the already-transformed result in the else branch. Now mfn is applied exactly once per entry. --- common/src/app/common/data.cljc | 7 ++----- common/test/common_tests/data_test.cljc | 17 ++++++++++------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 7f59c14ad65..93d66780f58 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -602,12 +602,9 @@ (let [do-map (fn [entry] (let [[k v] (mfn entry)] - (cond - (or (vector? v) (map? v)) + (if (or (vector? v) (map? v)) [k (deep-mapm mfn v)] - - :else - (mfn [k v]))))] + [k v])))] (cond (map? m) (into {} (map do-map) m) diff --git a/common/test/common_tests/data_test.cljc b/common/test/common_tests/data_test.cljc index 873f0bb7d1d..7cad2da9110 100644 --- a/common/test/common_tests/data_test.cljc +++ b/common/test/common_tests/data_test.cljc @@ -538,17 +538,20 @@ (into [] (d/distinct-xf :id) [{:id 1 :v "a"} {:id 2 :v "x"} {:id 2 :v "b"}])))) (t/deftest deep-mapm-test - ;; Note: mfn is called twice on leaf entries (once initially, once again - ;; after checking if the value is a map/vector), so a doubling fn applied - ;; to value 1 gives 1*2*2=4. - (t/is (= {:a 4 :b {:c 8}} + ;; mfn is applied once per entry + (t/is (= {:a 2 :b {:c 4}} (d/deep-mapm (fn [[k v]] [k (if (number? v) (* v 2) v)]) {:a 1 :b {:c 2}}))) - ;; Keyword renaming: keys are also transformed — and applied twice. - ;; Use an idempotent key transformation (uppercase once = uppercase twice). + ;; Keyword renaming: keys are transformed once per entry (let [result (d/deep-mapm (fn [[k v]] [(keyword (str (name k) "!")) v]) {:a 1})] - (t/is (contains? result (keyword "a!!"))))) + (t/is (contains? result (keyword "a!")))) + ;; Vectors inside maps are recursed into + (t/is (= {:items [{:x 10}]} + (d/deep-mapm (fn [[k v]] [k (if (number? v) (* v 10) v)]) + {:items [{:x 1}]}))) + ;; Plain scalar at top level map + (t/is (= {:a "hello"} (d/deep-mapm identity {:a "hello"})))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Numeric helpers From 92dd5d9954d981bfddd5bff5e2bb6300d21c365f Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 20:05:35 +0000 Subject: [PATCH 038/162] :bug: Fix index-of-pred early termination on nil elements The index-of-pred function used (nil? c) to detect end-of-collection, which caused premature termination when the collection contained nil values. Rewrite using (seq coll) / (next s) pattern to correctly distinguish between nil elements and end-of-sequence. --- common/src/app/common/data.cljc | 11 ++++------- common/test/common_tests/data_test.cljc | 11 +++++++++-- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 93d66780f58..5f4b4a0cf25 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -291,15 +291,12 @@ (defn index-of-pred [coll pred] - (loop [c (first coll) - coll (rest coll) + (loop [s (seq coll) index 0] - (if (nil? c) - nil - (if (pred c) + (when s + (if (pred (first s)) index - (recur (first coll) - (rest coll) + (recur (next s) (inc index)))))) (defn index-of diff --git a/common/test/common_tests/data_test.cljc b/common/test/common_tests/data_test.cljc index 7cad2da9110..f0487ed71d8 100644 --- a/common/test/common_tests/data_test.cljc +++ b/common/test/common_tests/data_test.cljc @@ -372,12 +372,19 @@ (t/is (= 0 (d/index-of-pred [1 2 3] odd?))) (t/is (= 1 (d/index-of-pred [2 3 4] odd?))) (t/is (nil? (d/index-of-pred [2 4 6] odd?))) - (t/is (nil? (d/index-of-pred [] odd?)))) + (t/is (nil? (d/index-of-pred [] odd?))) + ;; works correctly when collection contains nil elements + (t/is (= 2 (d/index-of-pred [nil nil 3] some?))) + (t/is (= 0 (d/index-of-pred [nil 1 2] nil?))) + ;; works correctly when collection contains false elements + (t/is (= 1 (d/index-of-pred [false true false] true?)))) (t/deftest index-of-test (t/is (= 0 (d/index-of [:a :b :c] :a))) (t/is (= 2 (d/index-of [:a :b :c] :c))) - (t/is (nil? (d/index-of [:a :b :c] :z)))) + (t/is (nil? (d/index-of [:a :b :c] :z))) + ;; works when searching for nil in a collection + (t/is (= 1 (d/index-of [:a nil :c] nil)))) (t/deftest replace-by-id-test (let [items [{:id 1 :v "a"} {:id 2 :v "b"} {:id 3 :v "c"}] From 1cc860807e2246a16bd00a99991fef04cdce3366 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 20:06:13 +0000 Subject: [PATCH 039/162] :zap: Use seq/next idiom in enumerate instead of empty?/rest Replace (empty? items) + (rest items) with (seq items) + (next items) in enumerate. The seq/next pattern is idiomatic Clojure and avoids the overhead of empty? which internally calls seq and then negates. --- common/src/app/common/data.cljc | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 5f4b4a0cf25..2da21aadb03 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -252,13 +252,13 @@ ([items] (enumerate items 0)) ([items start] (loop [idx start - items items + items (seq items) res (transient [])] - (if (empty? items) - (persistent! res) + (if items (recur (inc idx) - (rest items) - (conj! res [idx (first items)])))))) + (next items) + (conj! res [idx (first items)])) + (persistent! res))))) (defn group-by ([kf coll] (group-by kf identity [] coll)) From d73ab3ec92b4af8a445db1545f54af9de614a512 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 20:11:46 +0000 Subject: [PATCH 040/162] :bug: Fix safe-subvec 3-arity evaluating (count v) before nil check The 3-arity of safe-subvec called (count v) in a let binding before checking (some? v). While (count nil) returns 0 in Clojure and does not crash, the nil guard was dead code. Restructure to check (some? v) first with an outer when, then compute size inside the guarded block. --- common/src/app/common/data.cljc | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 2da21aadb03..6b2cc5d3b43 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -1149,11 +1149,11 @@ (> start 0) (< start (count v))) (subvec v start))) ([v start end] - (let [size (count v)] - (when (and (some? v) - (>= start 0) (< start size) - (>= end 0) (<= start end) (<= end size)) - (subvec v start end))))) + (when (some? v) + (let [size (count v)] + (when (and (>= start 0) (< start size) + (>= end 0) (<= start end) (<= end size)) + (subvec v start end)))))) (defn append-class [class current-class] From 29ea1cc49548d1a63c33bbdf44447f0d3c45e914 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 20:12:38 +0000 Subject: [PATCH 041/162] :books: Fix misleading without-obj docstring The docstring claimed the function removes nil values in addition to the specified object, but the implementation only removes elements equal to the given object. Fix the docstring in both data.cljc and the local copy in files/changes.cljc. --- common/src/app/common/data.cljc | 2 +- common/src/app/common/files/changes.cljc | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 6b2cc5d3b43..19d4828d132 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -393,7 +393,7 @@ (subvec v (inc index)))) (defn without-obj - "Clear collection from specified obj and without nil values." + "Return a vector with all elements equal to `o` removed." [coll o] (into [] (filter #(not= % o)) coll)) diff --git a/common/src/app/common/files/changes.cljc b/common/src/app/common/files/changes.cljc index 8673ef81e32..c9793434b72 100644 --- a/common/src/app/common/files/changes.cljc +++ b/common/src/app/common/files/changes.cljc @@ -439,7 +439,7 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (defn- without-obj - "Clear collection from specified obj and without nil values." + "Return a vector with all elements equal to `o` removed." [coll o] (into [] (filter #(not= % o)) coll)) From eca9b63d688c87e62a58bfdba9a683c91061ce29 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 20:13:20 +0000 Subject: [PATCH 042/162] :zap: Remove redundant map lookups in map-diff The :else branch of diff-attr was calling (get m1 key) and (get m2 key) again, but v1 and v2 were already bound to those exact values. Reuse the existing bindings to avoid the extra lookups. --- common/src/app/common/data.cljc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 19d4828d132..1494132f1db 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -952,7 +952,7 @@ (assoc diff key (map-diff v1 v2)) :else - (assoc diff key [(get m1 key) (get m2 key)]))))] + (assoc diff key [v1 v2]))))] (->> keys (reduce diff-attr {})))) From 69e25a4998e006108cc17f6f2817cbc41403be82 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 20:14:00 +0000 Subject: [PATCH 043/162] :books: Fix typo in namespace docstring ('if' -> 'of') --- common/src/app/common/data.cljc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 1494132f1db..e1efba5de65 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -5,7 +5,7 @@ ;; Copyright (c) KALEIDOS INC (ns app.common.data - "A collection if helpers for working with data structures and other + "A collection of helpers for working with data structures and other data resources." (:refer-clojure :exclude [read-string hash-map merge name update-vals parse-double group-by iteration concat mapcat From da8e44147c72c5b19b7e0295bd97b730fec8d9b9 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 20:15:39 +0000 Subject: [PATCH 044/162] :sparkles: Remove redundant str call in format-number format-precision already returns a string, so wrapping its result in an additional (str ...) call was unnecessary. --- common/src/app/common/data.cljc | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index e1efba5de65..70f775b4c3c 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -1117,8 +1117,7 @@ ([value {:keys [precision] :or {precision 2}}] (let [value (if (string? value) (parse-double value) value)] (when (num? value) - (let [value (format-precision value precision)] - (str value)))))) + (format-precision value precision))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Util protocols From 83da487b24cf969d99cdba0ba292e406c93f0df6 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 20:16:49 +0000 Subject: [PATCH 045/162] :bug: Fix append-class producing leading space for empty class When called with an empty string as the base class, append-class was producing " bar" (with a leading space) because (some? "") returns true. Use (seq class) instead to treat both nil and empty string as absent, avoiding invalid CSS class strings with leading whitespace. --- common/src/app/common/data.cljc | 5 +++-- common/test/common_tests/data_test.cljc | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 70f775b4c3c..715bd975448 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -1156,5 +1156,6 @@ (defn append-class [class current-class] - (str (if (some? class) (str class " ") "") - current-class)) + (if (seq class) + (str class " " current-class) + current-class)) diff --git a/common/test/common_tests/data_test.cljc b/common/test/common_tests/data_test.cljc index f0487ed71d8..d85e835af90 100644 --- a/common/test/common_tests/data_test.cljc +++ b/common/test/common_tests/data_test.cljc @@ -791,7 +791,8 @@ (t/deftest append-class-test (t/is (= "foo bar" (d/append-class "foo" "bar"))) (t/is (= "bar" (d/append-class nil "bar"))) - (t/is (= " bar" (d/append-class "" "bar")))) + ;; empty string is treated like nil — no leading space + (t/is (= "bar" (d/append-class "" "bar")))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Additional helpers (5th batch) From bba3610b7beb9e0f0063a9923fc92bfdbba03d54 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 20:17:48 +0000 Subject: [PATCH 046/162] :recycle: Rename shadowed 'fn' parameter to 'pred' in removev The removev function used 'fn' as its predicate parameter name, which shadows clojure.core/fn. Rename to 'pred' for clarity and to follow the naming convention used elsewhere in the namespace. --- common/src/app/common/data.cljc | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 715bd975448..86a733ee28d 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -420,9 +420,9 @@ coll))) (defn removev - "Returns a vector of the items in coll for which (fn item) returns logical false" - [fn coll] - (filterv (comp not fn) coll)) + "Returns a vector of the items in coll for which (pred item) returns logical false" + [pred coll] + (filterv (comp not pred) coll)) (defn filterm "Filter values of a map that satisfy a predicate" From 95d4d42c911e12294def92b09ce398de99801162 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 20:18:55 +0000 Subject: [PATCH 047/162] :bug: Add missing string? guard to num-string? on JVM The CLJS branch of num-string? checked (string? v) first, but the JVM branch did not. Passing non-string values (nil, keywords, etc.) would rely on exception handling inside parse-double for control flow. Add the string? check for consistency and to avoid using exceptions for normal control flow. --- common/src/app/common/data.cljc | 3 ++- common/test/common_tests/data_test.cljc | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 86a733ee28d..e9bb3a918ff 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -782,7 +782,8 @@ (not (js/isNaN v)) (not (js/isNaN (parse-double v)))) - :clj (not= (parse-double v :nan) :nan))) + :clj (and (string? v) + (not= (parse-double v :nan) :nan)))) (defn read-string [v] diff --git a/common/test/common_tests/data_test.cljc b/common/test/common_tests/data_test.cljc index d85e835af90..3228ec0298f 100644 --- a/common/test/common_tests/data_test.cljc +++ b/common/test/common_tests/data_test.cljc @@ -841,6 +841,9 @@ (t/is (d/num-string? "-7")) (t/is (not (d/num-string? "hello"))) (t/is (not (d/num-string? nil))) + ;; non-string types always return false + (t/is (not (d/num-string? 42))) + (t/is (not (d/num-string? :keyword))) ;; In CLJS, js/isNaN("") → false (empty string coerces to 0), so "" is numeric #?(:clj (t/is (not (d/num-string? "")))) #?(:cljs (t/is (d/num-string? "")))) From b26ef158ef7f9abec223486504aa6e0ecf73704a Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 20:21:30 +0000 Subject: [PATCH 048/162] :books: Fix typos in vec2, zip-all, and map-perm docstrings --- common/src/app/common/data.cljc | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index e9bb3a918ff..258b506895e 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -143,7 +143,7 @@ (oassoc-in o (cons k ks) v))) (defn vec2 - "Creates a optimized vector compatible type of length 2 backed + "Creates an optimized vector compatible type of length 2 backed internally with MapEntry impl because it has faster access method for its fields." [o1 o2] @@ -401,7 +401,7 @@ (map vector col1 col2)) (defn zip-all - "Return a zip of both collections, extended to the lenght of the longest one, + "Return a zip of both collections, extended to the length of the longest one, and padding the shorter one with nils as needed." [col1 col2] (let [diff (- (count col1) (count col2))] @@ -440,7 +440,7 @@ Optional parameters: `pred?` A predicate that if not satisfied won't process the pair - `target?` A collection that will be used as seed to be stored + `target` A collection that will be used as seed to be stored Example: (map-perm vector [1 2 3 4]) => [[1 2] [1 3] [1 4] [2 3] [2 4] [3 4]]" From 176edadb6f22ded8c7f1b0458b8b03f948d076c0 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 22:57:29 +0000 Subject: [PATCH 049/162] :bug: Fix nan? returning false for ##NaN on JVM Clojure's = uses .equals on doubles, and Double.equals(Double.NaN) returns true, so (not= v v) was always false for NaN. Use Double/isNaN with a number? guard instead. --- common/src/app/common/data.cljc | 2 +- common/test/common_tests/data_test.cljc | 22 +++++++--------------- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 258b506895e..4c03438ed2b 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -718,7 +718,7 @@ (defn nan? [v] #?(:cljs (js/isNaN v) - :clj (not= v v))) + :clj (and (number? v) (Double/isNaN v)))) (defn- impl-parse-integer [v] diff --git a/common/test/common_tests/data_test.cljc b/common/test/common_tests/data_test.cljc index 3228ec0298f..ad5d6a21a2e 100644 --- a/common/test/common_tests/data_test.cljc +++ b/common/test/common_tests/data_test.cljc @@ -565,16 +565,13 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (t/deftest nan-test - ;; Note: nan? behaves differently per platform: - ;; - CLJS: uses js/isNaN, returns true for ##NaN - ;; - CLJ: uses (not= v v); Clojure's = uses .equals on doubles, - ;; so (= ##NaN ##NaN) is true and nan? returns false for ##NaN. - ;; Either way, nan? returns false for regular numbers and nil. + (t/is (d/nan? ##NaN)) (t/is (not (d/nan? 0))) (t/is (not (d/nan? 1))) (t/is (not (d/nan? nil))) - ;; Platform-specific: JS nan? correctly detects NaN - #?(:cljs (t/is (d/nan? ##NaN)))) + ;; CLJS js/isNaN coerces non-numbers; JVM Double/isNaN is number-only + #?(:cljs (t/is (d/nan? "hello"))) + #?(:clj (t/is (not (d/nan? "hello"))))) (t/deftest safe-plus-test (t/is (= 5 (d/safe+ 3 2))) @@ -618,18 +615,13 @@ (t/is (nil? (d/parse-uuid nil)))) (t/deftest coalesce-str-test - ;; On JVM: nan? uses (not= v v), which is false for all normal values. - ;; On CLJS: nan? uses js/isNaN, which is true for non-numeric strings. - ;; coalesce-str returns default when value is nil or nan?. (t/is (= "default" (d/coalesce-str nil "default"))) ;; Numbers always stringify on both platforms (t/is (= "42" (d/coalesce-str 42 "default"))) - ;; ##NaN: nan? is true in CLJS, returns default; - ;; nan? is false in CLJ, so str(##NaN)="NaN" is returned. - #?(:cljs (t/is (= "default" (d/coalesce-str ##NaN "default")))) - #?(:clj (t/is (= "NaN" (d/coalesce-str ##NaN "default")))) + ;; ##NaN returns default on both platforms now that nan? is fixed on JVM + (t/is (= "default" (d/coalesce-str ##NaN "default"))) ;; Strings: in CLJS js/isNaN("hello")=true so "default" is returned; - ;; in CLJ nan? is false so (str "hello")="hello" is returned. + ;; in CLJ nan? is false for strings so (str "hello")="hello" is returned. #?(:cljs (t/is (= "default" (d/coalesce-str "hello" "default")))) #?(:clj (t/is (= "hello" (d/coalesce-str "hello" "default"))))) From 2e97f0183817fc2a422975f0a983874edd68d5c2 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 14 Apr 2026 22:58:13 +0000 Subject: [PATCH 050/162] :bug: Fix safe-subvec 2-arity rejecting start=0 The guard used (> start 0) instead of (>= start 0), so (safe-subvec v 0) returned nil instead of the full vector. --- common/src/app/common/data.cljc | 2 +- common/test/common_tests/data_test.cljc | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 4c03438ed2b..75f103e4969 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -1146,7 +1146,7 @@ "Wrapper around subvec so it doesn't throw an exception but returns nil instead" ([v start] (when (and (some? v) - (> start 0) (< start (count v))) + (>= start 0) (< start (count v))) (subvec v start))) ([v start end] (when (some? v) diff --git a/common/test/common_tests/data_test.cljc b/common/test/common_tests/data_test.cljc index ad5d6a21a2e..b8283ca49e4 100644 --- a/common/test/common_tests/data_test.cljc +++ b/common/test/common_tests/data_test.cljc @@ -325,6 +325,8 @@ (t/is (= [2 3] (d/safe-subvec [1 2 3 4] 1 3))) ;; single arg — from index to end (t/is (= [2 3 4] (d/safe-subvec [1 2 3 4] 1))) + ;; start=0 returns the full vector + (t/is (= [1 2 3 4] (d/safe-subvec [1 2 3 4] 0))) ;; out-of-range returns nil (t/is (nil? (d/safe-subvec [1 2 3] 5))) (t/is (nil? (d/safe-subvec [1 2 3] 0 5))) From f5271dabeed15990f9e6b9e68482c9c26ecf4496 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 15 Apr 2026 23:37:04 +0200 Subject: [PATCH 051/162] :bug: Fix error handling issues (#8962) * :ambulance: Fix RangeError from re-entrant error handling in errors.cljs Two complementary changes to prevent 'RangeError: Maximum call stack size exceeded' when an error fires while the potok store error pipeline is still on the call stack: 1. Re-entrancy guard on on-error: a volatile flag (handling-error?) is set true for the duration of each on-error invocation. Any nested call (e.g. from a notification emit that itself throws) is suppressed with a console.error instead of recursing indefinitely. 2. Async notification in flash: the st/emit!(ntf/show ...) call is now wrapped in ts/schedule (setTimeout 0) so the notification event is pushed to the store on the next event-loop tick, outside the error-handler call stack. This matches the pattern already used by the :worker-error, :svg-parser and :comment-error handlers. * :bug: Add unit tests for app.main.errors Test coverage for the error-handling module: - stale-asset-error?: 6 cases covering keyword-constant and protocol-dispatch mismatch signatures, plus negative cases - exception->error-data: plain JS Error, ex-info with/without :hint - on-error dispatch: map errors routed via ptk/handle-error, JS exceptions wrapped into error-data before dispatch - Re-entrancy guard: verifies that a second on-error call issued from within a handle-error method is suppressed (exactly one handler invocation) --------- Signed-off-by: Andrey Antukh --- frontend/src/app/main/errors.cljs | 48 +++++-- .../test/frontend_tests/main_errors_test.cljs | 136 ++++++++++++++++++ frontend/test/frontend_tests/runner.cljs | 2 + 3 files changed, 175 insertions(+), 11 deletions(-) create mode 100644 frontend/test/frontend_tests/main_errors_test.cljs diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index 223e9162616..37177aec7d9 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -33,6 +33,12 @@ ;; Will contain last uncaught exception (def last-exception nil) +;; Re-entrancy guard: prevents on-error from calling itself recursively. +;; If an error occurs while we are already handling an error (e.g. the +;; notification emit itself throws), we log it and bail out immediately +;; instead of recursing until the call-stack overflows. +(def ^:private handling-error? (volatile! false)) + ;; --- Stale-asset error detection and auto-reload ;; ;; When the browser loads JS modules from different builds (e.g. shared.js from @@ -80,12 +86,24 @@ (assoc ::trace (.-stack cause))))) (defn on-error - "A general purpose error handler." + "A general purpose error handler. + + Protected by a re-entrancy guard: if an error is raised while this + function is already on the call stack (e.g. the notification emit + itself fails), we print it to the console and return immediately + instead of recursing until the call-stack is exhausted." [error] - (if (map? error) - (ptk/handle-error error) - (let [data (exception->error-data error)] - (ptk/handle-error data)))) + (if @handling-error? + (.error js/console "[on-error] re-entrant call suppressed" error) + (do + (vreset! handling-error? true) + (try + (if (map? error) + (ptk/handle-error error) + (let [data (exception->error-data error)] + (ptk/handle-error data))) + (finally + (vreset! handling-error? false)))))) ;; Inject dependency to remove circular dependency (set! app.main.worker/on-error on-error) @@ -138,7 +156,14 @@ :report report})))) (defn flash - "Show error notification banner and emit error report" + "Show error notification banner and emit error report. + + The notification is scheduled asynchronously (via tm/schedule) to + avoid pushing a new event into the potok store while the store's own + error-handling pipeline is still on the call stack. Emitting + synchronously from inside an error handler creates a re-entrant + event-processing cycle that can exhaust the JS call stack + (RangeError: Maximum call stack size exceeded)." [& {:keys [type hint cause] :or {type :handled}}] (when (ex/exception? cause) (when-let [event-name (case type @@ -150,11 +175,12 @@ :report report :hint (ex/get-hint cause))))) - (st/emit! - (ntf/show {:content (or ^boolean hint (tr "errors.generic")) - :type :toast - :level :error - :timeout 5000}))) + (ts/schedule + #(st/emit! + (ntf/show {:content (or ^boolean hint (tr "errors.generic")) + :type :toast + :level :error + :timeout 5000})))) (defmethod ptk/handle-error :network [error] diff --git a/frontend/test/frontend_tests/main_errors_test.cljs b/frontend/test/frontend_tests/main_errors_test.cljs new file mode 100644 index 00000000000..5dc17476589 --- /dev/null +++ b/frontend/test/frontend_tests/main_errors_test.cljs @@ -0,0 +1,136 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns frontend-tests.main-errors-test + "Unit tests for app.main.errors. + + Tests cover: + - stale-asset-error? – pure predicate + - exception->error-data – pure transformer + - on-error re-entrancy guard – prevents recursive invocations + - flash schedules async emit – ntf/show is not emitted synchronously" + (:require + [app.main.errors :as errors] + [cljs.test :as t :include-macros true] + [potok.v2.core :as ptk])) + +;; --------------------------------------------------------------------------- +;; stale-asset-error? +;; --------------------------------------------------------------------------- + +(t/deftest stale-asset-error-nil + (t/testing "nil cause returns nil/falsy" + (t/is (not (errors/stale-asset-error? nil))))) + +(t/deftest stale-asset-error-keyword-cst-undefined + (t/testing "error with $cljs$cst$ and 'is undefined' is recognised" + (let [err (js/Error. "foo$cljs$cst$bar is undefined")] + (t/is (true? (boolean (errors/stale-asset-error? err))))))) + +(t/deftest stale-asset-error-keyword-cst-null + (t/testing "error with $cljs$cst$ and 'is null' is recognised" + (let [err (js/Error. "foo$cljs$cst$bar is null")] + (t/is (true? (boolean (errors/stale-asset-error? err))))))) + +(t/deftest stale-asset-error-protocol-dispatch-undefined + (t/testing "error with $cljs$core$I and 'Cannot read properties of undefined' is recognised" + (let [err (js/Error. "Cannot read properties of undefined (reading '$cljs$core$IFn$_invoke$arity$1$')")] + (t/is (true? (boolean (errors/stale-asset-error? err))))))) + +(t/deftest stale-asset-error-not-a-function + (t/testing "error with $cljs$cst$ and 'is not a function' is recognised" + (let [err (js/Error. "foo$cljs$cst$bar is not a function")] + (t/is (true? (boolean (errors/stale-asset-error? err))))))) + +(t/deftest stale-asset-error-unrelated-message + (t/testing "ordinary error without stale-asset signature is NOT recognised" + (let [err (js/Error. "Cannot read properties of undefined (reading 'foo')")] + (t/is (not (errors/stale-asset-error? err)))))) + +(t/deftest stale-asset-error-only-cst-no-undefined + (t/testing "error with $cljs$cst$ but no undefined/null/not-a-function keyword is not recognised" + (let [err (js/Error. "foo$cljs$cst$bar exploded")] + (t/is (not (errors/stale-asset-error? err)))))) + +;; --------------------------------------------------------------------------- +;; exception->error-data +;; --------------------------------------------------------------------------- + +(t/deftest exception->error-data-plain-error + (t/testing "plain JS Error is converted to a data map with :hint and ::instance" + (let [err (js/Error. "something went wrong") + data (errors/exception->error-data err)] + (t/is (= "something went wrong" (:hint data))) + (t/is (identical? err (::errors/instance data)))))) + +(t/deftest exception->error-data-ex-info + (t/testing "ex-info error preserves existing :hint and attaches ::instance" + (let [err (ex-info "original" {:hint "my-hint" :type :network}) + data (errors/exception->error-data err)] + (t/is (= "my-hint" (:hint data))) + (t/is (= :network (:type data))) + (t/is (identical? err (::errors/instance data)))))) + +(t/deftest exception->error-data-ex-info-no-hint + (t/testing "ex-info without :hint falls back to ex-message" + (let [err (ex-info "fallback message" {:type :validation}) + data (errors/exception->error-data err)] + (t/is (= "fallback message" (:hint data)))))) + +;; --------------------------------------------------------------------------- +;; on-error dispatches to ptk/handle-error +;; +;; We use a dedicated test-only error type so we can add/remove a +;; defmethod without touching the real handlers. +;; --------------------------------------------------------------------------- + +(def ^:private test-handled (atom nil)) + +(defmethod ptk/handle-error ::test-dispatch + [err] + (reset! test-handled err)) + +(t/deftest on-error-dispatches-map-error + (t/testing "on-error dispatches a map error to ptk/handle-error using its :type" + (reset! test-handled nil) + (errors/on-error {:type ::test-dispatch :hint "hello"}) + (t/is (= ::test-dispatch (:type @test-handled))) + (t/is (= "hello" (:hint @test-handled))))) + +(t/deftest on-error-wraps-exception-then-dispatches + (t/testing "on-error wraps a JS Error into error-data before dispatching" + (reset! test-handled nil) + (let [err (ex-info "wrapped" {:type ::test-dispatch})] + (errors/on-error err) + (t/is (= ::test-dispatch (:type @test-handled))) + (t/is (identical? err (::errors/instance @test-handled)))))) + +;; --------------------------------------------------------------------------- +;; on-error re-entrancy guard +;; +;; The guard is implemented via the `handling-error?` volatile inside +;; app.main.errors. We can verify its effect by registering a +;; handle-error method that itself calls on-error and checking that +;; only one invocation gets through. +;; --------------------------------------------------------------------------- + +(def ^:private reentrant-call-count (atom 0)) + +(defmethod ptk/handle-error ::test-reentrant + [_err] + (swap! reentrant-call-count inc) + ;; Simulate a secondary error inside the error handler + ;; (e.g. the notification emit itself throws). + ;; Without the re-entrancy guard this would recurse indefinitely. + (when (= 1 @reentrant-call-count) + (errors/on-error {:type ::test-reentrant :hint "secondary"}))) + +(t/deftest on-error-reentrancy-guard-prevents-recursion + (t/testing "a second on-error call while handling an error is suppressed by the guard" + (reset! reentrant-call-count 0) + (errors/on-error {:type ::test-reentrant :hint "first"}) + ;; The guard must have allowed only the first invocation through. + (t/is (= 1 @reentrant-call-count)))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 3cd38c12f0b..003e68264ca 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -14,6 +14,7 @@ [frontend-tests.logic.frame-guides-test] [frontend-tests.logic.groups-test] [frontend-tests.logic.pasting-in-containers-test] + [frontend-tests.main-errors-test] [frontend-tests.plugins.context-shapes-test] [frontend-tests.svg-fills-test] [frontend-tests.tokens.import-export-test] @@ -41,6 +42,7 @@ (t/run-tests 'frontend-tests.basic-shapes-test 'frontend-tests.data.repo-test + 'frontend-tests.main-errors-test 'frontend-tests.data.viewer-test 'frontend-tests.data.workspace-colors-test 'frontend-tests.data.workspace-texts-test From de27ea904da50fc5362365c43b25de13e9c3bcd5 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 16 Apr 2026 09:59:45 +0200 Subject: [PATCH 052/162] :sparkles: Add minor adjustments to the auth events (#9027) --- backend/scripts/_env | 4 ++++ backend/src/app/rpc/commands/auth.clj | 14 ++++++++++---- frontend/src/app/config.cljs | 5 +++++ frontend/src/app/main/ui/auth/register.cljs | 1 + frontend/src/app/main/ui/auth/verify_token.cljs | 2 ++ 5 files changed, 22 insertions(+), 4 deletions(-) diff --git a/backend/scripts/_env b/backend/scripts/_env index 0026d9f9010..e6ff68b7f4e 100644 --- a/backend/scripts/_env +++ b/backend/scripts/_env @@ -44,6 +44,10 @@ export PENPOT_FLAGS="\ enable-redis-cache \ enable-subscriptions"; +# Uncomment for nexus integration testing +# export PENPOT_FLAGS="$PENPOT_FLAGS enable-audit-log-archive"; +# export PENPOT_AUDIT_LOG_ARCHIVE_URI="http://localhost:6070/api/audit"; + # Default deletion delay for devenv export PENPOT_DELETION_DELAY="24h" diff --git a/backend/src/app/rpc/commands/auth.clj b/backend/src/app/rpc/commands/auth.clj index f3466f6d211..c3592d790cd 100644 --- a/backend/src/app/rpc/commands/auth.clj +++ b/backend/src/app/rpc/commands/auth.clj @@ -446,6 +446,7 @@ (when (:create-welcome-file params) (let [cfg (dissoc cfg ::db/conn)] (wrk/submit! executor (create-welcome-file cfg profile)))))] + (cond ;; When profile is blocked, we just ignore it and return plain data (:is-blocked profile) @@ -453,7 +454,8 @@ (l/wrn :hint "register attempt for already blocked profile" :profile-id (str (:id profile)) :profile-email (:email profile)) - (rph/with-meta {:email (:email profile)} + (rph/with-meta {:id (:id profile) + :email (:email profile)} {::audit/replace-props props ::audit/context {:action "ignore-because-blocked"} ::audit/profile-id (:id profile) @@ -469,7 +471,9 @@ (:member-email invitation))) (let [invitation (assoc invitation :member-id (:id profile)) token (tokens/generate cfg invitation)] - (-> {:invitation-token token} + (-> {:id (:id profile) + :email (:email profile) + :invitation-token token} (rph/with-transform (session/create-fn cfg profile claims)) (rph/with-meta {::audit/replace-props props ::audit/context {:action "accept-invitation"} @@ -492,7 +496,8 @@ (when-not (eml/has-reports? conn (:email profile)) (send-email-verification! cfg profile)) - (-> {:email (:email profile)} + (-> {:id (:id profile) + :email (:email profile)} (rph/with-defer create-welcome-file-when-needed) (rph/with-meta {::audit/replace-props props @@ -519,7 +524,8 @@ {:id (:id profile)}) (send-email-verification! cfg profile)) - (rph/with-meta {:email (:email profile)} + (rph/with-meta {:email (:email profile) + :id (:id profile)} {::audit/replace-props (audit/profile->props profile) ::audit/context {:action action} ::audit/profile-id (:id profile) diff --git a/frontend/src/app/config.cljs b/frontend/src/app/config.cljs index f1c1e2b8bf9..75f5010280c 100644 --- a/frontend/src/app/config.cljs +++ b/frontend/src/app/config.cljs @@ -195,6 +195,11 @@ (let [f (obj/get global "externalContextInfo")] (when (fn? f) (f)))) +(defn external-notify-register-success + [profile-id] + (let [f (obj/get global "externalNotifyRegisterSuccess")] + (when (fn? f) (f (str profile-id))))) + (defn initialize-external-context-info [] (let [f (obj/get global "initializeExternalConfigInfo")] diff --git a/frontend/src/app/main/ui/auth/register.cljs b/frontend/src/app/main/ui/auth/register.cljs index 3bd3fdf564a..917b272dd91 100644 --- a/frontend/src/app/main/ui/auth/register.cljs +++ b/frontend/src/app/main/ui/auth/register.cljs @@ -276,6 +276,7 @@ (mf/use-fn (mf/deps on-success-callback) (fn [params] + (cf/external-notify-register-success (:id params)) (if (fn? on-success-callback) (on-success-callback (:email params)) diff --git a/frontend/src/app/main/ui/auth/verify_token.cljs b/frontend/src/app/main/ui/auth/verify_token.cljs index 334303ade4e..16e818e4b27 100644 --- a/frontend/src/app/main/ui/auth/verify_token.cljs +++ b/frontend/src/app/main/ui/auth/verify_token.cljs @@ -6,6 +6,7 @@ (ns app.main.ui.auth.verify-token (:require + [app.config :as cf] [app.main.data.auth :as da] [app.main.data.common :as dcm] [app.main.data.notifications :as ntf] @@ -25,6 +26,7 @@ (defmethod handle-token :verify-email [data] + (cf/external-notify-register-success (:profile-id data)) (let [msg (tr "dashboard.notifications.email-verified-successfully")] (ts/schedule 1000 #(st/emit! (ntf/success msg))) (st/emit! (da/login-from-token data)))) From 390796f36e3982954feaf11694bc92a171cfba74 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 16 Apr 2026 10:20:05 +0200 Subject: [PATCH 053/162] :paperclip: Update changelog --- CHANGES.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 58231d2543b..da62fdd4f40 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,10 +1,12 @@ # CHANGELOG -## 2.14.3 (Unreleased) +## 2.14.3 ### :sparkles: New features & Enhancements - Add webp export format to plugin types [Github #8870](https://github.com/penpot/penpot/pull/8870) +- Add minor adjustments to the auth events [Github #9027](https://github.com/penpot/penpot/pull/9027) +- Use shared singleton containers for React portals to reduce DOM growth [Github #8957](https://github.com/penpot/penpot/pull/8957) ### :bug: Bugs fixed @@ -17,6 +19,16 @@ - Fix path drawing preview passing shape instead of content to next-node - Fix swapped arguments in CLJS PathData `-nth` with default - Normalize PathData coordinates to safe integer bounds on read +- Fix RangeError from re-entrant error handling causing stack overflow [Github #8962](https://github.com/penpot/penpot/pull/8962) +- Fix builder bool styles and media validation [Github #8963](https://github.com/penpot/penpot/pull/8963) +- Fix "Move to" menu allowing same project as target when multiple files are selected +- Fix crash when index query param is duplicated in URL +- Fix wrong extremity point in path `calculate-extremities` for line-to segments +- Fix reversed args in DTCG shadow composite token conversion +- Fix `inside-layout?` passing shape id instead of shape to `frame-shape?` +- Fix wrong `mapcat` call in `collect-main-shapes` +- Fix stale accumulator in `get-children-in-instance` recursion +- Fix typo `:podition` in swap-shapes grid cell ## 2.14.2 From 69e505a6a2e599dd8e690f71c02b444966a9fa46 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 16 Apr 2026 10:21:15 +0200 Subject: [PATCH 054/162] :paperclip: Update changelog --- CHANGES.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index da62fdd4f40..0d431c0d2be 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -5,7 +5,6 @@ ### :sparkles: New features & Enhancements - Add webp export format to plugin types [Github #8870](https://github.com/penpot/penpot/pull/8870) -- Add minor adjustments to the auth events [Github #9027](https://github.com/penpot/penpot/pull/9027) - Use shared singleton containers for React portals to reduce DOM growth [Github #8957](https://github.com/penpot/penpot/pull/8957) ### :bug: Bugs fixed From b38912f3cb1afa1fbd9e97924c9367c5c22350c7 Mon Sep 17 00:00:00 2001 From: Yamila Moreno Date: Thu, 16 Apr 2026 18:20:44 +0200 Subject: [PATCH 055/162] :wrench: Add short tag to DocherHub release (#8864) --- .github/workflows/release.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 21c0eb6de2b..053dd3ff0ef 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -64,13 +64,14 @@ jobs: echo "$PUB_DOCKER_PASSWORD" | skopeo login --username "$PUB_DOCKER_USERNAME" --password-stdin docker.io IMAGES=("frontend" "backend" "exporter" "storybook") + SHORT_TAG=${TAG%.*} for image in "${IMAGES[@]}"; do skopeo copy --all \ docker://$DOCKER_REGISTRY/$image:$TAG \ docker://docker.io/penpotapp/$image:$TAG - for alias in main latest; do + for alias in main latest "$SHORT_TAG"; do skopeo copy --all \ docker://$DOCKER_REGISTRY/$image:$TAG \ docker://docker.io/penpotapp/$image:$alias From 3a39676969a7c8222e239554db69fc06ade9e6c9 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 16 Apr 2026 11:30:36 +0200 Subject: [PATCH 056/162] :rewind: Backport MCP from staging (part 1) --- CHANGES.md | 2 + backend/src/app/migrations.clj | 4 +- .../sql/0146-mod-access-token-table.sql | 2 + backend/src/app/rpc/commands/access_token.clj | 29 +- backend/src/app/rpc/commands/profile.clj | 1 + .../backend_tests/http_middleware_test.clj | 2 +- .../backend_tests/rpc_access_tokens_test.clj | 16 +- common/src/app/common/flags.cljc | 4 +- common/src/app/common/i18n.cljc | 7 + common/src/app/common/schema/messages.cljc | 105 +++ common/src/app/common/types/tokens_lib.cljc | 100 +-- docker/devenv/files/Caddyfile | 2 +- docker/devenv/files/bashrc | 3 + docker/devenv/files/nginx.conf | 6 + docker/images/Dockerfile.mcp | 3 +- frontend/package.json | 2 +- frontend/pnpm-lock.yaml | 4 +- frontend/scripts/build | 2 +- frontend/src/app/config.cljs | 7 + frontend/src/app/main/broadcast.cljs | 5 +- frontend/src/app/main/data/plugins.cljs | 65 +- frontend/src/app/main/data/profile.cljs | 1 - frontend/src/app/main/data/workspace.cljs | 328 +++---- frontend/src/app/main/data/workspace/mcp.cljs | 292 +++++++ .../main/data/workspace/notifications.cljs | 1 + .../data/workspace/tokens/application.cljs | 186 ++-- .../src/app/main/data/workspace/variants.cljs | 4 +- frontend/src/app/main/errors.cljs | 96 +- frontend/src/app/main/refs.cljs | 3 + frontend/src/app/main/ui.cljs | 2 +- frontend/src/app/main/ui/confirm.cljs | 39 +- frontend/src/app/main/ui/confirm.scss | 32 +- frontend/src/app/main/ui/ds/_sizes.scss | 1 + .../src/app/main/ui/ds/controls/input.cljs | 11 +- frontend/src/app/main/ui/forms.cljs | 20 +- frontend/src/app/main/ui/routes.cljs | 2 +- frontend/src/app/main/ui/settings.cljs | 6 +- .../app/main/ui/settings/access_tokens.cljs | 291 ------- .../app/main/ui/settings/access_tokens.scss | 202 ----- .../app/main/ui/settings/integrations.cljs | 635 ++++++++++++++ .../app/main/ui/settings/integrations.scss | 239 +++++ .../src/app/main/ui/settings/sidebar.cljs | 17 +- .../app/main/ui/workspace/left_header.cljs | 18 +- .../src/app/main/ui/workspace/main_menu.cljs | 590 +++++++------ .../src/app/main/ui/workspace/main_menu.scss | 229 +++-- .../src/app/main/ui/workspace/sidebar.cljs | 12 +- .../tokens/management/context_menu.cljs | 2 +- frontend/src/app/plugins.cljs | 3 + frontend/src/app/plugins/api.cljs | 100 ++- frontend/src/app/plugins/comments.cljs | 26 +- frontend/src/app/plugins/file.cljs | 28 +- frontend/src/app/plugins/flags.cljs | 26 +- frontend/src/app/plugins/flex.cljs | 129 ++- frontend/src/app/plugins/fonts.cljs | 28 +- frontend/src/app/plugins/format.cljs | 7 + frontend/src/app/plugins/grid.cljs | 162 ++-- frontend/src/app/plugins/history.cljs | 6 +- frontend/src/app/plugins/library.cljs | 216 ++--- frontend/src/app/plugins/local_storage.cljs | 12 +- frontend/src/app/plugins/page.cljs | 70 +- frontend/src/app/plugins/public_utils.cljs | 4 +- frontend/src/app/plugins/register.cljs | 6 + frontend/src/app/plugins/ruler_guides.cljs | 10 +- frontend/src/app/plugins/shape.cljs | 324 +++---- frontend/src/app/plugins/text.cljs | 122 +-- frontend/src/app/plugins/tokens.cljs | 90 +- frontend/src/app/plugins/utils.cljs | 71 +- frontend/src/app/plugins/viewport.cljs | 8 +- frontend/src/app/util/forms.cljs | 96 +- frontend/src/debug.cljs | 6 + frontend/translations/en.po | 383 +++++--- frontend/translations/es.po | 374 +++++--- mcp/.gitignore | 2 + mcp/.serena/memories/project_overview.md | 67 +- mcp/.serena/project.yml | 48 +- mcp/README.md | 129 ++- mcp/bin/mcp-local.js | 31 + mcp/package.json | 16 +- mcp/packages/common/package.json | 2 +- mcp/packages/plugin/index.html | 81 +- mcp/packages/plugin/package.json | 3 +- mcp/packages/plugin/public/icon.jpg | Bin 0 -> 7632 bytes mcp/packages/plugin/public/manifest.json | 1 + mcp/packages/plugin/src/PenpotUtils.ts | 70 +- mcp/packages/plugin/src/index.d.ts | 21 + mcp/packages/plugin/src/main.ts | 159 +++- mcp/packages/plugin/src/plugin.ts | 69 +- mcp/packages/plugin/src/style.css | 186 +++- .../task-handlers/ExecuteCodeTaskHandler.ts | 42 +- mcp/packages/plugin/src/vite-env.d.ts | 1 + mcp/packages/plugin/vite.config.ts | 15 +- mcp/packages/plugin/vite.release.config.ts | 1 - mcp/packages/server/.gitignore | 1 + mcp/packages/server/data/api_types.yml | 818 ++++++++++-------- mcp/packages/server/data/base_instructions.md | 2 + .../server/data/initial_instructions.md | 229 +++-- mcp/packages/server/package.json | 6 +- mcp/packages/server/scripts/copy-resources.js | 5 + .../server/src/ConfigurationLoader.ts | 23 +- mcp/packages/server/src/PenpotMcpServer.ts | 219 +++-- mcp/packages/server/src/PluginBridge.ts | 15 + mcp/packages/server/src/Tool.ts | 13 +- .../server/src/tools/ExecuteCodeTool.ts | 5 +- .../server/src/tools/ExportShapeTool.ts | 8 +- .../server/src/tools/HighLevelOverviewTool.ts | 2 +- mcp/pnpm-lock.yaml | 3 + mcp/scripts/build | 4 +- mcp/scripts/pack | 11 + mcp/scripts/set-version | 51 ++ plugins/CHANGELOG.md | 6 + plugins/angular.json | 23 +- .../apps/colors-to-tokens-plugin/package.json | 4 +- .../src/app/app.config.ts | 4 +- .../colors-to-tokens-plugin/src/index.html | 1 - .../colors-to-tokens-plugin/src/manifest.json | 8 + .../colors-to-tokens-plugin/wrangler.toml | 2 +- plugins/apps/contrast-plugin/package.json | 4 +- .../contrast-plugin/src/app/app.config.ts | 4 +- plugins/apps/contrast-plugin/src/index.html | 1 - .../apps/contrast-plugin/src/manifest.json | 8 + plugins/apps/contrast-plugin/wrangler.toml | 2 +- .../apps/create-palette-plugin/package.json | 8 +- .../public/manifest.json | 8 + .../apps/create-palette-plugin/vite.config.ts | 5 +- plugins/apps/example-styles/index.html | 2 - plugins/apps/example-styles/package.json | 5 +- plugins/apps/example-styles/vite.config.ts | 4 +- plugins/apps/icons-plugin/src/index.html | 1 - plugins/apps/icons-plugin/src/manifest.json | 8 + plugins/apps/lorem-ipsum-plugin/package.json | 4 +- .../apps/lorem-ipsum-plugin/src/index.html | 1 - .../apps/lorem-ipsum-plugin/src/manifest.json | 8 + plugins/apps/poc-state-plugin/package.json | 4 +- .../poc-state-plugin/src/app/app.component.ts | 2 +- plugins/apps/poc-state-plugin/src/index.html | 1 - .../apps/poc-state-plugin/src/manifest.json | 14 + plugins/apps/poc-tokens-plugin/package.json | 4 +- .../src/app/app.component.ts | 2 +- plugins/apps/poc-tokens-plugin/src/index.html | 1 - .../apps/poc-tokens-plugin/src/manifest.json | 15 + .../apps/rename-layers-plugin/package.json | 4 +- .../apps/rename-layers-plugin/src/index.html | 1 - .../rename-layers-plugin/src/manifest.json | 8 + plugins/apps/table-plugin/package.json | 4 +- .../apps/table-plugin/src/app/app.config.ts | 4 +- plugins/apps/table-plugin/src/index.html | 1 - plugins/apps/table-plugin/src/manifest.json | 8 + plugins/apps/table-plugin/vite.config.ts | 6 + plugins/apps/table-plugin/wrangler.toml | 2 +- plugins/libs/plugin-types/index.d.ts | 56 +- plugins/libs/plugins-runtime/src/index.ts | 3 + .../libs/plugins-runtime/src/lib/api/index.ts | 9 + .../plugins-runtime/src/lib/create-modal.ts | 4 + .../src/lib/create-plugin.spec.ts | 9 +- .../plugins-runtime/src/lib/create-plugin.ts | 11 +- .../plugins-runtime/src/lib/create-sandbox.ts | 87 +- .../src/lib/load-plugin.spec.ts | 16 +- .../plugins-runtime/src/lib/load-plugin.ts | 15 +- .../src/lib/models/manifest.schema.ts | 1 + .../src/lib/models/open-ui-options.schema.ts | 1 + .../plugins-runtime/src/lib/parse-manifest.ts | 28 +- .../src/lib/plugin-manager.spec.ts | 12 +- .../plugins-runtime/src/lib/plugin-manager.ts | 14 +- plugins/libs/plugins-runtime/vite.config.ts | 2 +- plugins/package.json | 4 +- 165 files changed, 5733 insertions(+), 3037 deletions(-) create mode 100644 backend/src/app/migrations/sql/0146-mod-access-token-table.sql create mode 100644 common/src/app/common/schema/messages.cljc create mode 100644 frontend/src/app/main/data/workspace/mcp.cljs delete mode 100644 frontend/src/app/main/ui/settings/access_tokens.cljs delete mode 100644 frontend/src/app/main/ui/settings/access_tokens.scss create mode 100644 frontend/src/app/main/ui/settings/integrations.cljs create mode 100644 frontend/src/app/main/ui/settings/integrations.scss create mode 100644 mcp/bin/mcp-local.js create mode 100644 mcp/packages/plugin/public/icon.jpg create mode 100644 mcp/packages/plugin/src/index.d.ts create mode 100644 mcp/packages/server/data/base_instructions.md create mode 100644 mcp/packages/server/scripts/copy-resources.js create mode 100644 mcp/scripts/pack create mode 100644 mcp/scripts/set-version create mode 100644 plugins/apps/colors-to-tokens-plugin/src/manifest.json create mode 100644 plugins/apps/contrast-plugin/src/manifest.json create mode 100644 plugins/apps/create-palette-plugin/public/manifest.json create mode 100644 plugins/apps/icons-plugin/src/manifest.json create mode 100644 plugins/apps/lorem-ipsum-plugin/src/manifest.json create mode 100644 plugins/apps/poc-state-plugin/src/manifest.json create mode 100644 plugins/apps/poc-tokens-plugin/src/manifest.json create mode 100644 plugins/apps/rename-layers-plugin/src/manifest.json create mode 100644 plugins/apps/table-plugin/src/manifest.json create mode 100644 plugins/apps/table-plugin/vite.config.ts diff --git a/CHANGES.md b/CHANGES.md index 0d431c0d2be..3dcc585934d 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -90,6 +90,8 @@ - Optimize sidebar performance for deeply nested shapes [Taiga #13017](https://tree.taiga.io/project/penpot/task/13017) - Remove tokens path node and bulk remove tokens [Taiga #13007](https://tree.taiga.io/project/penpot/us/13007) - Replace themes management modal radio buttons for switches [Taiga #9215](https://tree.taiga.io/project/penpot/us/9215) +- [MCP server] Integrations section [Taiga #13112](https://tree.taiga.io/project/penpot/us/13112) +- [Access Tokens] Look & feel refinement [Taiga #13114](https://tree.taiga.io/project/penpot/us/13114) ### :bug: Bugs fixed diff --git a/backend/src/app/migrations.clj b/backend/src/app/migrations.clj index 2a9d9eba0b9..4c9199a6f56 100644 --- a/backend/src/app/migrations.clj +++ b/backend/src/app/migrations.clj @@ -463,8 +463,10 @@ :fn (mg/resource "app/migrations/sql/0144-mod-server-error-report-table.sql")} {:name "0145-fix-plugins-uri-on-profile" - :fn mg0145/migrate}]) + :fn mg0145/migrate} + {:name "0146-mod-access-token-table" + :fn (mg/resource "app/migrations/sql/0146-mod-access-token-table.sql")}]) (defn apply-migrations! [pool name migrations] diff --git a/backend/src/app/migrations/sql/0146-mod-access-token-table.sql b/backend/src/app/migrations/sql/0146-mod-access-token-table.sql new file mode 100644 index 00000000000..574257859d7 --- /dev/null +++ b/backend/src/app/migrations/sql/0146-mod-access-token-table.sql @@ -0,0 +1,2 @@ +ALTER TABLE access_token + ADD COLUMN type text NULL; diff --git a/backend/src/app/rpc/commands/access_token.clj b/backend/src/app/rpc/commands/access_token.clj index a302b820539..eedb119d06a 100644 --- a/backend/src/app/rpc/commands/access_token.clj +++ b/backend/src/app/rpc/commands/access_token.clj @@ -23,7 +23,7 @@ (dissoc row :perms)) (defn create-access-token - [{:keys [::db/conn] :as cfg} profile-id name expiration] + [{:keys [::db/conn] :as cfg} profile-id name expiration type] (let [token-id (uuid/next) expires-at (some-> expiration (ct/in-future)) created-at (ct/now) @@ -36,6 +36,7 @@ {:id token-id :name name :token token + :type type :profile-id profile-id :created-at created-at :updated-at created-at @@ -50,17 +51,18 @@ (def ^:private schema:create-access-token [:map {:title "create-access-token"} [:name [:string {:max 250 :min 1}]] - [:expiration {:optional true} ::ct/duration]]) + [:expiration {:optional true} ::ct/duration] + [:type {:optional true} :string]]) (sv/defmethod ::create-access-token {::doc/added "1.18" ::sm/params schema:create-access-token} - [cfg {:keys [::rpc/profile-id name expiration]}] + [cfg {:keys [::rpc/profile-id name expiration type]}] (quotes/check! cfg {::quotes/id ::quotes/access-tokens-per-profile ::quotes/profile-id profile-id}) - (db/tx-run! cfg create-access-token profile-id name expiration)) + (db/tx-run! cfg create-access-token profile-id name expiration type)) (def ^:private schema:delete-access-token [:map {:title "delete-access-token"} @@ -83,5 +85,22 @@ (->> (db/query pool :access-token {:profile-id profile-id} {:order-by [[:expires-at :asc] [:created-at :asc]] - :columns [:id :name :perms :created-at :updated-at :expires-at]}) + :columns [:id :name :perms :type :created-at :updated-at :expires-at]}) (mapv decode-row))) + +(def ^:private schema:get-current-mcp-token + [:map {:title "get-current-mcp-token"}]) + +(sv/defmethod ::get-current-mcp-token + {::doc/added "2.15" + ::sm/params schema:get-current-mcp-token} + [{:keys [::db/pool]} {:keys [::rpc/profile-id ::rpc/request-at]}] + (->> (db/query pool :access-token + {:profile-id profile-id + :type "mcp"} + {:order-by [[:expires-at :asc] [:created-at :asc]] + :columns [:token :expires-at]}) + (remove #(and (some? (:expires-at %)) + (ct/is-after? request-at (:expires-at %)))) + (map decode-row) + (first))) diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index 3d2f2b13514..efe99c4a709 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -48,6 +48,7 @@ (def schema:props [:map {:title "ProfileProps"} [:plugins {:optional true} schema:plugin-registry] + [:mcp-enabled {:optional true} ::sm/boolean] [:newsletter-updates {:optional true} ::sm/boolean] [:newsletter-news {:optional true} ::sm/boolean] [:onboarding-team-id {:optional true} ::sm/uuid] diff --git a/backend/test/backend_tests/http_middleware_test.clj b/backend/test/backend_tests/http_middleware_test.clj index b4fa5062d57..809e43f9e3f 100644 --- a/backend/test/backend_tests/http_middleware_test.clj +++ b/backend/test/backend_tests/http_middleware_test.clj @@ -102,7 +102,7 @@ (t/deftest access-token-authz (let [profile (th/create-profile* 1) - token (db/tx-run! th/*system* app.rpc.commands.access-token/create-access-token (:id profile) "test" nil) + token (db/tx-run! th/*system* app.rpc.commands.access-token/create-access-token (:id profile) "test" nil nil) handler (#'app.http.access-token/wrap-authz identity th/*system*)] (let [response (handler nil)] diff --git a/backend/test/backend_tests/rpc_access_tokens_test.clj b/backend/test/backend_tests/rpc_access_tokens_test.clj index fe0269d6095..6dc96ac0f63 100644 --- a/backend/test/backend_tests/rpc_access_tokens_test.clj +++ b/backend/test/backend_tests/rpc_access_tokens_test.clj @@ -107,4 +107,18 @@ ;; (th/print-result! out) (t/is (nil? (:error out))) (let [results (:result out)] - (t/is (= 2 (count results)))))))) + (t/is (= 2 (count results)))))) + + (t/testing "get mcp token" + (let [_ (th/command! {::th/type :create-access-token + ::rpc/profile-id (:id prof) + :type "mcp" + :name "token 1" + :perms ["get-profile"]}) + {:keys [error result]} + (th/command! {::th/type :get-current-mcp-token + ::rpc/profile-id (:id prof)})] + ;; (th/print-result! result) + (t/is (nil? error)) + (t/is (string? (:token result))))))) + diff --git a/common/src/app/common/flags.cljc b/common/src/app/common/flags.cljc index 816bc2edbb6..64cb7f9d68b 100644 --- a/common/src/app/common/flags.cljc +++ b/common/src/app/common/flags.cljc @@ -152,7 +152,9 @@ :redis-cache ;; Activates the nitrate module - :nitrate}) + :nitrate + + :mcp}) (def all-flags (set/union email login varia)) diff --git a/common/src/app/common/i18n.cljc b/common/src/app/common/i18n.cljc index bdd80b97416..f363329f2d1 100644 --- a/common/src/app/common/i18n.cljc +++ b/common/src/app/common/i18n.cljc @@ -13,3 +13,10 @@ unit tests or backend code for logs or error messages." [key & _args] key) + +(defn c + "This function will be monkeypatched at runtime with the real function in frontend i18n. + Here it just returns the key passed as argument. This way the result can be used in + unit tests or backend code for logs or error messages." + [x] + x) diff --git a/common/src/app/common/schema/messages.cljc b/common/src/app/common/schema/messages.cljc new file mode 100644 index 00000000000..93903c1b9cd --- /dev/null +++ b/common/src/app/common/schema/messages.cljc @@ -0,0 +1,105 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.common.schema.messages + (:require + [app.common.data :as d] + [app.common.i18n :as i18n :refer [tr]] + [app.common.schema :as sm] + [malli.core :as m])) + +;; --- Handlers Helpers + +(defn- translate-code + [code] + (if (vector? code) + (tr (nth code 0) (i18n/c (nth code 1))) + (tr code))) + +(defn- handle-error-fn + [props problem] + (let [v-fn (:error/fn props) + result (v-fn problem)] + (if (string? result) + {:message result} + {:message (or (some-> (get result :code) + (translate-code)) + (get result :message) + (tr "errors.invalid-data"))}))) + +(defn- handle-error-message + [props] + {:message (get props :error/message)}) + +(defn- handle-error-code + [props] + (let [code (get props :error/code)] + {:message (translate-code code)})) + +(defn interpret-schema-problem + [acc {:keys [schema in value type] :as problem}] + (let [props (m/properties schema) + tprops (m/type-properties schema) + field (or (:error/field props) + in) + field (if (vector? field) + field + [field])] + + (if (and (= 1 (count field)) + (contains? acc (first field))) + acc + (cond + (or (nil? field) + (empty? field)) + acc + + (or (= type :malli.core/missing-key) + (nil? value)) + (assoc-in acc field {:message (tr "errors.field-missing")}) + + ;; --- CHECK on schema props + (contains? props :error/fn) + (assoc-in acc field (handle-error-fn props problem)) + + (contains? props :error/message) + (assoc-in acc field (handle-error-message props)) + + (contains? props :error/code) + (assoc-in acc field (handle-error-code props)) + + ;; --- CHECK on type props + (contains? tprops :error/fn) + (assoc-in acc field (handle-error-fn tprops problem)) + + (contains? tprops :error/message) + (assoc-in acc field (handle-error-message tprops)) + + (contains? tprops :error/code) + (assoc-in acc field (handle-error-code tprops)) + + :else + (assoc-in acc field {:message (tr "errors.invalid-data")}))))) + + + +(defn- apply-validators + [validators state errors] + (reduce (fn [errors validator-fn] + (merge errors (validator-fn errors (:data state)))) + errors + validators)) + +(defn collect-schema-errors + [schema validators state] + (let [explain (sm/explain schema (:data state)) + errors (->> (reduce interpret-schema-problem {} (:errors explain)) + (apply-validators validators state))] + + (-> (:errors state) + (merge errors) + (d/without-nils) + (not-empty)))) diff --git a/common/src/app/common/types/tokens_lib.cljc b/common/src/app/common/types/tokens_lib.cljc index 8ab9c6bcd03..050de69c027 100644 --- a/common/src/app/common/types/tokens_lib.cljc +++ b/common/src/app/common/types/tokens_lib.cljc @@ -242,17 +242,19 @@ (update-token- [this token-id f] (assert (uuid? token-id) "expected uuid for `token-id`") (if-let [token (get-token- this token-id)] - (let [token' (-> (make-token (f token)) - (assoc :modified-at (ct/now)))] - (TokenSet. id - name - description - (ct/now) - (if (= (:name token) (:name token')) - (assoc tokens (:name token') token') - (-> tokens - (d/oassoc-before (:name token) (:name token') token') - (dissoc (:name token)))))) + (let [token' (f token)] + (if (not= token token') + (let [token' (assoc token' :modified-at (ct/now))] + (TokenSet. id + name + description + (ct/now) + (if (= (:name token) (:name token')) + (assoc tokens (:name token') token') + (-> tokens + (d/oassoc-before (:name token) (:name token') token') + (dissoc (:name token)))))) + this)) this)) (delete-token- [this token-id] @@ -303,6 +305,35 @@ (-clj->js [this] (clj->js (datafy this))))) +(def ^:private set-prefix "S-") + +(def ^:private set-group-prefix "G-") + +(def ^:private set-separator "/") + +(defn get-set-path + [token-set] + (cpn/split-path (get-name token-set) :separator set-separator)) + +(defn split-set-name + [name] + (cpn/split-path name :separator set-separator)) + +(defn join-set-path [path] + (cpn/join-path path :separator set-separator :with-spaces? false)) + +(defn normalize-set-name + "Normalize a set name (ensure that there are no extra spaces, like ' group / set' -> 'group/set'). + + If `relative-to` is provided, the normalized name will preserve the same group prefix as reference name." + ([name] + (-> (split-set-name (str name)) + (cpn/join-path :separator set-separator :with-spaces? false))) + ([name relative-to] + (-> (concat (butlast (split-set-name relative-to)) + (split-set-name (str name))) + (cpn/join-path :separator set-separator :with-spaces? false)))) + (defn token-set? [o] (instance? TokenSet o)) @@ -357,6 +388,7 @@ (def check-token-set (sm/check-fn schema:token-set :hint "expected valid token set")) + (defn map->token-set [& {:as attrs}] (TokenSet. (:id attrs) @@ -372,38 +404,10 @@ (update :modified-at #(or % (ct/now))) (update :tokens #(into (d/ordered-map) %)) (update :description d/nilv "") + (update :name normalize-set-name) (check-token-set-attrs) (map->token-set))) -(def ^:private set-prefix "S-") - -(def ^:private set-group-prefix "G-") - -(def ^:private set-separator "/") - -(defn get-set-path - [token-set] - (cpn/split-path (get-name token-set) :separator set-separator)) - -(defn split-set-name - [name] - (cpn/split-path name :separator set-separator)) - -(defn join-set-path [path] - (cpn/join-path path :separator set-separator :with-spaces? false)) - -(defn normalize-set-name - "Normalize a set name (ensure that there are no extra spaces, like ' group / set' -> 'group/set'). - - If `relative-to` is provided, the normalized name will preserve the same group prefix as reference name." - ([name] - (-> (split-set-name name) - (cpn/join-path :separator set-separator :with-spaces? false))) - ([name relative-to] - (-> (concat (butlast (split-set-name relative-to)) - (split-set-name name)) - (cpn/join-path :separator set-separator :with-spaces? false)))) - (defn normalized-set-name? "Check if a set name is normalized (no extra spaces)." [name] @@ -609,7 +613,7 @@ is-source external-id (ct/now) - set-names)) + (into #{} (filter some?) set-names))) (enable-set [this set-name] (set-sets this (conj sets set-name))) @@ -630,14 +634,9 @@ (update-set-name [this prev-set-name set-name] (if (get sets prev-set-name) - (TokenTheme. id - name - group - description - is-source - external-id - (ct/now) - (conj (disj sets prev-set-name) set-name)) + (let [sets (-> (disj sets prev-set-name) + (conj set-name))] + (set-sets this sets)) this)) (theme-matches-group-name [this group name] @@ -722,7 +721,8 @@ (update :is-source d/nilv false) (update :external-id #(or % (str new-id))) (update :modified-at #(or % (ct/now))) - (update :sets set) + (update :sets #(into #{} (comp (filter some?) + (map normalize-set-name)) %)) (check-token-theme-attrs) (map->TokenTheme)))) diff --git a/docker/devenv/files/Caddyfile b/docker/devenv/files/Caddyfile index eda140d5e95..a4e81434b02 100644 --- a/docker/devenv/files/Caddyfile +++ b/docker/devenv/files/Caddyfile @@ -8,7 +8,7 @@ localhost:3449 { header -Strict-Transport-Security } -http://localhost:3450 { +:3450 { # For subpath test # handle_path /penpot/* { # reverse_proxy localhost:4449 diff --git a/docker/devenv/files/bashrc b/docker/devenv/files/bashrc index 98fc4a96dc1..79ef2bd532a 100644 --- a/docker/devenv/files/bashrc +++ b/docker/devenv/files/bashrc @@ -5,6 +5,9 @@ EMSDK_QUIET=1 . /opt/emsdk/emsdk_env.sh; export PATH="/home/penpot/.cargo/bin:/opt/jdk/bin:/opt/utils/bin:/opt/clojure/bin:/opt/node/bin:/opt/imagick/bin:/opt/cargo/bin:$PATH" export CARGO_HOME="/home/penpot/.cargo" +export PENPOT_MCP_PLUGIN_SERVER_HOST=0.0.0.0 +export PENPOT_MCP_SERVER_HOST=0.0.0.0 + alias l='ls --color -GFlh' alias ll='ls --color -GFlh' alias rm='rm -rf' diff --git a/docker/devenv/files/nginx.conf b/docker/devenv/files/nginx.conf index ca770e04c70..3a6f50b4bed 100644 --- a/docker/devenv/files/nginx.conf +++ b/docker/devenv/files/nginx.conf @@ -126,6 +126,12 @@ http { proxy_http_version 1.1; } + location /plugins { + autoindex on; + alias /home/penpot/penpot/plugins/dist/apps; + proxy_http_version 1.1; + } + location /mcp/ws { proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; diff --git a/docker/images/Dockerfile.mcp b/docker/images/Dockerfile.mcp index f4d5544c89c..14b1172035d 100644 --- a/docker/images/Dockerfile.mcp +++ b/docker/images/Dockerfile.mcp @@ -5,7 +5,8 @@ ENV LANG=en_US.UTF-8 \ LC_ALL=en_US.UTF-8 \ NODE_VERSION=v22.21.1 \ DEBIAN_FRONTEND=noninteractive \ - PATH=/opt/node/bin:$PATH + PATH=/opt/node/bin:$PATH \ + PENPOT_MCP_SERVER_HOST=0.0.0.0 RUN set -ex; \ useradd -U -M -u 1001 -s /bin/false -d /opt/penpot penpot; \ diff --git a/frontend/package.json b/frontend/package.json index ed3c29f34a9..2e06b799056 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -50,7 +50,7 @@ "devDependencies": { "@penpot/draft-js": "workspace:./packages/draft-js", "@penpot/mousetrap": "workspace:./packages/mousetrap", - "@penpot/plugins-runtime": "link:../plugins/dist/plugins-runtime", + "@penpot/plugins-runtime": "link:../plugins/libs/plugins-runtime", "@penpot/svgo": "penpot/svgo#v3.2", "@penpot/text-editor": "workspace:./text-editor", "@penpot/tokenscript": "workspace:./packages/tokenscript", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 539b36dbe43..e98f3624102 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -20,8 +20,8 @@ importers: specifier: workspace:./packages/mousetrap version: link:packages/mousetrap '@penpot/plugins-runtime': - specifier: link:../plugins/dist/plugins-runtime - version: link:../plugins/dist/plugins-runtime + specifier: link:../plugins/libs/plugins-runtime + version: link:../plugins/libs/plugins-runtime '@penpot/svgo': specifier: penpot/svgo#v3.2 version: svgo@https://codeload.github.com/penpot/svgo/tar.gz/8c9b0e32e9cb5f106085260bd9375f3c91a5010b diff --git a/frontend/scripts/build b/frontend/scripts/build index 262a90ff45f..eb8e42ea1bd 100755 --- a/frontend/scripts/build +++ b/frontend/scripts/build @@ -36,7 +36,7 @@ popd pushd ../mcp; rm -rf node_modules; ./scripts/setup -WS_URI="/mcp/ws" pnpm run --filter "mcp-plugin" build:multi-user +WS_URI="/mcp/ws" pnpm run --filter "mcp-plugin" build popd; pnpm run build:app:main $EXTRA_PARAMS; diff --git a/frontend/src/app/config.cljs b/frontend/src/app/config.cljs index 75f5010280c..19339a8eca3 100644 --- a/frontend/src/app/config.cljs +++ b/frontend/src/app/config.cljs @@ -172,6 +172,10 @@ (normalize-uri (or (obj/get global "penpotPublicURI") (obj/get location "origin")))) +(def mcp-ws-uri + (or (some-> (obj/get global "penpotMcpServerURI") u/uri) + (u/join public-uri "mcp/ws"))) + (def rasterizer-uri (or (some-> (obj/get global "penpotRasterizerURI") normalize-uri) public-uri)) @@ -205,6 +209,9 @@ (let [f (obj/get global "initializeExternalConfigInfo")] (when (fn? f) (f)))) +(def mcp-server-url (-> public-uri u/ensure-path-slash (u/join "mcp/stream") str)) +(def mcp-help-center-uri "https://help.penpot.app/mcp/") + ;; --- Helper Functions (defn ^boolean check-browser? [candidate] diff --git a/frontend/src/app/main/broadcast.cljs b/frontend/src/app/main/broadcast.cljs index 33e12f12a6d..0a4ccf10708 100644 --- a/frontend/src/app/main/broadcast.cljs +++ b/frontend/src/app/main/broadcast.cljs @@ -57,5 +57,6 @@ [type data] (ptk/reify ::event ptk/EffectEvent - (effect [_ _ _] - (emit! type data)))) + (effect [_ state _] + (let [session-id (get state :session-id)] + (emit! session-id type data))))) diff --git a/frontend/src/app/main/data/plugins.cljs b/frontend/src/app/main/data/plugins.cljs index e9f5266c1b6..b091518b674 100644 --- a/frontend/src/app/main/data/plugins.cljs +++ b/frontend/src/app/main/data/plugins.cljs @@ -7,12 +7,14 @@ (ns app.main.data.plugins (:require [app.common.data.macros :as dm] + [app.common.exceptions :as ex] [app.common.files.changes-builder :as pcb] [app.common.time :as ct] [app.main.data.changes :as dch] [app.main.data.event :as ev] [app.main.data.modal :as modal] [app.main.data.notifications :as ntf] + [app.main.errors :as errors] [app.main.store :as st] [app.plugins.flags :as pflag] [app.plugins.register :as preg] @@ -20,7 +22,8 @@ [app.util.http :as http] [app.util.i18n :as i18n :refer [tr]] [beicon.v2.core :as rx] - [potok.v2.core :as ptk])) + [potok.v2.core :as ptk] + [promesa.core :as p])) (defn save-plugin-permissions-peek [id permissions] @@ -52,27 +55,47 @@ (update [_ state] (update-in state [:workspace-local :open-plugins] (fnil disj #{}) id)))) -(defn- load-plugin! - [{:keys [plugin-id name description host code icon permissions]}] - (try - (st/emit! (pflag/clear plugin-id) - (save-current-plugin plugin-id)) - - (.ɵloadPlugin - ^js ug/global - #js {:pluginId plugin-id - :name name - :description description - :host host - :code code - :icon icon - :permissions (apply array permissions)} - (fn [] - (st/emit! (remove-current-plugin plugin-id)))) +(defn start-plugin! + [{:keys [plugin-id name version description host code permissions allow-background]} ^js extensions] + (-> (.ɵloadPlugin + ^js ug/global + #js {:pluginId plugin-id + :name name + :version version + :description description + :host host + :code code + :allowBackground (boolean allow-background) + :permissions (apply array permissions)} + nil + extensions) + + (p/catch (fn [cause] + (ex/print-throwable cause :prefix "Plugin Error") + (errors/flash :cause cause :type :handled))))) - (catch :default e - (st/emit! (remove-current-plugin plugin-id)) - (.error js/console "Error" e)))) +(defn- load-plugin! + [{:keys [plugin-id name version description host code icon permissions]}] + (st/emit! (pflag/clear plugin-id) + (save-current-plugin plugin-id)) + + (-> (.ɵloadPlugin + ^js ug/global + #js {:pluginId plugin-id + :name name + :description description + :version version + :host host + :code code + :icon icon + :permissions (apply array permissions)} + (fn [] + (st/emit! (remove-current-plugin plugin-id)))) + + (p/catch (fn [cause] + (st/emit! (remove-current-plugin plugin-id)) + (ex/print-throwable cause :prefix "Plugin Error") + (errors/flash :cause cause :type :handled))))) (defn open-plugin! [{:keys [url] :as manifest} user-can-edit?] diff --git a/frontend/src/app/main/data/profile.cljs b/frontend/src/app/main/data/profile.cljs index e7828a03029..66ded6fc8b5 100644 --- a/frontend/src/app/main/data/profile.cljs +++ b/frontend/src/app/main/data/profile.cljs @@ -498,4 +498,3 @@ (->> (rp/cmd! :delete-access-token params) (rx/tap on-success) (rx/catch on-error)))))) - diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index 9116c99024f..540d9d38812 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -52,6 +52,7 @@ [app.main.data.workspace.layers :as dwly] [app.main.data.workspace.layout :as layout] [app.main.data.workspace.libraries :as dwl] + [app.main.data.workspace.mcp :as mcp] [app.main.data.workspace.notifications :as dwn] [app.main.data.workspace.pages :as dwpg] [app.main.data.workspace.path :as dwdp] @@ -211,8 +212,11 @@ ptk/WatchEvent (watch [_ _ _] - (rx/of (dp/check-open-plugin) - (fdf/fix-deleted-fonts-for-local-library file-id))))) + (rx/merge + (rx/of (dp/check-open-plugin) + (fdf/fix-deleted-fonts-for-local-library file-id)) + (when (contains? cf/flags :mcp) + (rx/of (mcp/init))))))) (defn- bundle-fetched [{:keys [file file-id thumbnails] :as bundle}] @@ -304,163 +308,169 @@ :team-id (dm/str team-id) :file-id (dm/str file-id)) - (->> (rx/merge - (rx/concat - ;; Fetch all essential data that should be loaded before the file - (rx/merge - (if ^boolean render-wasm? - (->> (rx/from @wasm/module) - (rx/filter true?) - (rx/tap (fn [_] - (let [event (ug/event "penpot:wasm:loaded")] - (ug/dispatch! event)))) - (rx/ignore)) - (rx/empty)) - - (->> stream - (rx/filter (ptk/type? ::df/fonts-loaded)) - (rx/take 1) - (rx/ignore)) - - (rx/of (ntf/hide) - (dcmt/retrieve-comment-threads file-id) - (dcmt/fetch-profiles) - (df/fetch-fonts team-id))) - - ;; Once the essential data is fetched, lets proceed to - ;; fetch teh file bunldle - (rx/of (fetch-bundle file-id features))) - - (->> stream - (rx/filter (ptk/type? ::bundle-fetched)) - (rx/take 1) - (rx/map deref) - (rx/mapcat - (fn [{:keys [file]}] - (log/debug :hint "bundle fetched" - :team-id (dm/str team-id) - :file-id (dm/str file-id)) - - (rx/of (dpj/initialize-project (:project-id file)) - (dwn/initialize team-id file-id) - (dwsl/initialize-shape-layout) - (fetch-libraries file-id features) - (-> (workspace-initialized file-id) - (with-meta {:team-id team-id - :file-id file-id})))))) - - ;; Install dev perf observers once the workspace is ready - (when (contains? cf/flags :perf-logs) - (->> stream - (rx/filter (ptk/type? ::workspace-initialized)) - (rx/take 1) - (rx/tap (fn [_] (perf/setup))))) - - (->> stream - (rx/filter (ptk/type? ::dps/persistence-notification)) - (rx/take 1) - (rx/map dwc/set-workspace-visited)) - - (when-let [component-id (some-> rparams :component-id uuid/parse)] - (->> stream - (rx/filter (ptk/type? ::workspace-initialized)) - (rx/observe-on :async) - (rx/take 1) - (rx/map #(dwl/go-to-local-component :id component-id :update-layout? (:update-layout rparams))))) - - (when (:board-id rparams) - (->> stream - (rx/filter (ptk/type? ::dwv/initialize-viewport)) - (rx/take 1) - (rx/map zoom-to-frame))) - - (when-let [comment-id (some-> rparams :comment-id uuid/parse)] - (->> stream - (rx/filter (ptk/type? ::workspace-initialized)) - (rx/observe-on :async) - (rx/take 1) - (rx/map #(dwcm/navigate-to-comment-id comment-id)))) - - (when render-wasm? - (->> stream - (rx/filter dch/commit?) - (rx/map deref) - (rx/mapcat - (fn [{:keys [redo-changes]}] - (let [added (->> redo-changes - (filter #(= (:type %) :add-obj)) - (map :id))] - (->> (rx/from added) - (rx/map process-wasm-object))))))) - - (when render-wasm? - (let [local-commits-s - (->> stream - (rx/filter dch/commit?) - (rx/map deref) - (rx/filter #(and (= :local (:source %)) - (not (contains? (:tags %) :position-data)))) - (rx/filter (complement empty?))) - - notifier-s - (rx/merge - (->> local-commits-s (rx/debounce 1000)) - (->> stream (rx/filter dps/force-persist?))) - - objects-s - (rx/from-atom refs/workspace-page-objects {:emit-current-value? true}) - - current-page-id-s - (rx/from-atom refs/current-page-id {:emit-current-value? true})] - - (->> local-commits-s - (rx/buffer-until notifier-s) - (rx/with-latest-from objects-s) - (rx/map - (fn [[commits objects]] - (->> commits - (mapcat :redo-changes) - (filter #(contains? #{:mod-obj :add-obj} (:type %))) - (filter #(cfh/text-shape? objects (:id %))) - (map #(vector - (:id %) - (wasm.api/calculate-position-data (get objects (:id %)))))))) - - (rx/with-latest-from current-page-id-s) - (rx/map - (fn [[text-position-data page-id]] - (let [changes - (->> text-position-data - (mapv (fn [[id position-data]] - {:type :mod-obj - :id id - :page-id page-id - :operations - [{:type :set - :attr :position-data - :val position-data - :ignore-touched true - :ignore-geometry true}]})))] - (when (d/not-empty? changes) - (dch/commit-changes - {:redo-changes changes :undo-changes [] - :save-undo? false - :tags #{:position-data}}))))) - (rx/take-until stoper-s)))) - - (->> stream - (rx/filter dch/commit?) - (rx/map deref) - (rx/mapcat - (fn [{:keys [save-undo? undo-changes redo-changes undo-group tags stack-undo?]}] - (if (and save-undo? (seq undo-changes)) - (let [entry {:undo-changes undo-changes - :redo-changes redo-changes - :undo-group undo-group - :tags tags}] - (rx/of (dwu/append-undo entry stack-undo?))) - (rx/empty)))))) - (rx/take-until stoper-s)))) + (rx/concat + (->> (rx/merge + (rx/concat + ;; Fetch all essential data that should be loaded before the file + (rx/merge + (if ^boolean render-wasm? + (->> (rx/from @wasm/module) + (rx/filter true?) + (rx/tap (fn [_] + (let [event (ug/event "penpot:wasm:loaded")] + (ug/dispatch! event)))) + (rx/ignore)) + (rx/empty)) + + (->> stream + (rx/filter (ptk/type? ::df/fonts-loaded)) + (rx/take 1) + (rx/ignore)) + + (rx/of (ntf/hide) + (dcmt/retrieve-comment-threads file-id) + (dcmt/fetch-profiles) + (df/fetch-fonts team-id)) + + (when (contains? cf/flags :mcp) + (rx/of (du/fetch-access-tokens)))) + + ;; Once the essential data is fetched, lets proceed to + ;; fetch teh file bunldle + (rx/of (fetch-bundle file-id features))) + + (->> stream + (rx/filter (ptk/type? ::bundle-fetched)) + (rx/take 1) + (rx/map deref) + (rx/mapcat + (fn [{:keys [file]}] + (log/debug :hint "bundle fetched" + :team-id (dm/str team-id) + :file-id (dm/str file-id)) + + (rx/of (dpj/initialize-project (:project-id file)) + (dwn/initialize team-id file-id) + (dwsl/initialize-shape-layout) + (fetch-libraries file-id features) + (-> (workspace-initialized file-id) + (with-meta {:team-id team-id + :file-id file-id})))))) + + ;; Install dev perf observers once the workspace is ready + (when (contains? cf/flags :perf-logs) + (->> stream + (rx/filter (ptk/type? ::workspace-initialized)) + (rx/take 1) + (rx/tap (fn [_] (perf/setup))))) + + (->> stream + (rx/filter (ptk/type? ::dps/persistence-notification)) + (rx/take 1) + (rx/map dwc/set-workspace-visited)) + + (when-let [component-id (some-> rparams :component-id uuid/parse)] + (->> stream + (rx/filter (ptk/type? ::workspace-initialized)) + (rx/observe-on :async) + (rx/take 1) + (rx/map #(dwl/go-to-local-component :id component-id :update-layout? (:update-layout rparams))))) + + (when (:board-id rparams) + (->> stream + (rx/filter (ptk/type? ::dwv/initialize-viewport)) + (rx/take 1) + (rx/map zoom-to-frame))) + + (when-let [comment-id (some-> rparams :comment-id uuid/parse)] + (->> stream + (rx/filter (ptk/type? ::workspace-initialized)) + (rx/observe-on :async) + (rx/take 1) + (rx/map #(dwcm/navigate-to-comment-id comment-id)))) + + (when render-wasm? + (->> stream + (rx/filter dch/commit?) + (rx/map deref) + (rx/mapcat + (fn [{:keys [redo-changes]}] + (let [added (->> redo-changes + (filter #(= (:type %) :add-obj)) + (map :id))] + (->> (rx/from added) + (rx/map process-wasm-object))))))) + + (when render-wasm? + (let [local-commits-s + (->> stream + (rx/filter dch/commit?) + (rx/map deref) + (rx/filter #(and (= :local (:source %)) + (not (contains? (:tags %) :position-data)))) + (rx/filter (complement empty?))) + + notifier-s + (rx/merge + (->> local-commits-s (rx/debounce 1000)) + (->> stream (rx/filter dps/force-persist?))) + + objects-s + (rx/from-atom refs/workspace-page-objects {:emit-current-value? true}) + + current-page-id-s + (rx/from-atom refs/current-page-id {:emit-current-value? true})] + + (->> local-commits-s + (rx/buffer-until notifier-s) + (rx/with-latest-from objects-s) + (rx/map + (fn [[commits objects]] + (->> commits + (mapcat :redo-changes) + (filter #(contains? #{:mod-obj :add-obj} (:type %))) + (filter #(cfh/text-shape? objects (:id %))) + (map #(vector + (:id %) + (wasm.api/calculate-position-data (get objects (:id %)))))))) + + (rx/with-latest-from current-page-id-s) + (rx/map + (fn [[text-position-data page-id]] + (let [changes + (->> text-position-data + (mapv (fn [[id position-data]] + {:type :mod-obj + :id id + :page-id page-id + :operations + [{:type :set + :attr :position-data + :val position-data + :ignore-touched true + :ignore-geometry true}]})))] + (when (d/not-empty? changes) + (dch/commit-changes + {:redo-changes changes :undo-changes [] + :save-undo? false + :tags #{:position-data}}))))) + (rx/take-until stoper-s)))) + + (->> stream + (rx/filter dch/commit?) + (rx/map deref) + (rx/mapcat + (fn [{:keys [save-undo? undo-changes redo-changes undo-group tags stack-undo?]}] + (if (and save-undo? (seq undo-changes)) + (let [entry {:undo-changes undo-changes + :redo-changes redo-changes + :undo-group undo-group + :tags tags}] + (rx/of (dwu/append-undo entry stack-undo?))) + (rx/empty)))))) + (rx/take-until stoper-s)) + + (rx/of (mcp/notify-other-tabs-disconnect))))) ptk/EffectEvent (effect [_ _ _] diff --git a/frontend/src/app/main/data/workspace/mcp.cljs b/frontend/src/app/main/data/workspace/mcp.cljs new file mode 100644 index 00000000000..f4a9c9bacc8 --- /dev/null +++ b/frontend/src/app/main/data/workspace/mcp.cljs @@ -0,0 +1,292 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.main.data.workspace.mcp + (:require + [app.common.logging :as log] + [app.common.uri :as u] + [app.config :as cf] + [app.main.broadcast :as mbc] + [app.main.data.event :as ev] + [app.main.data.notifications :as ntf] + [app.main.data.plugins :as dp] + [app.main.repo :as rp] + [app.main.store :as st] + [app.plugins.register :refer [mcp-plugin-id]] + [app.util.i18n :refer [tr]] + [app.util.timers :as ts] + [beicon.v2.core :as rx] + [potok.v2.core :as ptk])) + +(def retry-interval 10000) + +(log/set-level! :info) + +(def ^:private default-manifest + {:code "plugin.js" + :name "Penpot MCP Plugin" + :version 2 + :plugin-id mcp-plugin-id + :description "This plugin enables interaction with the Penpot MCP server" + :allow-background true + :permissions + #{"library:read" "library:write" + "comment:read" "comment:write" + "content:write" "content:read"}}) + +(defonce interval-sub (atom nil)) + +(defn finalize-workspace? + [event] + (= (ptk/type event) :app.main.data.workspace/finalize-workspace)) + +(defn set-mcp-active + [value] + (ptk/reify ::set-mcp-active + ptk/UpdateEvent + (update [_ state] + (assoc-in state [:mcp :active] value)))) + +(defn start-reconnect-watcher! + [] + (st/emit! (set-mcp-active true)) + (when (nil? @interval-sub) + (reset! + interval-sub + (ts/interval + retry-interval + (fn [] + ;; Try to reconnect if active and not connected + (when-not (contains? #{"connecting" "connected"} + (-> @st/state :mcp :connection-status)) + (.log js/console "Reconnecting to MCP...") + (st/emit! (ptk/data-event ::connect)))))))) + +(defn stop-reconnect-watcher! + [] + (st/emit! (set-mcp-active false)) + (when @interval-sub + (rx/dispose! @interval-sub) + (reset! interval-sub nil))) + +(declare manage-mcp-notification) + +(defn handle-pong + [{:keys [id data]}] + (ptk/reify ::handle-pong + ptk/UpdateEvent + (update [_ state] + (let [mcp-state (get state :mcp)] + (cond + (= "connected" (:connection-status data)) + (update state :mcp assoc :connected-tab id) + + (and (= "disconnected" (:connection-status data)) + (= id (:connection-status mcp-state))) + (update state :mcp dissoc :connected-tab) + + :else + state))) + + ptk/WatchEvent + (watch [_ _ _] + (rx/of (manage-mcp-notification))))) + +;; This event will arrive when a new workspace is open in another tab +(defn handle-ping + [] + (ptk/reify ::handle-ping + ptk/WatchEvent + (watch [_ state _] + (let [conn-status (get-in state [:mcp :connection-status])] + (rx/of (mbc/event :mcp/pong {:connection-status conn-status})))))) + +(defn notify-other-tabs-disconnect + [] + (ptk/reify ::notify-other-tabs-disconnect + ptk/WatchEvent + (watch [_ _ _] + (rx/of (mbc/event :mcp/pong {:connection-status "disconnected"}))))) + +;; This event will arrive when the mcp is enabled in the dashboard +(defn update-mcp-status + [value] + (ptk/reify ::update-mcp-status + ptk/UpdateEvent + (update [_ state] + (update-in state [:profile :props] assoc :mcp-enabled value)) + + ptk/WatchEvent + (watch [_ _ _] + (rx/merge + (rx/of (manage-mcp-notification)) + (case value + true (rx/of (ptk/data-event ::connect)) + false (rx/of (ptk/data-event ::disconnect)) + nil))))) + +(defn update-mcp-connection-status + [value] + (ptk/reify ::update-mcp-plugin-connection + ptk/UpdateEvent + (update [_ state] + (update state :mcp assoc :connection-status value)) + + ptk/WatchEvent + (watch [_ _ _] + (rx/of (manage-mcp-notification) + (mbc/event :mcp/pong {:connection-status value}))))) + +(defn connect-mcp + [] + (ptk/reify ::connect-mcp + ptk/UpdateEvent + (update [_ state] + (update state :mcp assoc :connected-tab (:session-id state))) + + ptk/WatchEvent + (watch [_ _ _] + (rx/of (mbc/event :mcp/force-disconect {}) + (ptk/data-event ::connect))))) + +;; This event will arrive when the user selects disconnect on the menu +;; or there is a broadcast message for disconnection +(defn user-disconnect-mcp + [] + (ptk/reify ::user-disconnect-mcp + ptk/WatchEvent + (watch [_ _ _] + (rx/of (ptk/data-event ::disconnect) + (update-mcp-connection-status "disconnected"))) + + ptk/EffectEvent + (effect [_ _ _] + (stop-reconnect-watcher!)))) + +(defn- manage-mcp-notification + [] + (ptk/reify ::manage-mcp-notification + ptk/WatchEvent + (watch [_ state _] + (let [mcp-state (get state :mcp) + + mcp-enabled? (-> state :profile :props :mcp-enabled) + + current-tab-id (get state :session-id) + connected-tab-id (get mcp-state :connected-tab)] + + (if mcp-enabled? + (if (= connected-tab-id current-tab-id) + (rx/of (ntf/hide)) + (rx/of (ntf/dialog + {:content (tr "notifications.mcp.active-in-another-tab") + :cancel {:label (tr "labels.dismiss") + :callback #(st/emit! (ntf/hide) + (ev/event {::ev/name "confirm-mcp-tab-switch" + ::ev/origin "workspace-notification"}))} + :accept {:label (tr "labels.switch") + :callback #(st/emit! (connect-mcp) + (ev/event {::ev/name "dismiss-mcp-tab-switch" + ::ev/origin "workspace-notification"}))}}))) + (rx/of (ntf/hide))))))) + +(defn init-mcp + [stream] + (->> (rp/cmd! :get-current-mcp-token) + (rx/tap + (fn [{:keys [token]}] + (when token + (dp/start-plugin! + (assoc default-manifest + :url (str (u/join cf/public-uri "plugins/mcp/manifest.json")) + :host (str (u/join cf/public-uri "plugins/mcp/"))) + + ;; API extension for MCP server + #js {:mcp + #js + {:getToken (constantly token) + :getServerUrl #(str cf/mcp-ws-uri) + :setMcpStatus + (fn [status] + (when (= status "connected") + (start-reconnect-watcher!)) + (st/emit! (update-mcp-connection-status status)) + (log/info :hint "MCP STATUS" :status status)) + + :on + (fn [event cb] + (when-let [event + (case event + "disconnect" ::disconnect + "connect" ::connect + nil)] + + (let [stopper (rx/filter finalize-workspace? stream)] + (->> stream + (rx/filter (ptk/type? event)) + (rx/take-until stopper) + (rx/subs! #(cb))))))}})))) + (rx/ignore))) + +(defn init + [] + (ptk/reify ::init + ptk/UpdateEvent + (update [_ state] + (update state :mcp assoc :connected-tab (:session-id state) :active true)) + + ptk/WatchEvent + (watch [_ state stream] + (let [stoper-s (rx/merge + (rx/filter (ptk/type? :app.main.data.workspace/finalize-workspace) stream) + (rx/filter (ptk/type? ::init) stream)) + session-id (get state :session-id) + enabled? (-> state :profile :props :mcp-enabled)] + + (->> (rx/merge + (if enabled? + (rx/merge + (init-mcp stream) + + (rx/of (mbc/event :mcp/ping {})) + + (->> mbc/stream + (rx/filter (mbc/type? :mcp/ping)) + (rx/filter (fn [{:keys [id]}] + (not= session-id id))) + (rx/map handle-ping)) + + (->> mbc/stream + (rx/filter (mbc/type? :mcp/pong)) + (rx/filter (fn [{:keys [id]}] + (not= session-id id))) + (rx/map handle-pong)) + + (->> mbc/stream + (rx/filter (mbc/type? :mcp/force-disconect)) + (rx/filter (fn [{:keys [id]}] + (not= session-id id))) + (rx/map deref) + (rx/map (fn [] (user-disconnect-mcp))))) + (rx/empty)) + + (->> mbc/stream + (rx/filter (mbc/type? :mcp/enable)) + (rx/mapcat (fn [_] + ;; NOTE: we don't need an explicit + ;; connect because the plugin has + ;; auto-connect + (rx/of (update-mcp-status true) + (init))))) + + (->> mbc/stream + (rx/filter (mbc/type? :mcp/disable)) + (rx/mapcat (fn [_] + (rx/of (update-mcp-status false) + (init) + (user-disconnect-mcp)))))) + + (rx/take-until stoper-s)))))) diff --git a/frontend/src/app/main/data/workspace/notifications.cljs b/frontend/src/app/main/data/workspace/notifications.cljs index 9bfc7ac8a22..5e01fd44865 100644 --- a/frontend/src/app/main/data/workspace/notifications.cljs +++ b/frontend/src/app/main/data/workspace/notifications.cljs @@ -214,6 +214,7 @@ (update state :workspace-presence dissoc session-id) (update state :workspace-presence update-presence)))))) + (defn handle-pointer-update [{:keys [page-id session-id position zoom zoom-inverse vbox vport] :as msg}] (ptk/reify ::handle-pointer-update diff --git a/frontend/src/app/main/data/workspace/tokens/application.cljs b/frontend/src/app/main/data/workspace/tokens/application.cljs index a85d0f117c9..10c148fe9f5 100644 --- a/frontend/src/app/main/data/workspace/tokens/application.cljs +++ b/frontend/src/app/main/data/workspace/tokens/application.cljs @@ -49,14 +49,14 @@ ;; (note that dwsh/update-shapes function returns an event) -(defn update-shape-radius-all - ([value shape-ids attributes] (update-shape-radius-all value shape-ids attributes nil)) - ([value shape-ids _attributes page-id] ; The attributes param is needed to have the same arity that other update functions +(defn update-shape-radius + ([value shape-ids attributes] (update-shape-radius value shape-ids attributes nil)) + ([value shape-ids attributes page-id] (when (number? value) (let [value (max 0 value)] (dwsh/update-shapes shape-ids (fn [shape] - (ctsr/set-radius-to-all-corners shape value)) + (ctsr/set-radius-for-corners shape attributes value)) {:reg-objects? true :ignore-touched true :page-id page-id @@ -531,7 +531,7 @@ (some attributes #{:r1 :r2 :r3 :r4}) (conj #(if (= attributes #{:r1 :r2 :r3 :r4}) - (update-shape-radius-all value shape-ids attributes page-id) + (update-shape-radius value shape-ids attributes page-id) (update-shape-radius-for-corners value shape-ids (set (filter attributes #{:r1 :r2 :r3 :r4})) @@ -607,6 +607,46 @@ :state state})] (apply rx/of (map #(%) actions))))))))) +(def attributes->shape-update + "Maps each attribute-set to the update function that applies it to a shape. + Used both here (to resolve the correct update fn when explicit attrs are + passed to toggle-token) and in propagation.cljs (re-exported from there)." + {ctt/border-radius-keys update-shape-radius-for-corners + ctt/color-keys update-fill-stroke + ctt/stroke-width-keys update-stroke-width + ctt/sizing-keys apply-dimensions-token + ctt/opacity-keys update-opacity + ctt/rotation-keys update-rotation + + ;; Typography + ctt/font-family-keys update-font-family + ctt/font-size-keys update-font-size + ctt/font-weight-keys update-font-weight + ctt/letter-spacing-keys update-letter-spacing + ctt/text-case-keys update-text-case + ctt/text-decoration-keys update-text-decoration + ctt/typography-token-keys update-typography + ctt/shadow-keys update-shadow + ctt/line-height-keys update-line-height + + ;; Layout + #{:x :y} update-shape-position + #{:p1 :p2 :p3 :p4} update-layout-padding + #{:m1 :m2 :m3 :m4} update-layout-item-margin + #{:column-gap :row-gap} update-layout-gap + #{:width :height} apply-dimensions-token + #{:layout-item-min-w :layout-item-min-h + :layout-item-max-w :layout-item-max-h} update-layout-sizing-limits}) + +;; Flattened per-individual-key version of attributes->shape-update. +;; Allows O(1) lookup of the update function for any single attribute. +(def ^:private attr->shape-update + (reduce + (fn [acc [attr-set update-fn]] + (into acc (map (fn [k] [k update-fn]) attr-set))) + {} + attributes->shape-update)) + ;; Events to apply / unapply tokens to shapes ------------------------------------------------------------ (defn apply-token @@ -620,65 +660,73 @@ ptk/WatchEvent (watch [_ state _] ;; We do not allow to apply tokens while text editor is open. - (if (empty? (get state :workspace-editor-state)) - (let [attributes-to-remove - ;; Remove atomic typography tokens when applying composite and vice-verca - (cond - (ctt/typography-token-keys (:type token)) (set/union attributes-to-remove ctt/typography-keys) - (ctt/typography-keys (:type token)) (set/union attributes-to-remove ctt/typography-token-keys) - :else attributes-to-remove)] - (when-let [tokens (some-> (dsh/lookup-file-data state) - (get :tokens-lib) - (ctob/get-tokens-in-active-sets))] - (->> (if (contains? cf/flags :tokenscript) - (rx/of (ts/resolve-tokens tokens)) - (sd/resolve-tokens tokens)) - (rx/mapcat - (fn [resolved-tokens] - (let [undo-id (js/Symbol) - objects (dsh/lookup-page-objects state) - selected-shapes (select-keys objects shape-ids) - - shapes (->> selected-shapes - (filter (fn [[_ shape]] - (or - (and (ctsl/any-layout-immediate-child? objects shape) - (some ctt/spacing-margin-keys attributes)) - (and (ctt/any-appliable-attr-for-shape? attributes (:type shape) (:layout shape)) - (all-attrs-appliable-for-token? attributes (:type token))))))) - shape-ids (d/nilv (keys shapes) []) - any-variant? (->> shapes vals (some ctk/is-variant?) boolean) - - resolved-value (get-in resolved-tokens [(cfo/token-identifier token) :resolved-value]) - resolved-value (if (contains? cf/flags :tokenscript) - (ts/tokenscript-symbols->penpot-unit resolved-value) - resolved-value) - tokenized-attributes (cfo/attributes-map attributes token) - type (:type token)] - (rx/concat - (rx/of - (st/emit! (ev/event {::ev/name "apply-tokens" - :type type - :applied-to attributes - :applied-to-variant any-variant?})) - (dwu/start-undo-transaction undo-id) - (dwsh/update-shapes shape-ids (fn [shape] - (cond-> shape - attributes-to-remove - (update :applied-tokens #(apply (partial dissoc %) attributes-to-remove)) - :always - (update :applied-tokens merge tokenized-attributes))))) - (when on-update-shape - (let [res (on-update-shape resolved-value shape-ids attributes)] - ;; Composed updates return observables and need to be executed differently - (if (rx/observable? res) - res - (rx/of res)))) - (rx/of (dwu/commit-undo-transaction undo-id))))))))) - (rx/of (ntf/show {:content (tr "workspace.tokens.error-text-edition") - :type :toast - :level :warning - :timeout 3000})))))) + ;; The classic text editor sets :workspace-editor-state; the WASM text editor + ;; does not, so we also check :workspace-local :edition for text shapes. + (let [edition (get-in state [:workspace-local :edition]) + objects (dsh/lookup-page-objects state) + text-editing? (and (some? edition) + (= :text (:type (get objects edition))))] + (if (and (empty? (get state :workspace-editor-state)) + (not text-editing?)) + (let [attributes-to-remove + ;; Remove atomic typography tokens when applying composite and vice-verca + (cond + (ctt/typography-token-keys (:type token)) (set/union attributes-to-remove ctt/typography-keys) + (ctt/typography-keys (:type token)) (set/union attributes-to-remove ctt/typography-token-keys) + :else attributes-to-remove)] + (when-let [tokens (some-> (dsh/lookup-file-data state) + (get :tokens-lib) + (ctob/get-tokens-in-active-sets))] + (->> (if (contains? cf/flags :tokenscript) + (rx/of (ts/resolve-tokens tokens)) + (sd/resolve-tokens tokens)) + (rx/mapcat + (fn [resolved-tokens] + (let [undo-id (js/Symbol) + objects (dsh/lookup-page-objects state) + selected-shapes (select-keys objects shape-ids) + + shapes (->> selected-shapes + (filter (fn [[_ shape]] + (or + (and (ctsl/any-layout-immediate-child? objects shape) + (some ctt/spacing-margin-keys attributes)) + (and (ctt/any-appliable-attr-for-shape? attributes (:type shape) (:layout shape)) + (all-attrs-appliable-for-token? attributes (:type token))))))) + shape-ids (d/nilv (keys shapes) []) + any-variant? (->> shapes vals (some ctk/is-variant?) boolean) + + resolved-value (get-in resolved-tokens [(cfo/token-identifier token) :resolved-value]) + resolved-value (if (contains? cf/flags :tokenscript) + (ts/tokenscript-symbols->penpot-unit resolved-value) + resolved-value) + tokenized-attributes (cfo/attributes-map attributes token) + type (:type token)] + (rx/concat + (rx/of + (st/emit! (ev/event {::ev/name "apply-tokens" + :type type + :applied-to attributes + :applied-to-variant any-variant?})) + (dwu/start-undo-transaction undo-id) + (dwsh/update-shapes shape-ids (fn [shape] + (cond-> shape + attributes-to-remove + (update :applied-tokens #(apply (partial dissoc %) attributes-to-remove)) + :always + (update :applied-tokens merge tokenized-attributes))))) + (when on-update-shape + (let [res (on-update-shape resolved-value shape-ids attributes)] + ;; Composed updates return observables and need to be executed differently + (if (rx/observable? res) + res + (rx/of res)))) + (rx/of (dwu/commit-undo-transaction undo-id))))))))) + + (rx/of (ntf/show {:content (tr "workspace.tokens.error-text-edition") + :type :toast + :level :warning + :timeout 3000}))))))) (defn apply-spacing-token-separated "Handles edge-case for spacing token when applying token via toggle button. @@ -744,10 +792,16 @@ {:keys [attributes all-attributes on-update-shape]} (get token-properties (:type token)) + on-update-shape + (if (seq attrs) + (or (get attr->shape-update (first attrs)) on-update-shape) + on-update-shape) + unapply-tokens? (cfo/shapes-token-applied? token shapes (or attrs all-attributes attributes)) - shape-ids (map :id shapes)] + shape-ids + (map :id shapes)] (if unapply-tokens? (rx/of @@ -808,7 +862,7 @@ :border-radius {:title "Border Radius" :attributes ctt/border-radius-keys - :on-update-shape update-shape-radius-all + :on-update-shape update-shape-radius :modal {:key :tokens/border-radius :fields [{:label "Border Radius" :key :border-radius}]}} diff --git a/frontend/src/app/main/data/workspace/variants.cljs b/frontend/src/app/main/data/workspace/variants.cljs index 28f1a309632..b21afdc8b7d 100644 --- a/frontend/src/app/main/data/workspace/variants.cljs +++ b/frontend/src/app/main/data/workspace/variants.cljs @@ -613,7 +613,7 @@ vec)) (defn combine-as-variants - [ids {:keys [page-id trigger]}] + [ids {:keys [page-id trigger variant-id]}] (ptk/reify ::combine-as-variants ptk/WatchEvent (watch [_ state stream] @@ -647,7 +647,7 @@ :shapes count inc) - variant-id (uuid/next) + variant-id (or variant-id (uuid/next)) undo-id (js/Symbol)] (rx/concat diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index 37177aec7d9..58146736e1a 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -33,6 +33,16 @@ ;; Will contain last uncaught exception (def last-exception nil) +(defn is-plugin-error? + "This is a placeholder that always return false. It will be + overwritten when plugin system is initialized. This works this way + because we can't import plugins here because plugins requries full + DOM. + + This placeholder is set on app.plugins/initialize event" + [_] + false) + ;; Re-entrancy guard: prevents on-error from calling itself recursively. ;; If an error occurs while we are already handling an error (e.g. the ;; notification emit itself throws), we log it and bail out immediately @@ -206,6 +216,16 @@ (ex/print-throwable cause :prefix "Unexpected Error") (flash :cause cause :type :unhandled)))) +(defmethod ptk/handle-error :wasm-error + [error] + (when-let [cause (::instance error)] + (ex/print-throwable cause) + (let [code (get error :code)] + (if (or (= code :panic) + (= code :webgl-context-lost)) + (st/emit! (rt/assign-exception error)) + (flash :type :handled :cause cause))))) + ;; We receive a explicit authentication error; If the uri is for ;; workspace, dashboard, viewer or settings, then assign the exception ;; for show the error page. Otherwise this explicitly clears all @@ -420,6 +440,15 @@ (and (string? stack) (str/includes? stack "posthog")))) + ;; Check if the error is marked as originating from plugin code. + ;; The plugin runtime tracks plugin errors in a WeakMap, which works + ;; even in SES hardened environments where error objects may be frozen. + (from-plugin? [cause] + (try + (is-plugin-error? cause) + (catch :default _ + false))) + (is-ignorable-exception? [cause] (let [message (ex-message cause)] (or (from-extension? cause) @@ -447,32 +476,55 @@ (on-unhandled-error [event] (.preventDefault ^js event) (when-let [cause (unchecked-get event "error")] - (when-not (is-ignorable-exception? cause) - (if (stale-asset-error? cause) - (cf/throttled-reload :reason (ex-message cause)) - (let [data (ex-data cause) - type (get data :type)] - (set! last-exception cause) - (if (= :wasm-error type) - (on-error cause) - (do - (ex/print-throwable cause :prefix "Uncaught Exception") - (ts/asap #(flash :cause cause :type :unhandled))))))))) + (cond + (stale-asset-error? cause) + (cf/throttled-reload :reason (ex-message cause)) + + ;; Plugin errors: log to console and ignore + (from-plugin? cause) + (ex/print-throwable cause :prefix "Plugin Error") + + ;; Other ignorable exceptions: ignore silently + (is-ignorable-exception? cause) + nil + + ;; All other errors: show exception page + :else + + (let [data (ex-data cause) + type (get data :type)] + (set! last-exception cause) + (if (= :wasm-error type) + (on-error cause) + (do + (ex/print-throwable cause :prefix "Uncaught Exception") + (ts/asap #(flash :cause cause :type :unhandled)))))))) (on-unhandled-rejection [event] (.preventDefault ^js event) (when-let [cause (unchecked-get event "reason")] - (when-not (is-ignorable-exception? cause) - (if (stale-asset-error? cause) - (cf/throttled-reload :reason (ex-message cause)) - (let [data (ex-data cause) - type (get data :type)] - (set! last-exception cause) - (if (= :wasm-error type) - (on-error cause) - (do - (ex/print-throwable cause :prefix "Uncaught Rejection") - (ts/asap #(flash :cause cause :type :unhandled)))))))))] + (cond + (stale-asset-error? cause) + (cf/throttled-reload :reason (ex-message cause)) + + ;; Plugin errors: log to console and ignore + (from-plugin? cause) + (ex/print-throwable cause :prefix "Plugin Error") + + ;; Other ignorable exceptions: ignore silently + (is-ignorable-exception? cause) + nil + + ;; All other errors: show exception page + :else + (let [data (ex-data cause) + type (get data :type)] + (set! last-exception cause) + (if (= :wasm-error type) + (on-error cause) + (do + (ex/print-throwable cause :prefix "Uncaught Rejection") + (ts/asap #(flash :cause cause :type :unhandled))))))))] (.addEventListener g/window "error" on-unhandled-error) (.addEventListener g/window "unhandledrejection" on-unhandled-rejection) diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs index c4e0faaecd2..30c431ac86c 100644 --- a/frontend/src/app/main/refs.cljs +++ b/frontend/src/app/main/refs.cljs @@ -150,6 +150,9 @@ (def workspace-global (l/derived :workspace-global st/state)) +(def mcp + (l/derived :mcp st/state)) + (def workspace-drawing (l/derived :workspace-drawing st/state)) diff --git a/frontend/src/app/main/ui.cljs b/frontend/src/app/main/ui.cljs index 1a03943ba4b..67795c2ff38 100644 --- a/frontend/src/app/main/ui.cljs +++ b/frontend/src/app/main/ui.cljs @@ -190,7 +190,7 @@ :settings-options :settings-feedback :settings-subscription - :settings-access-tokens + :settings-integrations :settings-notifications) (let [params (get params :query) error-report-id (some-> params :error-report-id uuid/parse*)] diff --git a/frontend/src/app/main/ui/confirm.cljs b/frontend/src/app/main/ui/confirm.cljs index ca8a78aea07..d2c068ebf2c 100644 --- a/frontend/src/app/main/ui/confirm.cljs +++ b/frontend/src/app/main/ui/confirm.cljs @@ -9,14 +9,17 @@ (:require [app.main.data.modal :as modal] [app.main.store :as st] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.main.ui.ds.notifications.context-notification :refer [context-notification*]] - [app.main.ui.icons :as deprecated-icon] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [app.util.keyboard :as k] [goog.events :as events] [rumext.v2 :as mf]) - (:import goog.events.EventType)) + (:import + goog.events.EventType)) (mf/defc confirm-dialog {::mf/register modal/components @@ -68,8 +71,11 @@ [:div {:class (stl/css :modal-container)} [:div {:class (stl/css :modal-header)} [:h2 {:class (stl/css :modal-title)} title] - [:button {:class (stl/css :modal-close-btn) - :on-click cancel-fn} deprecated-icon/close]] + [:div {:class (stl/css :modal-close-btn)} + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.close") + :on-click cancel-fn + :icon i/close}]]] [:div {:class (stl/css :modal-content)} (when (and (string? message) (not= message "")) @@ -87,24 +93,19 @@ [:ul {:class (stl/css :component-list)} (for [item items] [:li {:class (stl/css :modal-item-element)} - [:span {:class (stl/css :modal-component-icon)} - deprecated-icon/component] + [:> icon* {:icon-id i/component + :class (stl/css :modal-component-icon) + :size "s"}] [:span {:class (stl/css :modal-component-name)} (:name item)]])]])] [:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :action-buttons)} (when-not (= cancel-label :omit) - [:input - {:class (stl/css :cancel-button) - :type "button" - :value cancel-label - :on-click cancel-fn}]) - - [:input - {:class (stl/css-case :accept-btn true - :danger (= accept-style :danger) - :primary (= accept-style :primary)) - :type "button" - :value accept-label - :on-click accept-fn}]]]]])) + [:> button* {:variant "secondary" + :on-click cancel-fn} + cancel-label]) + [:> button* {:variant (cond (= accept-style :danger) "destructive" + (= accept-style :primary) "primary") + :on-click accept-fn} + accept-label]]]]])) diff --git a/frontend/src/app/main/ui/confirm.scss b/frontend/src/app/main/ui/confirm.scss index e517a7b685b..09b23426f32 100644 --- a/frontend/src/app/main/ui/confirm.scss +++ b/frontend/src/app/main/ui/confirm.scss @@ -15,10 +15,9 @@ .modal-container { @extend .modal-container-base; -} - -.modal-header { - margin-bottom: deprecated.$s-24; + display: flex; + flex-direction: column; + gap: var(--sp-xxl); } .modal-title { @@ -27,12 +26,13 @@ } .modal-close-btn { - @extend .modal-close-btn-base; + position: absolute; + top: var(--sp-m); + right: var(--sp-m); } .modal-content { @include deprecated.bodyLargeTypography; - margin-bottom: deprecated.$s-24; } .modal-item-element { @@ -41,32 +41,18 @@ .modal-component-icon { @include deprecated.flexCenter; - height: deprecated.$s-16; - width: deprecated.$s-16; - svg { - @extend .button-icon-small; - stroke: var(--color); - } + color: var(--color-foreground-secondary); } + .modal-component-name { @include deprecated.bodyLargeTypography; + color: var(--color-foreground-secondary); } .action-buttons { @extend .modal-action-btns; } -.cancel-button { - @extend .modal-cancel-btn; -} - -.accept-btn { - @extend .modal-accept-btn; - &.danger { - @extend .modal-danger-btn; - } -} - .modal-scd-msg, .modal-subtitle, .modal-msg { diff --git a/frontend/src/app/main/ui/ds/_sizes.scss b/frontend/src/app/main/ui/ds/_sizes.scss index 067bd0b416f..9daa3a5ec75 100644 --- a/frontend/src/app/main/ui/ds/_sizes.scss +++ b/frontend/src/app/main/ui/ds/_sizes.scss @@ -18,6 +18,7 @@ $sz-32: px2rem(32); $sz-36: px2rem(36); $sz-40: px2rem(40); $sz-48: px2rem(48); +$sz-64: px2rem(64); $sz-88: px2rem(88); $sz-96: px2rem(96); $sz-120: px2rem(120); diff --git a/frontend/src/app/main/ui/ds/controls/input.cljs b/frontend/src/app/main/ui/ds/controls/input.cljs index 29ae0cc8045..918e5a446bc 100644 --- a/frontend/src/app/main/ui/ds/controls/input.cljs +++ b/frontend/src/app/main/ui/ds/controls/input.cljs @@ -8,7 +8,6 @@ (:require-macros [app.main.style :as stl]) (:require [app.common.data :as d] - [app.common.data.macros :as dm] [app.main.constants :refer [max-input-length]] [app.main.ui.ds.controls.utilities.hint-message :refer [hint-message*]] [app.main.ui.ds.controls.utilities.input-field :refer [input-field*]] @@ -52,10 +51,11 @@ :has-hint has-hint :hint-type hint-type :variant variant})] - [:div {:class (dm/str class " " (stl/css-case :input-wrapper true - :variant-dense (= variant "dense") - :variant-comfortable (= variant "comfortable") - :has-hint has-hint))} + + [:div {:class [class (stl/css-case :input-wrapper true + :variant-dense (= variant "dense") + :variant-comfortable (= variant "comfortable") + :has-hint has-hint)]} (when has-label [:> label* {:for id :is-optional is-optional} label]) [:> input-field* props] @@ -64,4 +64,3 @@ :class hint-class :message hint-message :type hint-type}])])) - diff --git a/frontend/src/app/main/ui/forms.cljs b/frontend/src/app/main/ui/forms.cljs index 7f1244dcadf..9aede980cfd 100644 --- a/frontend/src/app/main/ui/forms.cljs +++ b/frontend/src/app/main/ui/forms.cljs @@ -8,6 +8,7 @@ (:require [app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.ds.controls.input :refer [input*]] + [app.main.ui.ds.controls.select :refer [select*]] [app.util.dom :as dom] [app.util.forms :as fm] [app.util.keyboard :as k] @@ -47,6 +48,23 @@ [:> input* props])) +(mf/defc form-select* + [{:keys [name] :as props}] + (let [select-name name + form (mf/use-ctx context) + value (get-in @form [:data select-name] "") + + handle-change + (fn [event] + (let [value (if (string? event) event (dom/get-target-val event))] + (fm/on-input-change form select-name value))) + + props + (mf/spread-props props {:on-change handle-change + :value value})] + + [:> select* props])) + (mf/defc form-submit* [{:keys [disabled on-submit] :rest props}] (let [form (mf/use-ctx context) @@ -79,4 +97,4 @@ (when (fn? on-submit) (on-submit form event))))] [:> (mf/provider context) {:value form} - [:form {:class class :on-submit on-submit'} children]])) \ No newline at end of file + [:form {:class class :on-submit on-submit'} children]])) diff --git a/frontend/src/app/main/ui/routes.cljs b/frontend/src/app/main/ui/routes.cljs index e8159d38527..ca45bc51339 100644 --- a/frontend/src/app/main/ui/routes.cljs +++ b/frontend/src/app/main/ui/routes.cljs @@ -36,7 +36,7 @@ ["/feedback" :settings-feedback] ["/options" :settings-options] ["/subscriptions" :settings-subscription] - ["/access-tokens" :settings-access-tokens] + ["/integrations" :settings-integrations] ["/notifications" :settings-notifications]] ["/frame-preview" :frame-preview] diff --git a/frontend/src/app/main/ui/settings.cljs b/frontend/src/app/main/ui/settings.cljs index bc40ae20fab..2cb617c9392 100644 --- a/frontend/src/app/main/ui/settings.cljs +++ b/frontend/src/app/main/ui/settings.cljs @@ -13,10 +13,10 @@ [app.main.store :as st] [app.main.ui.hooks :as hooks] [app.main.ui.modal :refer [modal-container*]] - [app.main.ui.settings.access-tokens :refer [access-tokens-page]] [app.main.ui.settings.change-email] [app.main.ui.settings.delete-account] [app.main.ui.settings.feedback :refer [feedback-page*]] + [app.main.ui.settings.integrations :refer [integrations-page*]] [app.main.ui.settings.notifications :refer [notifications-page*]] [app.main.ui.settings.options :refer [options-page]] [app.main.ui.settings.password :refer [password-page]] @@ -73,8 +73,8 @@ :settings-subscription [:> subscription-page* {:profile profile}] - :settings-access-tokens - [:& access-tokens-page] + :settings-integrations + [:> integrations-page*] :settings-notifications [:& notifications-page* {:profile profile}])]]]])) diff --git a/frontend/src/app/main/ui/settings/access_tokens.cljs b/frontend/src/app/main/ui/settings/access_tokens.cljs deleted file mode 100644 index 29a09476b02..00000000000 --- a/frontend/src/app/main/ui/settings/access_tokens.cljs +++ /dev/null @@ -1,291 +0,0 @@ -;; This Source Code Form is subject to the terms of the Mozilla Public -;; License, v. 2.0. If a copy of the MPL was not distributed with this -;; file, You can obtain one at http://mozilla.org/MPL/2.0/. -;; -;; Copyright (c) KALEIDOS INC - -(ns app.main.ui.settings.access-tokens - (:require-macros [app.main.style :as stl]) - (:require - [app.common.schema :as sm] - [app.common.time :as ct] - [app.main.data.modal :as modal] - [app.main.data.notifications :as ntf] - [app.main.data.profile :as du] - [app.main.store :as st] - [app.main.ui.components.context-menu-a11y :refer [context-menu*]] - [app.main.ui.components.forms :as fm] - [app.main.ui.icons :as deprecated-icon] - [app.util.clipboard :as clipboard] - [app.util.dom :as dom] - [app.util.i18n :as i18n :refer [tr]] - [app.util.keyboard :as kbd] - [okulary.core :as l] - [rumext.v2 :as mf])) - -(def ^:private clipboard-icon - (deprecated-icon/icon-xref :clipboard (stl/css :clipboard-icon))) - -(def ^:private close-icon - (deprecated-icon/icon-xref :close (stl/css :close-icon))) - -(def ^:private menu-icon - (deprecated-icon/icon-xref :menu (stl/css :menu-icon))) - -(def tokens-ref - (l/derived :access-tokens st/state)) - -(def token-created-ref - (l/derived :access-token-created st/state)) - -(def ^:private schema:form - [:map {:title "AccessTokenForm"} - [:name [::sm/text {:max 250}]] - [:expiration-date [::sm/text {:max 250}]]]) - -(def initial-data - {:name "" :expiration-date "never"}) - -(mf/defc access-token-modal - {::mf/register modal/components - ::mf/register-as :access-token} - [] - (let [form (fm/use-form - :initial initial-data - :schema schema:form) - - created (mf/deref token-created-ref) - created? (mf/use-state false) - - on-success - (mf/use-fn - (mf/deps created) - (fn [_] - (let [message (tr "dashboard.access-tokens.create.success")] - (st/emit! (du/fetch-access-tokens) - (ntf/success message) - (reset! created? true))))) - - on-close - (mf/use-fn - (mf/deps created) - (fn [_] - (reset! created? false) - (st/emit! (modal/hide)))) - - on-error - (mf/use-fn - (fn [_] - (st/emit! (ntf/error (tr "errors.generic")) - (modal/hide)))) - - on-submit - (mf/use-fn - (fn [form] - (let [cdata (:clean-data @form) - mdata {:on-success (partial on-success form) - :on-error (partial on-error form)} - expiration (:expiration-date cdata) - params (cond-> {:name (:name cdata) - :perms (:perms cdata)} - (not= "never" expiration) (assoc :expiration expiration))] - (st/emit! (du/create-access-token - (with-meta params mdata)))))) - - copy-token - (mf/use-fn - (mf/deps created) - (fn [event] - (dom/prevent-default event) - (clipboard/to-clipboard (:token created)) - (st/emit! (ntf/show {:level :info - :type :toast - :content (tr "dashboard.access-tokens.copied-success") - :timeout 7000}))))] - - [:div {:class (stl/css :modal-overlay)} - [:div {:class (stl/css :modal-container)} - [:& fm/form {:form form :on-submit on-submit} - - [:div {:class (stl/css :modal-header)} - [:h2 {:class (stl/css :modal-title)} (tr "modals.create-access-token.title")] - - [:button {:class (stl/css :modal-close-btn) - :on-click on-close} - close-icon]] - - [:div {:class (stl/css :modal-content)} - [:div {:class (stl/css :fields-row)} - [:& fm/input {:type "text" - :auto-focus? true - :form form - :name :name - :disabled @created? - :label (tr "modals.create-access-token.name.label") - :show-success? true - :placeholder (tr "modals.create-access-token.name.placeholder")}]] - - [:div {:class (stl/css :fields-row)} - [:div {:class (stl/css :select-title)} - (tr "modals.create-access-token.expiration-date.label")] - [:& fm/select {:options [{:label (tr "dashboard.access-tokens.expiration-never") :value "never" :key "never"} - {:label (tr "dashboard.access-tokens.expiration-30-days") :value "720h" :key "720h"} - {:label (tr "dashboard.access-tokens.expiration-60-days") :value "1440h" :key "1440h"} - {:label (tr "dashboard.access-tokens.expiration-90-days") :value "2160h" :key "2160h"} - {:label (tr "dashboard.access-tokens.expiration-180-days") :value "4320h" :key "4320h"}] - :default "never" - :disabled @created? - :name :expiration-date}] - (when @created? - [:span {:class (stl/css :token-created-info)} - (if (:expires-at created) - (tr "dashboard.access-tokens.token-will-expire" (ct/format-inst (:expires-at created) "PPP")) - (tr "dashboard.access-tokens.token-will-not-expire"))])] - - [:div {:class (stl/css :fields-row)} - (when @created? - [:div {:class (stl/css :custon-input-wrapper)} - [:input {:type "text" - :value (:token created "") - :class (stl/css :custom-input-token) - :read-only true}] - [:button {:title (tr "modals.create-access-token.copy-token") - :class (stl/css :copy-btn) - :on-click copy-token} - clipboard-icon]]) - #_(when @created? - [:button {:class (stl/css :copy-btn) - :title (tr "modals.create-access-token.copy-token") - :on-click copy-token} - [:span {:class (stl/css :token-value)} (:token created "")] - [:span {:class (stl/css :icon)} - i/clipboard]])]] - - [:div {:class (stl/css :modal-footer)} - [:div {:class (stl/css :action-buttons)} - - (if @created? - [:input {:class (stl/css :cancel-button) - :type "button" - :value (tr "labels.close") - :on-click modal/hide!}] - [:* - [:input {:class (stl/css :cancel-button) - :type "button" - :value (tr "labels.cancel") - :on-click modal/hide!}] - [:> fm/submit-button* - {:large? false :label (tr "modals.create-access-token.submit-label")}]])]]]]])) - -(mf/defc access-tokens-hero - [] - (let [on-click (mf/use-fn #(st/emit! (modal/show :access-token {})))] - [:div {:class (stl/css :access-tokens-hero)} - [:h2 {:class (stl/css :hero-title)} (tr "dashboard.access-tokens.personal")] - [:p {:class (stl/css :hero-desc)} (tr "dashboard.access-tokens.personal.description")] - - [:button {:class (stl/css :hero-btn) - :on-click on-click} - (tr "dashboard.access-tokens.create")]])) - -(mf/defc access-token-actions - [{:keys [on-delete]}] - (let [local (mf/use-state {:menu-open false}) - show? (:menu-open @local) - options (mf/with-memo [on-delete] - [{:name (tr "labels.delete") - :id "access-token-delete" - :handler on-delete}]) - - menu-ref (mf/use-ref) - - on-menu-close - (mf/use-fn #(swap! local assoc :menu-open false)) - - on-menu-click - (mf/use-fn - (fn [event] - (dom/prevent-default event) - (swap! local assoc :menu-open true))) - - on-keydown - (mf/use-fn - (mf/deps on-menu-click) - (fn [event] - (when (kbd/enter? event) - (dom/stop-propagation event) - (on-menu-click event))))] - - [:button {:class (stl/css :menu-btn) - :tab-index "0" - :ref menu-ref - :on-click on-menu-click - :on-key-down on-keydown} - menu-icon - [:> context-menu* - {:on-close on-menu-close - :show show? - :fixed true - :min-width true - :top "auto" - :left "auto" - :options options}]])) - -(mf/defc access-token-item - {::mf/wrap [mf/memo]} - [{:keys [token] :as props}] - (let [expires-at (:expires-at token) - expires-txt (some-> expires-at (ct/format-inst "PPP")) - expired? (and (some? expires-at) (> (ct/now) expires-at)) - - delete-fn - (mf/use-fn - (mf/deps token) - (fn [] - (let [params {:id (:id token)} - mdata {:on-success #(st/emit! (du/fetch-access-tokens))}] - (st/emit! (du/delete-access-token (with-meta params mdata)))))) - - on-delete - (mf/use-fn - (mf/deps delete-fn) - (fn [] - (st/emit! (modal/show - {:type :confirm - :title (tr "modals.delete-acces-token.title") - :message (tr "modals.delete-acces-token.message") - :accept-label (tr "modals.delete-acces-token.accept") - :on-accept delete-fn}))))] - - [:div {:class (stl/css :table-row)} - [:div {:class (stl/css :table-field :field-name)} - (str (:name token))] - - [:div {:class (stl/css-case :expiration-date true - :expired expired?)} - (cond - (nil? expires-at) (tr "dashboard.access-tokens.no-expiration") - expired? (tr "dashboard.access-tokens.expired-on" expires-txt) - :else (tr "dashboard.access-tokens.expires-on" expires-txt))] - [:div {:class (stl/css :table-field :actions)} - [:& access-token-actions - {:on-delete on-delete}]]])) - -(mf/defc access-tokens-page - [] - (let [tokens (mf/deref tokens-ref)] - (mf/with-effect [] - (dom/set-html-title (tr "title.settings.access-tokens")) - (st/emit! (du/fetch-access-tokens))) - - [:div {:class (stl/css :dashboard-access-tokens)} - [:& access-tokens-hero] - (if (empty? tokens) - [:div {:class (stl/css :access-tokens-empty)} - [:div (tr "dashboard.access-tokens.empty.no-access-tokens")] - [:div (tr "dashboard.access-tokens.empty.add-one")]] - [:div {:class (stl/css :dashboard-table)} - [:div {:class (stl/css :table-rows)} - (for [token tokens] - [:& access-token-item {:token token :key (:id token)}])]])])) - diff --git a/frontend/src/app/main/ui/settings/access_tokens.scss b/frontend/src/app/main/ui/settings/access_tokens.scss deleted file mode 100644 index 5e9f139765c..00000000000 --- a/frontend/src/app/main/ui/settings/access_tokens.scss +++ /dev/null @@ -1,202 +0,0 @@ -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. -// -// Copyright (c) KALEIDOS INC - -@use "refactor/common-refactor.scss" as deprecated; - -// ACCESS TOKENS PAGE -.dashboard-access-tokens { - display: grid; - grid-template-rows: auto 1fr; - margin: deprecated.$s-80 auto deprecated.$s-120 auto; - gap: deprecated.$s-32; - width: deprecated.$s-800; -} - -// hero -.access-tokens-hero { - display: grid; - grid-template-rows: auto auto 1fr; - gap: deprecated.$s-32; - width: deprecated.$s-500; - font-size: deprecated.$fs-14; - margin: deprecated.$s-16 auto 0 auto; -} - -.hero-title { - @include deprecated.bigTitleTipography; - color: var(--title-foreground-color-hover); -} - -.hero-desc { - color: var(--title-foreground-color); - margin-bottom: 0; - font-size: deprecated.$fs-14; -} - -.hero-btn { - @extend .button-primary; -} - -// table empty -.access-tokens-empty { - display: grid; - place-items: center; - align-content: center; - height: deprecated.$s-156; - max-width: deprecated.$s-1000; - width: 100%; - padding: deprecated.$s-32; - border: deprecated.$s-1 solid var(--panel-border-color); - border-radius: deprecated.$br-8; - color: var(--dashboard-list-text-foreground-color); -} - -// Access tokens table -.dashboard-table { - height: fit-content; -} - -.table-rows { - display: grid; - grid-auto-rows: deprecated.$s-64; - gap: deprecated.$s-16; - width: 100%; - height: 100%; - max-width: deprecated.$s-1000; - margin-top: deprecated.$s-16; - color: var(--title-foreground-color); -} - -.table-row { - display: grid; - grid-template-columns: 43% 1fr auto; - align-items: center; - height: deprecated.$s-64; - width: 100%; - padding: 0 deprecated.$s-16; - border-radius: deprecated.$br-8; - background-color: var(--dashboard-list-background-color); - color: var(--dashboard-list-foreground-color); -} - -.field-name { - @include deprecated.textEllipsis; - display: grid; - width: 43%; - min-width: deprecated.$s-300; -} - -.expiration-date { - @include deprecated.flexCenter; - min-width: deprecated.$s-76; - width: fit-content; - height: deprecated.$s-24; - border-radius: deprecated.$br-8; - color: var(--dashboard-list-text-foreground-color); -} - -.expired { - @include deprecated.headlineSmallTypography; - padding: 0 deprecated.$s-6; - color: var(--pill-foreground-color); - background-color: var(--status-widget-background-color-warning); -} - -.actions { - position: relative; -} -.menu-icon { - @extend .button-icon; - stroke: var(--icon-foreground); -} - -.menu-btn { - @include deprecated.buttonStyle; -} - -// Create access token modal -.modal-overlay { - @extend .modal-overlay-base; -} - -.modal-container { - @extend .modal-container-base; - min-width: deprecated.$s-408; -} - -.modal-header { - margin-bottom: deprecated.$s-24; -} - -.modal-title { - @include deprecated.uppercaseTitleTipography; - color: var(--modal-title-foreground-color); -} -.modal-close-btn { - @extend .modal-close-btn-base; -} - -.modal-content { - @include deprecated.flexColumn; - gap: deprecated.$s-24; - @include deprecated.bodySmallTypography; - margin-bottom: deprecated.$s-24; -} - -.select-title { - @include deprecated.bodySmallTypography; - color: var(--modal-title-foreground-color); -} - -.custon-input-wrapper { - @include deprecated.flexRow; - border-radius: deprecated.$br-8; - height: deprecated.$s-32; - background-color: var(--input-background-color); -} - -.custom-input-token { - @extend .input-element; - @include deprecated.bodySmallTypography; - margin: 0; - flex-grow: 1; - &:focus { - outline: none; - border: deprecated.$s-1 solid var(--input-border-color-active); - } -} - -.token-value { - @include deprecated.textEllipsis; - @include deprecated.bodySmallTypography; - flex-grow: 1; -} - -.copy-btn { - @include deprecated.flexCenter; - @extend .button-secondary; - height: deprecated.$s-28; - width: deprecated.$s-28; -} - -.clipboard-icon { - @extend .button-icon-small; -} - -.token-created-info { - color: var(--modal-text-foreground-color); -} - -.action-buttons { - @extend .modal-action-btns; - button { - @extend .modal-accept-btn; - } -} - -.cancel-button { - @extend .modal-cancel-btn; -} diff --git a/frontend/src/app/main/ui/settings/integrations.cljs b/frontend/src/app/main/ui/settings/integrations.cljs new file mode 100644 index 00000000000..780f9918e31 --- /dev/null +++ b/frontend/src/app/main/ui/settings/integrations.cljs @@ -0,0 +1,635 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.main.ui.settings.integrations + (:require-macros [app.main.style :as stl]) + (:require + [app.common.data.macros :as dm] + [app.common.schema :as sm] + [app.common.time :as ct] + [app.config :as cf] + [app.main.broadcast :as mbc] + [app.main.data.event :as ev] + [app.main.data.modal :as modal] + [app.main.data.notifications :as ntf] + [app.main.data.profile :as du] + [app.main.refs :as refs] + [app.main.store :as st] + [app.main.ui.components.context-menu-a11y :refer [context-menu*]] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.controls.input :refer [input*]] + [app.main.ui.ds.controls.switch :refer [switch*]] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] + [app.main.ui.ds.foundations.typography :as t] + [app.main.ui.ds.foundations.typography.heading :refer [heading*]] + [app.main.ui.ds.foundations.typography.text :refer [text*]] + [app.main.ui.ds.notifications.shared.notification-pill :refer [notification-pill*]] + [app.main.ui.ds.tooltip :refer [tooltip*]] + [app.main.ui.forms :as fc] + [app.util.clipboard :as clipboard] + [app.util.dom :as dom] + [app.util.forms :as fm] + [app.util.i18n :as i18n :refer [tr]] + [okulary.core :as l] + [rumext.v2 :as mf])) + +(def tokens-ref + (l/derived :access-tokens st/state)) + +(def token-created-ref + (l/derived :access-token-created st/state)) + +(def notification-timeout 7000) + +(def ^:private schema:form-access-token + [:map + [:name [::sm/text {:max 250}]] + [:expiration-date [::sm/text {:max 250}]]]) + +(def ^:private schema:form-mcp-key + [:map + [:expiration-date [::sm/text {:max 250}]]]) + +(def form-initial-data-access-token + {:name "" + :expiration-date "never"}) + +(def form-initial-data-mcp-key + {:expiration-date "never"}) + +(mf/defc input-copy* + {::mf/private true} + [{:keys [value on-copy-to-clipboard]}] + [:div {:class (stl/css :input-copy)} + [:> input* {:type "text" + :default-value value + :read-only true}] + [:div {:class (stl/css :input-copy-button-wrapper)} + [:> icon-button* {:variant "secondary" + :class (stl/css :input-copy-button) + :aria-label (tr "integrations.copy-to-clipboard") + :on-click on-copy-to-clipboard + :icon i/clipboard}]]]) + +(mf/defc token-created* + {::mf/private true} + [{:keys [title mcp-key?]}] + (let [token-created (mf/deref token-created-ref) + + on-copy-to-clipboard + (mf/use-fn + (mf/deps token-created) + (fn [event] + (dom/prevent-default event) + (clipboard/to-clipboard (:token token-created)) + (st/emit! (ntf/show {:level :info + :type :toast + :content (if mcp-key? + (tr "integrations.notification.success.mcp-key-copied") + (tr "integrations.notification.success.token-copied")) + :timeout notification-timeout}))))] + + [:div {:class (stl/css :modal-form)} + [:> text* {:as "h2" + :typography t/headline-large + :class (stl/css :color-primary)} + title] + + [:> notification-pill* {:level :info + :type :context} + [:> text* {:as "div" + :typography t/body-small + :class (stl/css :color-primary)} + (if mcp-key? + (tr "integrations.mcp-key.info.non-recuperable") + (tr "integrations.token.info.non-recuperable"))]] + + [:div {:class (stl/css :modal-content)} + [:> input-copy* {:value (:token token-created "") + :on-copy-to-clipboard on-copy-to-clipboard}] + + [:> text* {:as "div" + :typography t/body-small + :class (stl/css :color-secondary)} + (if (:expires-at token-created) + (if mcp-key? + (tr "integrations.mcp-key.will-expire" (ct/format-inst (:expires-at token-created) "PPP")) + (tr "integrations.token.will-expire" (ct/format-inst (:expires-at token-created) "PPP"))) + (if mcp-key? + (tr "integrations.mcp-key.will-not-expire") + (tr "integrations.token.will-not-expire")))]] + + (when mcp-key? + [:div {:class (stl/css :modal-content)} + [:> text* {:as "div" + :typography t/body-small + :class (stl/css :color-primary)} + (tr "integrations.info.mcp-client-config")] + [:textarea {:class (stl/css :textarea) + :wrap "off" + :rows 7 + :read-only true} + (dm/str + "{\n" + " \"mcpServers\": {\n" + " \"penpot\": {\n" + " \"url\": \"" cf/mcp-server-url "?userToken=" (:token token-created "") "\"\n" + " }\n" + " }" + "\n}")]]) + + [:div {:class (stl/css :modal-footer)} + [:> button* {:variant "secondary" + :on-click modal/hide!} + (tr "labels.close")]]])) + +(mf/defc create-token* + {::mf/private true} + [{:keys [title info mcp-key? on-created]}] + (let [form (fm/use-form + :initial (if mcp-key? + form-initial-data-mcp-key + form-initial-data-access-token) + :schema (if mcp-key? + schema:form-mcp-key + schema:form-access-token)) + + on-error + (mf/use-fn + #(st/emit! (ntf/error (tr "errors.generic")) + (modal/hide))) + + on-success + (mf/use-fn + #(st/emit! (du/fetch-access-tokens) + (ntf/success (tr "integrations.notification.success.created")) + (on-created))) + + on-submit + (mf/use-fn + (fn [form] + (let [cdata (:clean-data @form) + mdata {:on-success (partial on-success form) + :on-error (partial on-error form)} + expiration (:expiration-date cdata) + params (cond-> {:name (:name cdata) + :perms (:perms cdata)} + (not= "never" expiration) (assoc :expiration expiration) + (true? mcp-key?) (assoc :type "mcp" + :name "MCP key"))] + (st/emit! (du/create-access-token (with-meta params mdata))))))] + + [:> fc/form* {:form form + :class (stl/css :modal-form) + :on-submit on-submit} + + [:> text* {:as "h2" + :typography t/headline-large + :class (stl/css :color-primary)} + title] + + (when (some? info) + [:> notification-pill* {:level :info + :type :context} + [:> text* {:as "div" + :typography t/body-small + :class (stl/css :color-primary)} + info]]) + + (if mcp-key? + [:div {:class (stl/css :modal-content)} + [:> text* {:as "div" + :typography t/body-medium + :class (stl/css :color-secondary)} + (tr "integrations.info.mcp-server")]] + + [:div {:class (stl/css :modal-content)} + [:> fc/form-input* {:type "text" + :auto-focus? true + :form form + :name :name + :label (tr "integrations.name.label") + :placeholder (tr "integrations.name.placeholder")}]]) + + [:div {:class (stl/css :modal-content)} + [:> text* {:as "label" + :typography t/body-small + :for :expiration-date + :class (stl/css :color-primary)} + (tr "integrations.expiration-date.label")] + [:> fc/form-select* {:options [{:label (tr "integrations.expiration-never") :value "never" :id "never"} + {:label (tr "integrations.expiration-30-days") :value "720h" :id "720h"} + {:label (tr "integrations.expiration-60-days") :value "1440h" :id "1440h"} + {:label (tr "integrations.expiration-90-days") :value "2160h" :id "2160h"} + {:label (tr "integrations.expiration-180-days") :value "4320h" :id "4320h"}] + :default-selected "never" + :name :expiration-date}]] + + [:div {:class (stl/css :modal-footer)} + [:> button* {:variant "secondary" + :on-click modal/hide!} + (tr "labels.cancel")] + [:> fc/form-submit* {:variant "primary"} + title]]])) + +(mf/defc create-access-token-modal + {::mf/register modal/components + ::mf/register-as :create-access-token} + [] + (let [created? (mf/use-state false) + + on-close + (mf/use-fn + (fn [] + (reset! created? false) + (st/emit! (modal/hide)))) + + on-created + (mf/use-fn + #(reset! created? true))] + + [:div {:class (stl/css :modal-overlay)} + [:div {:class (stl/css :modal-container)} + [:div {:class (stl/css :modal-close-button)} + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.close") + :on-click on-close + :icon i/close}]] + + (if @created? + [:> token-created* {:title (tr "integrations.create-access-token.title.created")}] + [:> create-token* {:title (tr "integrations.create-access-token.title") + :on-created on-created}])]])) + +(mf/defc generate-mcp-key-modal + {::mf/register modal/components + ::mf/register-as :generate-mcp-key} + [] + (let [created? (mf/use-state false) + + on-close + (mf/use-fn + (fn [] + (reset! created? false) + (st/emit! (modal/hide)))) + + on-created + (mf/use-fn + (fn [] + (st/emit! (du/update-profile-props {:mcp-enabled true}) + (ev/event {::ev/name "generate-mcp-key" + ::ev/origin "integrations"}) + (ev/event {::ev/name "enable-mcp" + ::ev/origin "integrations" + :source "key-creation"}) + (mbc/event :mcp/enable {})) + (reset! created? true)))] + + [:div {:class (stl/css :modal-overlay)} + [:div {:class (stl/css :modal-container)} + [:div {:class (stl/css :modal-close-button)} + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.close") + :on-click on-close + :icon i/close}]] + + (if @created? + [:> token-created* {:title (tr "integrations.generate-mcp-key.title.created") + :mcp-key? true}] + [:> create-token* {:title (tr "integrations.generate-mcp-key.title") + :mcp-key? true + :on-created on-created}])]])) + +(mf/defc regenerate-mcp-key-modal + {::mf/register modal/components + ::mf/register-as :regenerate-mcp-key} + [] + (let [created? (mf/use-state false) + + tokens (mf/deref tokens-ref) + mcp-key (some #(when (= (:type %) "mcp") %) tokens) + mcp-key-id (:id mcp-key) + + on-close + (mf/use-fn + (fn [] + (reset! created? false) + (st/emit! (modal/hide)))) + + on-created + (mf/use-fn + (fn [] + (st/emit! (du/delete-access-token {:id mcp-key-id}) + (du/update-profile-props {:mcp-enabled true}) + (ev/event {::ev/name "regenerate-mcp-key" + ::ev/origin "integrations"}) + (mbc/event :mcp/enable {})) + (reset! created? true)))] + + [:div {:class (stl/css :modal-overlay)} + [:div {:class (stl/css :modal-container)} + [:div {:class (stl/css :modal-close-button)} + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.close") + :on-click on-close + :icon i/close}]] + + (if @created? + [:> token-created* {:title (tr "integrations.regenerate-mcp-key.title.created") + :mcp-key? true}] + [:> create-token* {:title (tr "integrations.regenerate-mcp-key.title") + :info (tr "integrations.regenerate-mcp-key.info") + :mcp-key? true + :on-created on-created}])]])) + +(mf/defc token-item* + {::mf/private true + ::mf/wrap [mf/memo]} + [{:keys [name expires-at on-delete]}] + (let [expires-txt (some-> expires-at (ct/format-inst "PPP")) + expired? (and (some? expires-at) (> (ct/now) expires-at)) + + menu-open* (mf/use-state false) + menu-open? (deref menu-open*) + + handle-menu-close + (mf/use-fn + #(reset! menu-open* false)) + + handle-menu-click + (mf/use-fn + #(reset! menu-open* (not menu-open?))) + + handle-open-confirm-modal + (mf/use-fn + (mf/deps on-delete) + (fn [] + (st/emit! (modal/show {:type :confirm + :title (tr "integrations.delete-token.title") + :message (tr "integrations.delete-token.message") + :accept-label (tr "integrations.delete-token.accept") + :on-accept on-delete})))) + + options + (mf/with-memo [on-delete] + [{:name (tr "labels.delete") + :id "token-delete" + :handler handle-open-confirm-modal}])] + + [:div {:class (stl/css :item)} + [:> text* {:as "div" + :typography t/body-medium + :title name + :class (stl/css :item-title)} + name] + + [:> text* {:as "div" + :typography t/body-small + :class (stl/css-case :item-subtitle true + :warning expired?)} + (cond + (nil? expires-at) (tr "integrations.no-expiration") + expired? (tr "integrations.expired-on" expires-txt) + :else (tr "integrations.expires-on" expires-txt))] + + [:div {:class (stl/css :item-actions)} + [:> icon-button* {:variant "ghost" + :class (stl/css :item-button) + :aria-pressed menu-open? + :aria-label (tr "labels.options") + :on-click handle-menu-click + :icon i/menu}] + [:> context-menu* {:on-close handle-menu-close + :show menu-open? + :min-width true + :top -10 + :left -138 + :options options}]]])) + +(mf/defc mcp-server-section* + {::mf/private true} + [] + (let [tokens (mf/deref tokens-ref) + profile (mf/deref refs/profile) + + mcp-key (some #(when (= (:type %) "mcp") %) tokens) + mcp-enabled? (true? (-> profile :props :mcp-enabled)) + + expires-at (:expires-at mcp-key) + expired? (and (some? expires-at) (> (ct/now) expires-at)) + + tooltip-id + (mf/use-id) + + handle-mcp-change + (mf/use-fn + (fn [value] + (st/emit! (du/update-profile-props {:mcp-enabled value}) + (ntf/show {:level :info + :type :toast + :content (if (true? value) + (tr "integrations.notification.success.mcp-server-enabled") + (tr "integrations.notification.success.mcp-server-disabled")) + :timeout notification-timeout}) + (ev/event {::ev/name (if (true? value) "enable-mcp" "disable-mcp") + ::ev/origin "integrations" + :source "toggle"}) + (if value + (mbc/event :mcp/enable {}) + (mbc/event :mcp/disable {}))))) + + handle-generate-mcp-key + (mf/use-fn + #(st/emit! (modal/show {:type :generate-mcp-key}))) + + handle-regenerate-mcp-key + (mf/use-fn + #(st/emit! (modal/show {:type :regenerate-mcp-key}))) + + handle-delete + (mf/use-fn + (mf/deps mcp-key) + (fn [] + (let [params {:id (:id mcp-key)} + mdata {:on-success #(st/emit! (du/fetch-access-tokens))}] + (st/emit! (du/delete-access-token (with-meta params mdata)) + (du/update-profile-props {:mcp-enabled false}) + (mbc/event :mcp/disable {}))))) + + on-copy-to-clipboard + (mf/use-fn + (fn [event] + (dom/prevent-default event) + (clipboard/to-clipboard cf/mcp-server-url) + (st/emit! (ntf/show {:level :info + :type :toast + :content (tr "integrations.notification.success.copied-link") + :timeout notification-timeout}) + (ev/event {::ev/name "copy-mcp-url" + ::ev/origin "integrations"}))))] + + [:section {:class (stl/css :mcp-server-section)} + [:div + [:div {:class (stl/css :title)} + [:> heading* {:level 2 + :typography t/title-medium + :class (stl/css :color-primary :mcp-server-title)} + (tr "integrations.mcp-server.title")] + [:> text* {:as "span" + :typography t/body-small + :class (stl/css :beta)} + (tr "integrations.mcp-server.title.beta")]] + + [:> text* {:as "div" + :typography t/body-medium + :class (stl/css :color-secondary)} + (tr "integrations.mcp-server.description")]] + + [:div + [:> text* {:as "h3" + :typography t/headline-small + :class (stl/css :color-primary)} + (tr "integrations.mcp-server.status")] + + [:div {:class (stl/css :mcp-server-block)} + (when expired? + [:> notification-pill* {:level :error + :type :context} + [:div {:class (stl/css :mcp-server-notification)} + [:> text* {:as "div" + :typography t/body-medium + :class (stl/css :color-primary)} + (tr "integrations.mcp-server.status.expired.0")] + + [:> text* {:as "div" + :typography t/body-medium + :class (stl/css :color-primary)} + (tr "integrations.mcp-server.status.expired.1")]]]) + + [:div {:class (stl/css :mcp-server-switch)} + [:> switch* {:label (if mcp-enabled? + (tr "integrations.mcp-server.status.enabled") + (tr "integrations.mcp-server.status.disabled")) + :default-checked mcp-enabled? + :on-change handle-mcp-change}] + (when (and (false? mcp-enabled?) (nil? mcp-key)) + [:div {:class (stl/css :mcp-server-switch-cover) + :on-click handle-generate-mcp-key}])]]] + + (when (some? mcp-key) + [:div {:class (stl/css :mcp-server-key)} + [:> text* {:as "h3" + :typography t/headline-small + :class (stl/css :color-primary)} + (tr "integrations.mcp-server.mcp-keys.title")] + + [:div {:class (stl/css :mcp-server-block)} + [:div {:class (stl/css :mcp-server-regenerate)} + [:> button* {:variant "primary" + :class (stl/css :fit-content) + :on-click handle-regenerate-mcp-key} + (tr "integrations.mcp-server.mcp-keys.regenerate")] + [:> tooltip* {:content (tr "integrations.mcp-server.mcp-keys.tootip") + :id tooltip-id} + [:> icon* {:icon-id i/info + :class (stl/css :color-secondary)}]]] + + [:div {:class (stl/css :list)} + [:> token-item* {:key (:id mcp-key) + :name (:name mcp-key) + :expires-at (:expires-at mcp-key) + :on-delete handle-delete}]]]]) + + [:> notification-pill* {:level :default + :type :context} + [:div {:class (stl/css :mcp-server-notification)} + [:> text* {:as "div" + :typography t/body-medium + :class (stl/css :color-secondary)} + (tr "integrations.mcp-server.mcp-keys.info")] + + [:> input-copy* {:value (dm/str cf/mcp-server-url "?userToken=") + :on-copy-to-clipboard on-copy-to-clipboard}] + + [:> text* {:as "div" + :typography t/body-medium + :class (stl/css :color-secondary)} + [:a {:href cf/mcp-help-center-uri + :target "_blank" + :rel "noopener noreferrer" + :class (stl/css :mcp-server-notification-link)} + (tr "integrations.mcp-server.mcp-keys.help") [:> icon* {:icon-id i/open-link}]]]]]])) + +(mf/defc access-tokens-section* + {::mf/private true} + [] + (let [tokens (mf/deref tokens-ref) + + handle-click + (mf/use-fn + #(st/emit! (modal/show {:type :create-access-token}))) + + handle-delete + (mf/use-fn + (fn [token-id] + (let [params {:id token-id} + mdata {:on-success #(st/emit! (du/fetch-access-tokens))}] + (st/emit! (du/delete-access-token (with-meta params mdata))))))] + + [:section {:class (stl/css :access-tokens-section)} + [:> heading* {:level 2 + :typography t/title-medium + :class (stl/css :color-primary)} + (tr "integrations.access-tokens.personal")] + + [:> text* {:as "div" + :typography t/body-medium + :class (stl/css :color-secondary)} + (tr "integrations.access-tokens.personal.description")] + + [:> button* {:variant "primary" + :class (stl/css :fit-content) + :on-click handle-click} + (tr "integrations.access-tokens.create")] + + (if (empty? tokens) + [:div {:class (stl/css :frame)} + [:> text* {:as "div" + :typography t/body-medium + :class (stl/css :color-secondary :text-center)} + [:div (tr "integrations.access-tokens.empty.no-access-tokens")] + [:div (tr "integrations.access-tokens.empty.add-one")]]] + + [:div {:class (stl/css :list)} + (for [token tokens] + (when (nil? (:type token)) + [:> token-item* {:key (:id token) + :name (:name token) + :expires-at (:expires-at token) + :on-delete (partial handle-delete (:id token))}]))])])) + +(mf/defc integrations-page* + [] + (mf/with-effect [] + (dom/set-html-title (tr "title.settings.integrations")) + (st/emit! (du/fetch-access-tokens))) + + [:div {:class (stl/css :integrations)} + [:> heading* {:level 1 + :typography t/title-large + :class (stl/css :color-primary)} + (tr "integrations.title")] + + (when (contains? cf/flags :mcp) + [:> mcp-server-section*]) + + (when (and (contains? cf/flags :mcp) + (contains? cf/flags :access-tokens)) + [:hr {:class (stl/css :separator)}]) + + (when (contains? cf/flags :access-tokens) + [:> access-tokens-section*])]) diff --git a/frontend/src/app/main/ui/settings/integrations.scss b/frontend/src/app/main/ui/settings/integrations.scss new file mode 100644 index 00000000000..d7be475bb46 --- /dev/null +++ b/frontend/src/app/main/ui/settings/integrations.scss @@ -0,0 +1,239 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) KALEIDOS INC + +@use "refactor/common-refactor.scss" as deprecated; + +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/mixins.scss" as *; +@use "ds/spacing.scss" as *; +@use "ds/typography.scss" as t; + +.color-primary { + color: var(--color-foreground-primary); +} + +.color-secondary { + color: var(--color-foreground-secondary); +} + +.text-center { + text-align: center; +} + +.fit-content { + inline-size: fit-content; +} + +.beta { + color: var(--color-accent-primary); + border: $b-1 solid var(--color-accent-primary); + inline-size: fit-content; + padding: var(--sp-xxs) var(--sp-s); + border-radius: $br-4; +} + +.title { + display: flex; + flex-direction: row; + align-items: baseline; + gap: var(--sp-s); +} + +.modal-overlay { + @extend .modal-overlay-base; +} + +.modal-container { + @extend .modal-container-base; + inline-size: $sz-400; + max-block-size: fit-content; + position: relative; +} + +.modal-content { + display: flex; + flex-direction: column; + gap: var(--sp-xs); +} + +.modal-form { + display: flex; + flex-direction: column; + gap: var(--sp-xxxl); +} + +.modal-close-button { + position: absolute; + top: var(--sp-s); + right: var(--sp-s); +} + +.modal-footer { + display: flex; + justify-content: right; + gap: var(--sp-s); +} + +.input-copy { + position: relative; +} + +.input-copy-button-wrapper { + position: absolute; + top: 0; + right: 0; + border-start-start-radius: 0; + border-end-start-radius: 0; +} + +.input-copy-button { + border-radius: 0 $br-8 $br-8 0; +} + +.integrations { + display: grid; + grid-template-rows: auto 1fr; + margin: $sz-88 auto $sz-120 auto; + gap: $sz-32; + inline-size: $sz-500; +} + +.access-tokens-section { + display: grid; + grid-template-rows: auto auto 1fr; + gap: var(--sp-m); +} + +.mcp-server-section { + display: flex; + flex-direction: column; + gap: var(--sp-l); +} + +.mcp-server-key { + display: flex; + flex-direction: column; +} + +.mcp-server-notification { + display: flex; + flex-direction: column; + gap: var(--sp-m); + padding-right: var(--sp-xxl); +} + +.mcp-server-notification-link { + cursor: pointer; + color: var(--color-accent-primary); + display: flex; + flex-direction: row; + align-items: center; + gap: var(--sp-xs); +} + +.mcp-server-title { + margin: var(--sp-s) 0; +} + +.mcp-server-block { + display: flex; + flex-direction: column; + gap: var(--sp-l); +} + +.mcp-server-regenerate { + display: flex; + align-items: center; + gap: var(--sp-s); +} + +.mcp-server-switch { + position: relative; +} + +.mcp-server-switch-cover { + position: absolute; + inset-block: 0; + inset-inline: 0; +} + +.separator { + border: $b-1 solid var(--color-background-quaternary); + margin: var(--sp-s) 0; +} + +.frame { + border: $b-1 solid var(--color-background-quaternary); + padding: var(--sp-m); + border-radius: $br-8; +} + +.list { + display: grid; + grid-auto-rows: $sz-64; + gap: var(--sp-m); +} + +.item { + display: grid; + grid-template-columns: 45% 1fr auto; + align-items: center; + background-color: var(--color-background-tertiary); + border-radius: $br-8; +} + +.item-title { + @include textEllipsis; + align-content: center; + block-size: $sz-64; + padding: 0 var(--sp-l); + color: var(--color-foreground-primary); +} + +.item-subtitle { + align-content: center; + block-size: $sz-64; + color: var(--color-foreground-secondary); + + &.warning { + padding: var(--sp-s) var(--sp-m); + block-size: fit-content; + inline-size: fit-content; + color: var(--color-foreground-primary); + background-color: var(--color-background-warning); + border: $b-1 solid var(--color-accent-warning); + border-radius: $br-8; + } +} + +.item-actions { + position: relative; +} + +.item-button { + block-size: $sz-64; + inline-size: $sz-48; + border-radius: 0 var(--sp-s) var(--sp-s) 0; +} + +.textarea { + @include t.use-typography("body-small"); + border-radius: $br-8; + background-color: var(--color-background-tertiary); + color: var(--color-foreground-secondary); + padding: var(--sp-xs) var(--sp-s); + border: 0; + resize: none; + + &:hover { + background-color: var(--color-background-quaternary); + } + + &:focus-visible { + outline: $b-1 solid var(--color-accent-primary); + } +} diff --git a/frontend/src/app/main/ui/settings/sidebar.cljs b/frontend/src/app/main/ui/settings/sidebar.cljs index 0808e2299dc..49ffcb6d19f 100644 --- a/frontend/src/app/main/ui/settings/sidebar.cljs +++ b/frontend/src/app/main/ui/settings/sidebar.cljs @@ -43,8 +43,8 @@ (def ^:private go-settings-subscription #(st/emit! (rt/nav :settings-subscription))) -(def ^:private go-settings-access-tokens - #(st/emit! (rt/nav :settings-access-tokens))) +(def ^:private go-settings-integrations + #(st/emit! (rt/nav :settings-integrations))) (def ^:private go-settings-notifications #(st/emit! (rt/nav :settings-notifications))) @@ -66,7 +66,7 @@ options? (= section :settings-options) feedback? (= section :settings-feedback) subscription? (= section :settings-subscription) - access-tokens? (= section :settings-access-tokens) + integrations? (= section :settings-integrations) notifications? (= section :settings-notifications) team-id (or (dtm/get-last-team-id) (:default-team-id profile)) @@ -115,12 +115,13 @@ :data-testid "settings-subscription"} [:span {:class (stl/css :element-title)} (tr "subscription.labels")]]) - (when (contains? cf/flags :access-tokens) - [:li {:class (stl/css-case :current access-tokens? + (when (or (contains? cf/flags :access-tokens) + (contains? cf/flags :mcp)) + [:li {:class (stl/css-case :current integrations? :settings-item true) - :on-click go-settings-access-tokens - :data-testid "settings-access-tokens"} - [:span {:class (stl/css :element-title)} (tr "labels.access-tokens")]]) + :on-click go-settings-integrations + :data-testid "settings-integrations"} + [:span {:class (stl/css :element-title)} (tr "labels.integrations")]]) [:hr {:class (stl/css :sidebar-separator)}] diff --git a/frontend/src/app/main/ui/workspace/left_header.cljs b/frontend/src/app/main/ui/workspace/left_header.cljs index 08f1488d9f9..09f34268a58 100644 --- a/frontend/src/app/main/ui/workspace/left_header.cljs +++ b/frontend/src/app/main/ui/workspace/left_header.cljs @@ -15,7 +15,6 @@ [app.main.refs :as refs] [app.main.router :as rt] [app.main.store :as st] - [app.main.ui.context :as ctx] [app.main.ui.icons :as deprecated-icon] [app.main.ui.workspace.main-menu :as main-menu] [app.util.dom :as dom] @@ -27,12 +26,10 @@ ;; --- Header Component (mf/defc left-header* - [{:keys [file layout project page-id class]}] - (let [profile (mf/deref refs/profile) - file-id (:id file) + [{:keys [file layout project class]}] + (let [file-id (:id file) file-name (:name file) project-id (:id project) - team-id (:team-id project) shared? (:is-shared file) persistence (mf/deref refs/persistence) @@ -40,8 +37,6 @@ persistence-status (get persistence :status) - read-only? (mf/use-ctx ctx/workspace-read-only?) - editing* (mf/use-state false) editing? (deref editing*) input-ref (mf/use-ref nil) @@ -137,10 +132,5 @@ (when ^boolean shared? [:span {:class (stl/css :shared-badge)} deprecated-icon/library]) [:div {:class (stl/css :menu-section)} - [:& main-menu/menu - {:layout layout - :file file - :profile profile - :read-only? read-only? - :team-id team-id - :page-id page-id}]]])) + [:> main-menu/menu* {:layout layout + :file file}]]])) diff --git a/frontend/src/app/main/ui/workspace/main_menu.cljs b/frontend/src/app/main/ui/workspace/main_menu.cljs index a964d274753..4d16c796464 100644 --- a/frontend/src/app/main/ui/workspace/main_menu.cljs +++ b/frontend/src/app/main/ui/workspace/main_menu.cljs @@ -10,6 +10,7 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.files.helpers :as cfh] + [app.common.time :as ct] [app.common.uuid :as uuid] [app.config :as cf] [app.main.data.common :as dcm] @@ -22,6 +23,7 @@ [app.main.data.shortcuts :as scd] [app.main.data.workspace :as dw] [app.main.data.workspace.libraries :as dwl] + [app.main.data.workspace.mcp :as mcp] [app.main.data.workspace.shortcuts :as sc] [app.main.data.workspace.undo :as dwu] [app.main.data.workspace.versions :as dwv] @@ -34,22 +36,31 @@ [app.main.ui.dashboard.subscription :refer [get-subscription-type main-menu-power-up*]] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] - [app.main.ui.ds.foundations.assets.icon :as i] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.main.ui.hooks.resize :as r] - [app.main.ui.icons :as deprecated-icon] [app.plugins.register :as preg] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [app.util.keyboard :as kbd] [beicon.v2.core :as rx] + [okulary.core :as l] [potok.v2.core :as ptk] [rumext.v2 :as mf])) -;; --- Header menu and submenus +(def tokens-ref + (l/derived :access-tokens st/state)) + +(mf/defc shortcuts* + {::mf/private true} + [{:keys [id]}] + [:span {:class (stl/css :shortcut)} + (for [sc (scd/split-sc (sc/get-tooltip id))] + [:span {:class (stl/css :shortcut-key) + :key sc} + sc])]) (mf/defc help-info-menu* - {::mf/props :obj - ::mf/private true + {::mf/private true ::mf/wrap [mf/memo]} [{:keys [layout on-close]}] (let [nav-to-helpc-center @@ -100,6 +111,9 @@ plugins? (features/active-feature? @st/state "plugins/runtime") + mcp? + (contains? cf/flags :mcp) + show-shortcuts (mf/use-fn (mf/deps layout) @@ -115,213 +129,206 @@ (mf/use-fn (fn [event] (let [version (:main cf/version)] - (st/emit! (ptk/event ::ev/event {::ev/name "show-release-notes" :version version})) + (st/emit! (ptk/event ::ev/event {::ev/name "show-release-notes" + :version version})) (println version) (if (and (kbd/alt? event) (kbd/mod? event)) (st/emit! (modal/show {:type :onboarding})) - (st/emit! (modal/show {:type :release-notes :version version}))))))] + (st/emit! (modal/show {:type :release-notes + :version version}))))))] [:> dropdown-menu* {:show true - ;; :id "workspace-help-menu" :on-close on-close - :class (stl/css-case :sub-menu true - :help-info plugins? - :help-info-old (not plugins?))} - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + :class (stl/css-case :base-menu true + :sub-menu true + :pos-final-5 (not (or plugins? mcp?)) + :pos-final-6 (not= plugins? mcp?) + :pos-final-7 (and plugins? mcp?))} + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click nav-to-helpc-center :on-key-down (fn [event] (when (kbd/enter? event) (nav-to-helpc-center event))) :id "file-menu-help-center"} - [:span {:class (stl/css :item-name)} (tr "labels.help-center")]] + [:span {:class (stl/css :item-name)} + (tr "labels.help-center")]] - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click nav-to-community :on-key-down (fn [event] (when (kbd/enter? event) (nav-to-community event))) :id "file-menu-community"} - [:span {:class (stl/css :item-name)} (tr "labels.community")]] + [:span {:class (stl/css :item-name)} + (tr "labels.community")]] - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click nav-to-youtube :on-key-down (fn [event] (when (kbd/enter? event) (nav-to-youtube event))) :id "file-menu-youtube"} - [:span {:class (stl/css :item-name)} (tr "labels.tutorials")]] + [:span {:class (stl/css :item-name)} + (tr "labels.tutorials")]] - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click show-release-notes :on-key-down (fn [event] (when (kbd/enter? event) (show-release-notes event))) :id "file-menu-release-notes"} - [:span {:class (stl/css :item-name)} (tr "labels.release-notes")]] + [:span {:class (stl/css :item-name)} + (tr "labels.release-notes")]] - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click nav-to-templates :on-key-down (fn [event] (when (kbd/enter? event) (nav-to-templates event))) :id "file-menu-templates"} - [:span {:class (stl/css :item-name)} (tr "labels.libraries-and-templates")]] + [:span {:class (stl/css :item-name)} + (tr "labels.libraries-and-templates")]] - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click nav-to-github :on-key-down (fn [event] (when (kbd/enter? event) (nav-to-github event))) :id "file-menu-github"} - [:span {:class (stl/css :item-name)} (tr "labels.github-repo")]] + [:span {:class (stl/css :item-name)} + (tr "labels.github-repo")]] - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click nav-to-terms :on-key-down (fn [event] (when (kbd/enter? event) (nav-to-terms event))) :id "file-menu-terms"} - [:span {:class (stl/css :item-name)} (tr "auth.terms-of-service")]] + [:span {:class (stl/css :item-name)} + (tr "auth.terms-of-service")]] - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click show-shortcuts :on-key-down (fn [event] (when (kbd/enter? event) (show-shortcuts event))) :id "file-menu-shortcuts"} - [:span {:class (stl/css :item-name)} (tr "label.shortcuts")] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :show-shortcuts))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]] + [:span {:class (stl/css :item-name)} + (tr "label.shortcuts")] + [:> shortcuts* {:id :show-shortcuts}]] (when (contains? cf/flags :user-feedback) - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click nav-to-feedback :on-key-down (fn [event] (when (kbd/enter? event) (nav-to-feedback event))) :id "file-menu-feedback"} - [:span {:class (stl/css-case :feedback true - :item-name true)} (tr "labels.give-feedback")]])])) + [:span {:class (stl/css :feedback :item-name)} + (tr "labels.give-feedback")]])])) (mf/defc preferences-menu* - {::mf/props :obj - ::mf/private true + {::mf/private true ::mf/wrap [mf/memo]} [{:keys [layout profile toggle-flag on-close toggle-theme]}] - (let [show-nudge-options (mf/use-fn #(modal/show! {:type :nudge-option}))] + (let [show-nudge-options + (mf/use-fn + #(modal/show! {:type :nudge-option}))] [:> dropdown-menu* {:show true - ;; :id "workspace-preferences-menu" - :class (stl/css-case :sub-menu true - :preferences true) + :class (stl/css :base-menu :sub-menu :pos-4) :on-close on-close} [:> dropdown-menu-item* {:on-click toggle-flag - :class (stl/css :submenu-item) + :class (stl/css :base-menu-item :submenu-item) :on-key-down (fn [event] (when (kbd/enter? event) (toggle-flag event))) - :data-testid "scale-text" + :data-testid "scale-text" :id "file-menu-scale-text"} [:span {:class (stl/css :item-name)} (if (contains? layout :scale-text) (tr "workspace.header.menu.disable-scale-content") (tr "workspace.header.menu.enable-scale-content"))] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :scale))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]] + [:> shortcuts* {:id :scale}]] [:> dropdown-menu-item* {:on-click toggle-flag - :class (stl/css :submenu-item) + :class (stl/css :base-menu-item :submenu-item) :on-key-down (fn [event] (when (kbd/enter? event) (toggle-flag event))) - :data-testid "snap-ruler-guides" + :data-testid "snap-ruler-guides" :id "file-menu-snap-ruler-guides"} [:span {:class (stl/css :item-name)} (if (contains? layout :snap-ruler-guides) (tr "workspace.header.menu.disable-snap-ruler-guides") (tr "workspace.header.menu.enable-snap-ruler-guides"))] - [:span {:class (stl/css :shortcut)} - - (for [sc (scd/split-sc (sc/get-tooltip :toggle-snap-ruler-guide))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]] + [:> shortcuts* {:id :toggle-snap-ruler-guide}]] [:> dropdown-menu-item* {:on-click toggle-flag - :class (stl/css :submenu-item) + :class (stl/css :base-menu-item :submenu-item) :on-key-down (fn [event] (when (kbd/enter? event) (toggle-flag event))) - :data-testid "snap-guides" + :data-testid "snap-guides" :id "file-menu-snap-guides"} [:span {:class (stl/css :item-name)} (if (contains? layout :snap-guides) (tr "workspace.header.menu.disable-snap-guides") (tr "workspace.header.menu.enable-snap-guides"))] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :toggle-snap-guides))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]] + [:> shortcuts* {:id :toggle-snap-guides}]] [:> dropdown-menu-item* {:on-click toggle-flag - :class (stl/css :submenu-item) + :class (stl/css :base-menu-item :submenu-item) :on-key-down (fn [event] (when (kbd/enter? event) (toggle-flag event))) - :data-testid "dynamic-alignment" + :data-testid "dynamic-alignment" :id "file-menu-dynamic-alignment"} [:span {:class (stl/css :item-name)} (if (contains? layout :dynamic-alignment) (tr "workspace.header.menu.disable-dynamic-alignment") (tr "workspace.header.menu.enable-dynamic-alignment"))] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :toggle-alignment))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]] + [:> shortcuts* {:id :toggle-alignment}]] [:> dropdown-menu-item* {:on-click toggle-flag - :class (stl/css :submenu-item) + :class (stl/css :base-menu-item :submenu-item) :on-key-down (fn [event] (when (kbd/enter? event) (toggle-flag event))) - :data-testid "snap-pixel-grid" + :data-testid "snap-pixel-grid" :id "file-menu-pixel-grid"} [:span {:class (stl/css :item-name)} (if (contains? layout :snap-pixel-grid) (tr "workspace.header.menu.disable-snap-pixel-grid") (tr "workspace.header.menu.enable-snap-pixel-grid"))] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :snap-pixel-grid))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]] + [:> shortcuts* {:id :snap-pixel-grid}]] [:> dropdown-menu-item* {:on-click show-nudge-options - :class (stl/css :submenu-item) + :class (stl/css :base-menu-item :submenu-item) :on-key-down (fn [event] (when (kbd/enter? event) (show-nudge-options event))) - :data-testid "snap-pixel-grid" + :data-testid "snap-pixel-grid" :id "file-menu-nudge"} [:span {:class (stl/css :item-name)} (tr "modals.nudge-title")]] - [:> dropdown-menu-item* {:on-click toggle-theme - :class (stl/css :submenu-item) + :class (stl/css :base-menu-item :submenu-item) :on-key-down (fn [event] (when (kbd/enter? event) (toggle-theme event))) - :data-testid "toggle-theme" + :data-testid "toggle-theme" :id "file-menu-toggle-theme"} [:span {:class (stl/css :item-name)} (case (:theme profile) ;; dark -> light -> system -> dark and so on "dark" (tr "workspace.header.menu.toggle-light-theme") - "light" (tr "workspace.header.menu.toggle-system-theme") + "light" (tr "workspace.header.menu.toggle-system-theme") "system" (tr "workspace.header.menu.toggle-dark-theme") (tr "workspace.header.menu.toggle-light-theme"))] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :toggle-theme))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]]])) + [:> shortcuts* {:id :toggle-theme}]]])) (mf/defc view-menu* - {::mf/props :obj - ::mf/private true + {::mf/private true ::mf/wrap [mf/memo]} [{:keys [layout toggle-flag on-close]}] (let [read-only? (mf/use-ctx ctx/workspace-read-only?) @@ -343,46 +350,40 @@ (vary-meta assoc ::ev/origin "workspace-menu")))))] [:> dropdown-menu* {:show true - ;; :id "workspace-view-menu" - :class (stl/css-case :sub-menu true - :view true) + :class (stl/css :base-menu :sub-menu :pos-3) :on-close on-close} - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click toggle-flag :on-key-down (fn [event] (when (kbd/enter? event) (toggle-flag event))) - :data-testid "rulers" + :data-testid "rulers" :id "file-menu-rulers"} [:span {:class (stl/css :item-name)} (if (contains? layout :rulers) (tr "workspace.header.menu.hide-rules") (tr "workspace.header.menu.show-rules"))] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :toggle-rulers))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]] + [:> shortcuts* {:id :toggle-rulers}]] - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click toggle-flag :on-key-down (fn [event] (when (kbd/enter? event) (toggle-flag event))) - :data-testid "display-guides" + :data-testid "display-guides" :id "file-menu-guides"} [:span {:class (stl/css :item-name)} (if (contains? layout :display-guides) (tr "workspace.header.menu.hide-guides") (tr "workspace.header.menu.show-guides"))] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :toggle-guides))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]] + [:> shortcuts* {:id :toggle-guides}]] (when-not ^boolean read-only? [:* - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click toggle-color-palette :on-key-down (fn [event] (when (kbd/enter? event) @@ -392,11 +393,9 @@ (if (contains? layout :colorpalette) (tr "workspace.header.menu.hide-palette") (tr "workspace.header.menu.show-palette"))] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :toggle-colorpalette))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]] + [:> shortcuts* {:id :toggle-colorpalette}]] - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click toggle-text-palette :on-key-down (fn [event] (when (kbd/enter? event) @@ -406,68 +405,68 @@ (if (contains? layout :textpalette) (tr "workspace.header.menu.hide-textpalette") (tr "workspace.header.menu.show-textpalette"))] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :toggle-textpalette))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]]]) + [:> shortcuts* {:id :toggle-textpalette}]]]) - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click toggle-flag :on-key-down (fn [event] (when (kbd/enter? event) (toggle-flag event))) - :data-testid "display-artboard-names" + :data-testid "display-artboard-names" :id "file-menu-artboards"} [:span {:class (stl/css :item-name)} (if (contains? layout :display-artboard-names) (tr "workspace.header.menu.hide-artboard-names") (tr "workspace.header.menu.show-artboard-names"))]] - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click toggle-flag :on-key-down (fn [event] (when (kbd/enter? event) (toggle-flag event))) - :data-testid "show-pixel-grid" + :data-testid "show-pixel-grid" :id "file-menu-pixel-grid"} [:span {:class (stl/css :item-name)} (if (contains? layout :show-pixel-grid) (tr "workspace.header.menu.hide-pixel-grid") (tr "workspace.header.menu.show-pixel-grid"))] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :show-pixel-grid))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]] + [:> shortcuts* {:id :show-pixel-grid}]] - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click toggle-flag :on-key-down (fn [event] (when (kbd/enter? event) (toggle-flag event))) - :data-testid "hide-ui" + :data-testid "hide-ui" :id "file-menu-hide-ui"} [:span {:class (stl/css :item-name)} (tr "workspace.shape.menu.hide-ui")] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :hide-ui))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]]])) + [:> shortcuts* {:id :hide-ui}]]])) (mf/defc edit-menu* - {::mf/props :obj - ::mf/private true + {::mf/private true ::mf/wrap [mf/memo]} [{:keys [on-close]}] - (let [select-all (mf/use-fn #(st/emit! (dw/select-all))) - undo (mf/use-fn #(st/emit! dwu/undo)) - redo (mf/use-fn #(st/emit! dwu/redo)) - perms (mf/use-ctx ctx/permissions) - can-edit (:can-edit perms)] + (let [perms (mf/use-ctx ctx/permissions) + can-edit (:can-edit perms) + + select-all + (mf/use-fn + #(st/emit! (dw/select-all))) + + undo + (mf/use-fn + #(st/emit! dwu/undo)) + + redo + (mf/use-fn + #(st/emit! dwu/redo))] [:> dropdown-menu* {:show true - ;; :id "workspace-edit-menu" - :class (stl/css-case :sub-menu true - :edit true) + :class (stl/css :base-menu :sub-menu :pos-2) :on-close on-close} - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click select-all :on-key-down (fn [event] (when (kbd/enter? event) @@ -475,45 +474,32 @@ :id "file-menu-select-all"} [:span {:class (stl/css :item-name)} (tr "workspace.header.menu.select-all")] - [:span {:class (stl/css :shortcut)} - - (for [sc (scd/split-sc (sc/get-tooltip :select-all))] - [:span {:class (stl/css :shortcut-key) - :key sc} - sc])]] + [:> shortcuts* {:id :select-all}]] (when can-edit - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click undo :on-key-down (fn [event] (when (kbd/enter? event) (undo event))) :id "file-menu-undo"} - [:span {:class (stl/css :item-name)} (tr "workspace.header.menu.undo")] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :undo))] - [:span {:class (stl/css :shortcut-key) - :key sc} - sc])]]) + [:span {:class (stl/css :item-name)} + (tr "workspace.header.menu.undo")] + [:> shortcuts* {:id :undo}]]) (when can-edit - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click redo :on-key-down (fn [event] (when (kbd/enter? event) (redo event))) :id "file-menu-redo"} - [:span {:class (stl/css :item-name)} (tr "workspace.header.menu.redo")] - [:span {:class (stl/css :shortcut)} - - (for [sc (scd/split-sc (sc/get-tooltip :redo))] - [:span {:class (stl/css :shortcut-key) - :key sc} - sc])]])])) + [:span {:class (stl/css :item-name)} + (tr "workspace.header.menu.redo")] + [:> shortcuts* {:id :redo}]])])) (mf/defc file-menu* - {::mf/props :obj - ::mf/private true} + {::mf/private true} [{:keys [on-close file]}] (let [file-id (:id file) shared? (:is-shared file) @@ -536,12 +522,11 @@ (fn [event] (dom/prevent-default event) (dom/stop-propagation event) - (modal/show! - {:type :delete-shared-libraries - :origin :unpublish - :ids #{file-id} - :on-accept #(st/emit! (dwl/set-file-shared file-id false)) - :count-libraries 1}))) + (modal/show! {:type :delete-shared-libraries + :origin :unpublish + :ids #{file-id} + :on-accept #(st/emit! (dwl/set-file-shared file-id false)) + :count-libraries 1}))) on-remove-shared-key-down (mf/use-fn @@ -590,7 +575,8 @@ (on-pin-version event)))) on-export-shapes - (mf/use-fn #(st/emit! (de/show-workspace-export-dialog {:origin "workspace:menu"}))) + (mf/use-fn + #(st/emit! (de/show-workspace-export-dialog {:origin "workspace:menu"}))) on-export-shapes-key-down (mf/use-fn @@ -627,14 +613,12 @@ (on-export-frames event))))] [:> dropdown-menu* {:show true - ;; :id "workspace-file-menu" - :class (stl/css-case :sub-menu true - :file true) + :class (stl/css :base-menu :sub-menu :pos-1) :on-close on-close} (if ^boolean shared? (when can-edit - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click on-remove-shared :on-key-down on-remove-shared-key-down :id "file-menu-remove-shared"} @@ -642,7 +626,7 @@ (tr "dashboard.unpublish-shared")]]) (when can-edit - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click on-add-shared :on-key-down on-add-shared-key-down :id "file-menu-add-shared"} @@ -653,35 +637,32 @@ [:* [:div {:class (stl/css :separator)}] - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click on-pin-version :on-key-down on-pin-version-key-down :id "file-menu-create-version"} [:span {:class (stl/css :item-name)} (tr "dashboard.create-version-menu")]] - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click on-show-version-history :on-key-down on-show-version-history-key-down :id "file-menu-show-version-history"} [:span {:class (stl/css :item-name)} (tr "dashboard.show-version-history")] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :toggle-history))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]] + [:> shortcuts* {:id :toggle-history}]] [:div {:class (stl/css :separator)}]]) - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click on-export-shapes :on-key-down on-export-shapes-key-down :id "file-menu-export-shapes"} - [:span {:class (stl/css :item-name)} (tr "dashboard.export-shapes")] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :export-shapes))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]] + [:span {:class (stl/css :item-name)} + (tr "dashboard.export-shapes")] + [:> shortcuts* {:id :export-shapes}]] - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click on-export-file :on-key-down on-export-file-key-down :data-format "binfile-v3" @@ -690,7 +671,7 @@ (tr "dashboard.download-binary-file")]] (when (seq frames) - [:> dropdown-menu-item* {:class (stl/css :submenu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click on-export-frames :on-key-down on-export-frames-key-down :id "file-menu-export-frames"} @@ -698,30 +679,26 @@ (tr "dashboard.export-frames")]])])) (mf/defc plugins-menu* - {::mf/props :obj - ::mf/private true + {::mf/private true ::mf/wrap [mf/memo]} [{:keys [open-plugins on-close]}] (when (features/active-feature? @st/state "plugins/runtime") - (let [plugins (preg/plugins-list) - user-can-edit? (:can-edit (deref refs/permissions)) - permissions-peek (deref refs/plugins-permissions-peek)] + (let [plugins (preg/plugins-list) + user-can-edit? (:can-edit (deref refs/permissions)) + permissions-peek (deref refs/plugins-permissions-peek)] [:> dropdown-menu* {:show true - ;; :id "workspace-plugins-menu" - :class (stl/css-case :sub-menu true :plugins true) + :class (stl/css :base-menu :sub-menu :pos-5 :plugins) :on-close on-close} [:> dropdown-menu-item* {:on-click open-plugins - :class (stl/css :submenu-item) + :class (stl/css :base-menu-item :submenu-item) :on-key-down (fn [event] (when (kbd/enter? event) (open-plugins event))) - :data-testid "open-plugins" + :data-testid "open-plugins" :id "file-menu-open-plugins"} [:span {:class (stl/css :item-name)} (tr "workspace.plugins.menu.plugins-manager")] - [:span {:class (stl/css :shortcut)} - (for [sc (scd/split-sc (sc/get-tooltip :plugins))] - [:span {:class (stl/css :shortcut-key) :key sc} sc])]] + [:> shortcuts* {:id :plugins}]] (when (d/not-empty? plugins) @@ -756,28 +733,103 @@ :name name :host host})) (dp/open-plugin! manifest user-can-edit?)))))] + [:> dropdown-menu-item* {:key (dm/str "plugins-menu-" idx) :on-click on-click - :class (stl/css-case :submenu-item true :menu-disabled (not can-open?)) + :class (stl/css-case :base-menu-item true + :submenu-item true + :disabled (not can-open?)) :on-key-down on-key-down} [:span {:class (stl/css :item-name)} name] (when-not can-open? - [:span {:class (stl/css :item-icon) - :title (tr "workspace.plugins.error.need-editor")} deprecated-icon/help])]))]))) + [:span {:title (tr "workspace.plugins.error.need-editor")} + [:> icon* {:icon-id i/help + :class (stl/css :item-icon)}]])]))]))) + +(mf/defc mcp-menu* + {::mf/private true} + [{:keys [on-close]}] + (let [plugins? (features/active-feature? @st/state "plugins/runtime") -(mf/defc menu - {::mf/props :obj} - [{:keys [layout file profile]}] - (let [show-menu* (mf/use-state false) - show-menu? (deref show-menu*) - sub-menu* (mf/use-state false) - sub-menu (deref sub-menu*) + profile (mf/deref refs/profile) + mcp (mf/deref refs/mcp) - open-menu + mcp-enabled? (true? (-> profile :props :mcp-enabled)) + mcp-connected? (= "connected" (get mcp :connection-status)) + + on-nav-to-integrations + (mf/use-fn + (fn [] + (st/emit! (ptk/event ::ev/event {::ev/name "manage-mpc-option" + ::ev/origin "workspace-menu"})) + (dom/open-new-window "/#/settings/integrations"))) + + on-nav-to-integrations-key-down + (mf/use-fn + (fn [event] + (when (kbd/enter? event) + (on-nav-to-integrations)))) + + on-toggle-mcp-plugin + (mf/use-fn + (fn [] + (if mcp-connected? + (st/emit! (mcp/user-disconnect-mcp) + (ptk/event ::ev/event {::ev/name "disconnect-mcp-plugin" + ::ev/origin "workspace-menu"})) + (st/emit! (mcp/connect-mcp) + (ptk/event ::ev/event {::ev/name "connect-mcp-plugin" + ::ev/origin "workspace-menu"}))))) + + on-toggle-mcp-plugin-key-down + (mf/use-fn + (fn [event] + (when (kbd/enter? event) + (on-toggle-mcp-plugin))))] + + [:> dropdown-menu* {:show true + :class (stl/css-case :base-menu true + :sub-menu true + :pos-5 (not plugins?) + :pos-6 plugins?) + :on-close on-close} + + (when mcp-enabled? + [:> dropdown-menu-item* {:id "mcp-menu-toggle-mcp-plugin" + :class (stl/css :base-menu-item :submenu-item) + :on-click on-toggle-mcp-plugin + :on-key-down on-toggle-mcp-plugin-key-down} + [:span {:class (stl/css :item-name)} + (if mcp-connected? + (tr "workspace.header.menu.mcp.plugin.status.disconnect") + (tr "workspace.header.menu.mcp.plugin.status.connect"))]]) + + [:> dropdown-menu-item* {:id "mcp-menu-nav-to-integrations" + :class (stl/css :base-menu-item :submenu-item) + :on-click on-nav-to-integrations + :on-key-down on-nav-to-integrations-key-down} + [:span {:class (stl/css :item-name)} + (if mcp-enabled? + (tr "workspace.header.menu.mcp.server.status.enabled") + (tr "workspace.header.menu.mcp.server.status.disabled"))]]])) + +(mf/defc menu* + [{:keys [layout file]}] + (let [profile (mf/deref refs/profile) + mcp (mf/deref refs/mcp) + + show-menu* (mf/use-state false) + show-menu? (deref show-menu*) + selected-sub-menu* (mf/use-state nil) + selected-sub-menu (deref selected-sub-menu*) + + toggle-menu (mf/use-fn (fn [event] (dom/stop-propagation event) - (reset! show-menu* true))) + (swap! show-menu* not) + (when (not show-menu?) + (reset! selected-sub-menu* nil)))) close-menu (mf/use-fn @@ -789,13 +841,13 @@ (mf/use-fn (fn [event] (dom/stop-propagation event) - (reset! sub-menu* nil))) + (reset! selected-sub-menu* nil))) close-all-menus (mf/use-fn (fn [] (reset! show-menu* false) - (reset! sub-menu* nil))) + (reset! selected-sub-menu* nil))) on-menu-click (mf/use-fn @@ -804,12 +856,13 @@ (let [menu (-> (dom/get-current-target event) (dom/get-data "testid") (keyword))] - (reset! sub-menu* menu)))) + (reset! selected-sub-menu* menu)))) on-power-up-click (mf/use-fn (fn [] - (st/emit! (ptk/event ::ev/event {::ev/name "explore-pricing-click" ::ev/origin "workspace-menu"})) + (st/emit! (ptk/event ::ev/event {::ev/name "explore-pricing-click" + ::ev/origin "workspace-menu"})) (dom/open-new-window "https://penpot.app/pricing"))) toggle-flag @@ -823,7 +876,7 @@ (-> (dw/toggle-layout-flag flag) (vary-meta assoc ::ev/origin "workspace-menu"))) (reset! show-menu* false) - (reset! sub-menu* nil)))) + (reset! selected-sub-menu* nil)))) toggle-theme (mf/use-fn @@ -836,9 +889,10 @@ (fn [event] (dom/stop-propagation event) (reset! show-menu* false) - (reset! sub-menu* nil) + (reset! selected-sub-menu* nil) (st/emit! - (ptk/event ::ev/event {::ev/name "open-plugins-manager" ::ev/origin "workspace:menu"}) + (ptk/event ::ev/event {::ev/name "open-plugins-manager" + ::ev/origin "workspace:menu"}) (modal/show :plugin-management {})))) subscription (:subscription (:props profile)) @@ -853,15 +907,16 @@ [:* [:> icon-button* {:variant "ghost" + :aria-pressed show-menu? :aria-label (tr "shortcut-subsection.main-menu") - :on-click open-menu + :on-click toggle-menu :icon i/menu}] [:> dropdown-menu* {:show show-menu? :id "workspace-menu" :on-close close-menu - :class (stl/css :menu)} - [:> dropdown-menu-item* {:class (stl/css :menu-item) + :class (stl/css :base-menu :menu)} + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :menu-item) :on-click on-menu-click :on-key-down (fn [event] (when (kbd/enter? event) @@ -869,111 +924,156 @@ :on-pointer-enter on-menu-click :data-testid "file" :id "file-menu-file"} - [:span {:class (stl/css :item-name)} (tr "workspace.header.menu.option.file")] - [:span {:class (stl/css :open-arrow)} deprecated-icon/arrow]] + [:span {:class (stl/css :item-name)} + (tr "workspace.header.menu.option.file")] + [:> icon* {:icon-id i/arrow-right + :class (stl/css :item-arrow)}]] - [:> dropdown-menu-item* {:class (stl/css :menu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :menu-item) :on-click on-menu-click :on-key-down (fn [event] (when (kbd/enter? event) (on-menu-click event))) :on-pointer-enter on-menu-click - :data-testid "edit" + :data-testid "edit" :id "file-menu-edit"} - [:span {:class (stl/css :item-name)} (tr "workspace.header.menu.option.edit")] - [:span {:class (stl/css :open-arrow)} deprecated-icon/arrow]] + [:span {:class (stl/css :item-name)} + (tr "workspace.header.menu.option.edit")] + [:> icon* {:icon-id i/arrow-right + :class (stl/css :item-arrow)}]] - [:> dropdown-menu-item* {:class (stl/css :menu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :menu-item) :on-click on-menu-click :on-key-down (fn [event] (when (kbd/enter? event) (on-menu-click event))) :on-pointer-enter on-menu-click - :data-testid "view" + :data-testid "view" :id "file-menu-view"} - [:span {:class (stl/css :item-name)} (tr "workspace.header.menu.option.view")] - [:span {:class (stl/css :open-arrow)} deprecated-icon/arrow]] + [:span {:class (stl/css :item-name)} + (tr "workspace.header.menu.option.view")] + [:> icon* {:icon-id i/arrow-right + :class (stl/css :item-arrow)}]] - [:> dropdown-menu-item* {:class (stl/css :menu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :menu-item) :on-click on-menu-click :on-key-down (fn [event] (when (kbd/enter? event) (on-menu-click event))) :on-pointer-enter on-menu-click - :data-testid "preferences" + :data-testid "preferences" :id "file-menu-preferences"} - [:span {:class (stl/css :item-name)} (tr "workspace.header.menu.option.preferences")] - [:span {:class (stl/css :open-arrow)} deprecated-icon/arrow]] + [:span {:class (stl/css :item-name)} + (tr "workspace.header.menu.option.preferences")] + [:> icon* {:icon-id i/arrow-right + :class (stl/css :item-arrow)}]] (when (features/active-feature? @st/state "plugins/runtime") - [:> dropdown-menu-item* {:class (stl/css :menu-item) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :menu-item) :on-click on-menu-click :on-key-down (fn [event] (when (kbd/enter? event) (on-menu-click event))) :on-pointer-enter on-menu-click - :data-testid "plugins" + :data-testid "plugins" :id "file-menu-plugins"} - [:span {:class (stl/css :item-name)} (tr "workspace.plugins.menu.title")] - [:span {:class (stl/css :open-arrow)} deprecated-icon/arrow]]) + [:span {:class (stl/css :item-name)} + (tr "workspace.plugins.menu.title")] + [:> icon* {:icon-id i/arrow-right + :class (stl/css :item-arrow)}]]) + + (when (contains? cf/flags :mcp) + (let [tokens (mf/deref tokens-ref) + expired? (some->> tokens + (some #(when (= (:type %) "mcp") %)) + :expires-at + (> (ct/now))) + + mcp-enabled? (true? (-> profile :props :mcp-enabled)) + mcp-connection (get mcp :connection-status) + mcp-connected? (= mcp-connection "connected") + mcp-error? (= mcp-connection "error") + + active? (and mcp-enabled? mcp-connected?) + failed? (or (and mcp-enabled? mcp-error?) + (true? expired?))] + + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :menu-item) + :on-click on-menu-click + :on-key-down (fn [event] + (when (kbd/enter? event) + (on-menu-click event))) + :on-pointer-enter on-menu-click + :data-testid "mcp" + :id "file-menu-mcp"} + [:span {:class (stl/css :item-name)} + (tr "workspace.header.menu.option.mcp")] + [:span {:class (stl/css-case :item-indicator true + :active active? + :failed failed?)}] + [:> icon* {:icon-id i/arrow-right + :class (stl/css :item-arrow)}]])) [:div {:class (stl/css :separator)}] - [:> dropdown-menu-item* {:class (stl/css-case :menu-item true) + + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :menu-item) :on-click on-menu-click :on-key-down (fn [event] (when (kbd/enter? event) (on-menu-click event))) :on-pointer-enter on-menu-click - :data-testid "help-info" + :data-testid "help-info" :id "file-menu-help-info"} - [:span {:class (stl/css :item-name)} (tr "workspace.header.menu.option.help-info")] - [:span {:class (stl/css :open-arrow)} deprecated-icon/arrow]] + [:span {:class (stl/css :item-name)} + (tr "workspace.header.menu.option.help-info")] + [:> icon* {:icon-id i/arrow-right + :class (stl/css :item-arrow)}]] - (when (and (contains? cf/flags :subscriptions) (not= "enterprise" subscription-type)) + (when (and (contains? cf/flags :subscriptions) + (not= "enterprise" subscription-type)) [:> main-menu-power-up* {:close-sub-menu close-sub-menu}]) ;; TODO remove this block when subscriptions is full implemented (when (contains? cf/flags :subscriptions-old) - [:> dropdown-menu-item* {:class (stl/css-case :menu-item true) + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :menu-item) :on-click on-power-up-click :on-key-down (fn [event] (when (kbd/enter? event) (on-power-up-click))) :on-pointer-enter close-sub-menu :id "file-menu-power-up"} - [:span {:class (stl/css :item-name)} (tr "subscription.workspace.header.menu.option.power-up")]])] + [:span {:class (stl/css :item-name)} + (tr "subscription.workspace.header.menu.option.power-up")]])] - (case sub-menu + (case selected-sub-menu :file [:> file-menu* {:file file :on-close close-sub-menu}] :edit - [:> edit-menu* - {:on-close close-sub-menu}] + [:> edit-menu* {:on-close close-sub-menu}] :view - [:> view-menu* - {:layout layout - :toggle-flag toggle-flag - :on-close close-sub-menu}] + [:> view-menu* {:layout layout + :toggle-flag toggle-flag + :on-close close-sub-menu}] :preferences - [:> preferences-menu* - {:layout layout - :profile profile - :toggle-flag toggle-flag - :toggle-theme toggle-theme - :on-close close-sub-menu}] + [:> preferences-menu* {:layout layout + :profile profile + :toggle-flag toggle-flag + :toggle-theme toggle-theme + :on-close close-sub-menu}] :plugins - [:> plugins-menu* - {:open-plugins open-plugins-manager - :on-close close-sub-menu}] + [:> plugins-menu* {:open-plugins open-plugins-manager + :on-close close-sub-menu}] + + :mcp + [:> mcp-menu* {:on-close close-sub-menu}] :help-info - [:> help-info-menu* - {:layout layout - :on-close close-sub-menu}] + [:> help-info-menu* {:layout layout + :on-close close-sub-menu}] nil)])) diff --git a/frontend/src/app/main/ui/workspace/main_menu.scss b/frontend/src/app/main/ui/workspace/main_menu.scss index 7deccc70ed3..1b12e2cdbf4 100644 --- a/frontend/src/app/main/ui/workspace/main_menu.scss +++ b/frontend/src/app/main/ui/workspace/main_menu.scss @@ -4,125 +4,178 @@ // // Copyright (c) KALEIDOS INC -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/typography.scss" as t; +@use "ds/z-index.scss" as *; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; + +.base-menu { + position: absolute; + display: flex; + flex-direction: column; + gap: var(--sp-xs); + padding: var(--sp-xs); + border-radius: $br-8; + z-index: var(--z-index-dropdown); + background-color: var(--menu-background-color); + border: $b-2 solid var(--panel-border-color); + box-shadow: 0 0 $sz-12 0 var(--menu-shadow-color); +} .menu { - @extend .menu-dropdown; - top: deprecated.$s-48; - left: calc(var(--right-sidebar-width, deprecated.$s-256) - deprecated.$s-16); - width: deprecated.$s-192; - margin: 0; + top: $sz-48; + left: calc(var(--right-sidebar-width) - $sz-40); + inline-size: $sz-192; } -.menu-item { - @extend .menu-item-base; - cursor: pointer; - - .open-arrow { - @include deprecated.flexCenter; +.sub-menu { + left: calc(var(--right-sidebar-width) + $sz-154); + min-width: $sz-284; + width: 115%; - svg { - @extend .button-icon; - stroke: var(--icon-foreground); - } + &.pos-1 { + top: calc($sz-16 + $sz-32); } - &:hover { - color: var(--menu-foreground-color-hover); - - .open-arrow { - svg { - stroke: var(--menu-foreground-color-hover); - } - } - - .shortcut-key { - color: var(--menu-shortcut-foreground-color-hover); - } + &.pos-2 { + top: calc($sz-16 + (2 * $sz-32)); } -} - -.separator { - border-top: deprecated.$s-1 solid var(--color-background-quaternary); - height: deprecated.$s-4; - left: calc(-1 * deprecated.$s-4); - margin-top: deprecated.$s-8; - position: relative; - width: calc(100% + deprecated.$s-8); -} -.shortcut { - @extend .shortcut-base; -} - -.shortcut-key { - @extend .shortcut-key-base; -} - -.sub-menu { - @extend .menu-dropdown; - left: calc(var(--right-sidebar-width, deprecated.$s-256) + deprecated.$s-180); - width: deprecated.$s-192; - min-width: calc(deprecated.$s-272 - deprecated.$s-2); - width: 110%; - - .submenu-item { - @extend .menu-item-base; - - &:hover { - color: var(--menu-foreground-color-hover); - - .shortcut-key { - color: var(--menu-shortcut-foreground-color-hover); - } - } + &.pos-3 { + top: calc($sz-16 + (3 * $sz-32)); } - .menu-disabled { - color: var(--color-foreground-secondary); + &.pos-4 { + top: calc($sz-16 + (4 * $sz-32)); + } - &:hover { - cursor: default; - color: var(--color-foreground-secondary); - background-color: var(--menu-background-color); - } + &.pos-5 { + top: calc($sz-16 + (5 * $sz-32)); } - &.file { - top: deprecated.$s-48; + &.pos-6 { + top: calc($sz-16 + (6 * $sz-32)); } - &.edit { - top: deprecated.$s-76; + &.pos-final-5 { + top: calc($sz-32 + (5 * $sz-32)); } - &.view { - top: deprecated.$s-116; + &.pos-final-6 { + top: calc($sz-32 + (6 * $sz-32)); } - &.preferences { - top: deprecated.$s-148; + &.pos-final-7 { + top: calc($sz-32 + (7 * $sz-32)); } &.plugins { - top: deprecated.$s-180; - max-height: calc(100vh - deprecated.$s-180); + max-height: calc(100vh - $sz-200); overflow-x: hidden; overflow-y: auto; } +} - &.help-info { - top: deprecated.$s-232; +.base-menu-item { + @include t.use-typography("body-small"); + display: grid; + align-items: center; + grid-template-columns: auto $sz-16 $sz-16; + grid-template-areas: "name indicator arrow"; + block-size: $sz-28; + inline-size: 100%; + padding: $sz-6; + border-radius: $br-8; + color: var(--menu-foreground-color); + background-color: var(--menu-background-color); + + &:hover { + --menu-foreground-color: var(--menu-foreground-color-hover); + --menu-background-color: var(--menu-background-color-hover); + --menu-shortcut-foreground-color: var(--menu-shortcut-foreground-color-hover); + --menu-icon-foreground-color: var(--menu-foreground-color-hover); } - &.help-info-old { - top: deprecated.$s-192; + &.disabled { + --menu-foreground-color: var(--color-foreground-secondary); + pointer-events: none; } } -.item-icon { - svg { - @extend .button-icon; - stroke: var(--icon-foreground); +.menu-item { + display: grid; + align-items: center; + grid-template-columns: auto $sz-16 $sz-16; + grid-template-areas: "name indicator arrow"; +} + +.submenu-item { + display: flex; + align-items: center; + justify-content: space-between; +} + +.item-name { + grid-area: name; +} + +.item-indicator { + --menu-indicator-color: var(--color-foreground-secondary); + grid-area: indicator; + display: flex; + align-items: center; + justify-content: center; + inline-size: px2rem(8); + block-size: px2rem(8); + border-radius: $br-circle; + background-color: var(--menu-indicator-color); + + &.active { + --menu-indicator-color: var(--color-accent-primary); + } + + &.failed { + --menu-indicator-color: var(--color-foreground-error); } } + +.item-arrow { + grid-area: arrow; + color: var(--menu-icon-foreground-color); +} + +.item-icon { + color: var(--menu-icon-foreground-color); + display: flex; + align-items: center; + justify-content: center; +} + +.separator { + position: relative; + block-size: var(--sp-xs); + inline-size: calc(100% + var(--sp-s)); + border-top: $b-1 solid var(--color-background-quaternary); + left: calc(-1 * var(--sp-xs)); + margin-top: var(--sp-s); +} + +.shortcut { + display: flex; + align-items: center; + justify-content: center; + gap: var(--sp-xxs); + color: var(--menu-shortcut-foreground-color); +} + +.shortcut-key { + @include t.use-typography("body-small"); + display: flex; + align-items: center; + justify-content: center; + height: px2rem(20); + padding: var(--sp-xxs) px2rem(6); + border-radius: $br-6; + background-color: var(--menu-shortcut-background-color); +} diff --git a/frontend/src/app/main/ui/workspace/sidebar.cljs b/frontend/src/app/main/ui/workspace/sidebar.cljs index f7278fd650c..ac219faa2f4 100644 --- a/frontend/src/app/main/ui/workspace/sidebar.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar.cljs @@ -119,7 +119,7 @@ (mf/defc left-sidebar* {::mf/memo true} - [{:keys [layout file page-id tokens-lib active-tokens resolved-active-tokens]}] + [{:keys [layout file tokens-lib active-tokens resolved-active-tokens]}] (let [options-mode (mf/deref refs/options-mode-global) project (mf/deref refs/project) file-id (get file :id) @@ -185,12 +185,10 @@ :class aside-class :style {:--left-sidebar-width (dm/str width "px")}} - [:> left-header* - {:file file - :layout layout - :project project - :page-id page-id - :class (stl/css :left-header)}] + [:> left-header* {:file file + :layout layout + :project project + :class (stl/css :left-header)}] [:div {:on-pointer-down on-pointer-down :on-lost-pointer-capture on-lost-pointer-capture diff --git a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs index c870baf9fb4..8efa0ba66c9 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs @@ -291,7 +291,7 @@ :r4 "Bottom Left" :r3 "Bottom Right"} :hint (tr "workspace.tokens.radius") - :on-update-shape-all dwta/update-shape-radius-all + :on-update-shape-all dwta/update-shape-radius :on-update-shape update-shape-radius-for-corners}) shadow (partial generic-attribute-actions #{:shadow} "Shadow")] {:border-radius border-radius diff --git a/frontend/src/app/plugins.cljs b/frontend/src/app/plugins.cljs index 36f31ff1101..3ce022753e1 100644 --- a/frontend/src/app/plugins.cljs +++ b/frontend/src/app/plugins.cljs @@ -8,6 +8,7 @@ "RPC for plugins runtime." (:require ["@penpot/plugins-runtime" :as runtime] + [app.main.errors :as errors] [app.main.features :as features] [app.main.store :as st] [app.plugins.api :as api] @@ -30,6 +31,8 @@ (ptk/reify ::initialize ptk/WatchEvent (watch [_ _ stream] + (set! errors/is-plugin-error? runtime/isPluginError) + (->> stream (rx/filter (ptk/type? ::features/initialize)) (rx/observe-on :async) diff --git a/frontend/src/app/plugins/api.cljs b/frontend/src/app/plugins/api.cljs index febb24e4bae..68526ae8a4c 100644 --- a/frontend/src/app/plugins/api.cljs +++ b/frontend/src/app/plugins/api.cljs @@ -14,9 +14,11 @@ [app.common.geom.point :as gpt] [app.common.schema :as sm] [app.common.types.color :as ctc] + [app.common.types.component :as ctk] [app.common.types.shape :as cts] [app.common.types.text :as txt] [app.common.uuid :as uuid] + [app.config :as cf] [app.main.data.changes :as ch] [app.main.data.common :as dcm] [app.main.data.helpers :as dsh] @@ -26,6 +28,7 @@ [app.main.data.workspace.groups :as dwg] [app.main.data.workspace.media :as dwm] [app.main.data.workspace.selection :as dws] + [app.main.data.workspace.variants :as dwv] [app.main.data.workspace.wasm-text :as dwwt] [app.main.features :as features] [app.main.fonts :refer [fetch-font-css]] @@ -82,6 +85,10 @@ :$plugin {:enumerable false :get (fn [] plugin-id)} ;; Public properties + :version + {:this true + :get (constantly (:base cf/version))} + :root {:this true :get #(.getRoot ^js %)} @@ -110,7 +117,7 @@ (fn [_ shapes] (cond (or (not (array? shapes)) (not (every? shape/shape-proxy? shapes))) - (u/display-not-valid :selection shapes) + (u/not-valid plugin-id :selection shapes) :else (let [ids (into (d/ordered-set) (map #(obj/get % "$id")) shapes)] @@ -175,7 +182,7 @@ (fn [shapes] (cond (or (not (array? shapes)) (not (every? shape/shape-proxy? shapes))) - (u/display-not-valid :shapesColors-shapes shapes) + (u/not-valid plugin-id :shapesColors-shapes shapes) :else (let [objects (u/locate-objects) @@ -195,13 +202,13 @@ new-color (parser/parse-color-data new-color)] (cond (or (not (array? shapes)) (not (every? shape/shape-proxy? shapes))) - (u/display-not-valid :replaceColor-shapes shapes) + (u/not-valid plugin-id :replaceColor-shapes shapes) (not (sm/validate ctc/schema:color old-color)) - (u/display-not-valid :replaceColor-oldColor old-color) + (u/not-valid plugin-id :replaceColor-oldColor old-color) (not (sm/validate ctc/schema:color new-color)) - (u/display-not-valid :replaceColor-newColor new-color) + (u/not-valid plugin-id :replaceColor-newColor new-color) :else (let [file-id (:current-file-id @st/state) @@ -254,10 +261,10 @@ (fn [name url] (cond (not (string? name)) - (u/display-not-valid :uploadMedia-name name) + (u/not-valid plugin-id :uploadMedia-name name) (not (string? url)) - (u/display-not-valid :uploadMedia-url url) + (u/not-valid plugin-id :uploadMedia-url url) :else (let [file-id (:current-file-id @st/state)] @@ -288,7 +295,7 @@ (fn [shapes] (cond (or (not (array? shapes)) (not (every? shape/shape-proxy? shapes))) - (u/display-not-valid :group-shapes shapes) + (u/not-valid plugin-id :group-shapes shapes) :else (let [file-id (:current-file-id @st/state) @@ -303,10 +310,10 @@ (fn [group & rest] (cond (not (shape/shape-proxy? group)) - (u/display-not-valid :ungroup group) + (u/not-valid plugin-id :ungroup group) (and (some? rest) (not (every? shape/shape-proxy? rest))) - (u/display-not-valid :ungroup rest) + (u/not-valid plugin-id :ungroup rest) :else (let [shapes (concat [group] rest) @@ -346,7 +353,7 @@ (fn [text] (cond (or (not (string? text)) (empty? text)) - (u/display-not-valid :createText text) + (u/not-valid plugin-id :createText text) :else (let [page (dsh/lookup-page @st/state) @@ -377,7 +384,7 @@ (fn [svg-string] (cond (or (not (string? svg-string)) (empty? svg-string)) - (u/display-not-valid :createShapeFromSvg svg-string) + (u/not-valid plugin-id :createShapeFromSvg svg-string) :else (let [id (uuid/next) @@ -394,7 +401,7 @@ (cond (or (not (string? svg-string)) (empty? svg-string)) (do - (u/display-not-valid :createShapeFromSvg "Svg not valid") + (u/not-valid plugin-id :createShapeFromSvg "Svg not valid") (reject "Svg not valid")) :else @@ -412,10 +419,10 @@ (let [bool-type (keyword bool-type)] (cond (not (contains? cts/bool-types bool-type)) - (u/display-not-valid :createBoolean-boolType bool-type) + (u/not-valid plugin-id :createBoolean-boolType bool-type) (or (not (array? shapes)) (empty? shapes) (not (every? shape/shape-proxy? shapes))) - (u/display-not-valid :createBoolean-shapes shapes) + (u/not-valid plugin-id :createBoolean-shapes shapes) :else (let [ids (into #{} (map #(obj/get % "$id")) shapes) @@ -429,10 +436,10 @@ (let [type (d/nilv (obj/get options "type") "html")] (cond (or (not (array? shapes)) (not (every? shape/shape-proxy? shapes))) - (u/display-not-valid :generateMarkup-shapes shapes) + (u/not-valid plugin-id :generateMarkup-shapes shapes) (and (some? type) (not (contains? #{"html" "svg"} type))) - (u/display-not-valid :generateMarkup-type type) + (u/not-valid plugin-id :generateMarkup-type type) :else (let [resolved-code @@ -464,16 +471,16 @@ children? (d/nilv (obj/get options "includeChildren") true)] (cond (or (not (array? shapes)) (not (every? shape/shape-proxy? shapes))) - (u/display-not-valid :generateStyle-shapes shapes) + (u/not-valid plugin-id :generateStyle-shapes shapes) (and (some? type) (not (contains? #{"css"} type))) - (u/display-not-valid :generateStyle-type type) + (u/not-valid plugin-id :generateStyle-type type) (and (some? prelude?) (not (boolean? prelude?))) - (u/display-not-valid :generateStyle-withPrelude prelude?) + (u/not-valid plugin-id :generateStyle-withPrelude prelude?) (and (some? children?) (not (boolean? children?))) - (u/display-not-valid :generateStyle-includeChildren children?) + (u/not-valid plugin-id :generateStyle-includeChildren children?) :else (let [resolved-styles @@ -546,7 +553,7 @@ :else nil) new-window (if (boolean? new-window) new-window false)] (if (nil? id) - (u/display-not-valid :openPage "Expected a Page object or a page UUID string") + (u/not-valid plugin-id :openPage "Expected a Page object or a page UUID string") (st/emit! (dcm/go-to-workspace :page-id id ::rt/new-window new-window))))) :alignHorizontal @@ -558,10 +565,10 @@ nil)] (cond (nil? dir) - (u/display-not-valid :alignHorizontal-direction "Direction not valid") + (u/not-valid plugin-id :alignHorizontal-direction "Direction not valid") (or (not (array? shapes)) (not (every? shape/shape-proxy? shapes))) - (u/display-not-valid :alignHorizontal-shapes "Not valid shapes") + (u/not-valid plugin-id :alignHorizontal-shapes "Not valid shapes") :else (let [ids (into #{} (map #(obj/get % "$id")) shapes)] @@ -576,10 +583,10 @@ nil)] (cond (nil? dir) - (u/display-not-valid :alignVertical-direction "Direction not valid") + (u/not-valid plugin-id :alignVertical-direction "Direction not valid") (or (not (array? shapes)) (not (every? shape/shape-proxy? shapes))) - (u/display-not-valid :alignVertical-shapes "Not valid shapes") + (u/not-valid plugin-id :alignVertical-shapes "Not valid shapes") :else (let [ids (into #{} (map #(obj/get % "$id")) shapes)] @@ -589,7 +596,7 @@ (fn [shapes] (cond (or (not (array? shapes)) (not (every? shape/shape-proxy? shapes))) - (u/display-not-valid :distributeHorizontal-shapes "Not valid shapes") + (u/not-valid plugin-id :distributeHorizontal-shapes "Not valid shapes") :else (let [ids (into #{} (map #(obj/get % "$id")) shapes)] @@ -599,7 +606,7 @@ (fn [shapes] (cond (or (not (array? shapes)) (not (every? shape/shape-proxy? shapes))) - (u/display-not-valid :distributeVertical-shapes "Not valid shapes") + (u/not-valid plugin-id :distributeVertical-shapes "Not valid shapes") :else (let [ids (into #{} (map #(obj/get % "$id")) shapes)] @@ -609,8 +616,41 @@ (fn [shapes] (cond (or (not (array? shapes)) (not (every? shape/shape-proxy? shapes))) - (u/display-not-valid :flatten-shapes "Not valid shapes") + (u/not-valid plugin-id :flatten-shapes "Not valid shapes") :else (let [ids (into #{} (map #(obj/get % "$id")) shapes)] - (st/emit! (dw/convert-selected-to-path ids))))))) + (st/emit! (dw/convert-selected-to-path ids))))) + + :createVariantFromComponents + (fn [shapes] + (cond + (or (not (seq shapes)) + (not (every? u/is-main-component-proxy? shapes))) + (u/not-valid plugin-id :shapes shapes) + + :else + (let [file-id (obj/get (first shapes) "$file") + page-id (obj/get (first shapes) "$page") + ids (->> shapes + (map #(obj/get % "$id")) + (into #{})) + + ;; Check that every component is: + ;; - in the same page + ;; - not already a variant + valid? + (every? + (fn [id] + (let [shape (u/locate-shape file-id page-id id) + component (u/locate-library-component file-id (:component-id shape))] + (not (ctk/is-variant? component)))) + ids)] + (if valid? + (let [variant-id (uuid/next)] + (st/emit! (dwv/combine-as-variants + ids + {:trigger "plugin:combine-as-variants" :variant-id variant-id})) + (shape/shape-proxy plugin-id variant-id)) + + (u/not-valid plugin-id :shapes "One of the components is not on the same page or is already a variant"))))))) diff --git a/frontend/src/app/plugins/comments.cljs b/frontend/src/app/plugins/comments.cljs index f3cfdcf9549..236074142d3 100644 --- a/frontend/src/app/plugins/comments.cljs +++ b/frontend/src/app/plugins/comments.cljs @@ -60,13 +60,13 @@ (let [profile (:profile @st/state)] (cond (or (not (string? content)) (empty? content)) - (u/display-not-valid :content "Not valid") + (u/not-valid plugin-id :content "Not valid") (not= (:id profile) (:owner-id data)) - (u/display-not-valid :content "Cannot change content from another user's comments") + (u/not-valid plugin-id :content "Cannot change content from another user's comments") (not (r/check-permission plugin-id "comment:write")) - (u/display-not-valid :content "Plugin doesn't have 'comment:write' permission") + (u/not-valid plugin-id :content "Plugin doesn't have 'comment:write' permission") :else (->> (rp/cmd! :update-comment {:id (:id data) :content content}) @@ -81,7 +81,7 @@ (cond (not (r/check-permission plugin-id "comment:write")) (do - (u/display-not-valid :remove "Plugin doesn't have 'comment:write' permission") + (u/not-valid plugin-id :remove "Plugin doesn't have 'comment:write' permission") (reject "Plugin doesn't have 'comment:write' permission")) :else @@ -120,10 +120,10 @@ (cond (or (not (sm/valid-safe-number? (:x position))) (not (sm/valid-safe-number? (:y position)))) - (u/display-not-valid :position "Not valid point") + (u/not-valid plugin-id :position "Not valid point") (not (r/check-permission plugin-id "comment:write")) - (u/display-not-valid :position "Plugin doesn't have 'comment:write' permission") + (u/not-valid plugin-id :position "Plugin doesn't have 'comment:write' permission") :else (do (st/emit! (dwc/update-comment-thread-position @data* [(:x position) (:y position)])) @@ -137,10 +137,10 @@ (fn [is-resolved] (cond (not (boolean? is-resolved)) - (u/display-not-valid :resolved "Not a boolean type") + (u/not-valid plugin-id :resolved "Not a boolean type") (not (r/check-permission plugin-id "comment:write")) - (u/display-not-valid :resolved "Plugin doesn't have 'comment:write' permission") + (u/not-valid plugin-id :resolved "Plugin doesn't have 'comment:write' permission") :else (do (st/emit! (dc/update-comment-thread (assoc @data* :is-resolved is-resolved))) @@ -153,7 +153,7 @@ (cond (not (r/check-permission plugin-id "comment:read")) (do - (u/display-not-valid :findComments "Plugin doesn't have 'comment:read' permission") + (u/not-valid plugin-id :findComments "Plugin doesn't have 'comment:read' permission") (reject "Plugin doesn't have 'comment:read' permission")) :else @@ -169,10 +169,10 @@ (fn [content] (cond (not (r/check-permission plugin-id "comment:write")) - (u/display-not-valid :reply "Plugin doesn't have 'comment:write' permission") + (u/not-valid plugin-id :reply "Plugin doesn't have 'comment:write' permission") (or (not (string? content)) (empty? content)) - (u/display-not-valid :reply "Not valid") + (u/not-valid plugin-id :reply "Not valid") :else (js/Promise. @@ -186,10 +186,10 @@ owner (dsh/lookup-profile @st/state (:owner-id data))] (cond (not (r/check-permission plugin-id "comment:write")) - (u/display-not-valid :remove "Plugin doesn't have 'comment:write' permission") + (u/not-valid plugin-id :remove "Plugin doesn't have 'comment:write' permission") (not= (:id profile) owner) - (u/display-not-valid :remove "Cannot change content from another user's comments") + (u/not-valid plugin-id :remove "Cannot change content from another user's comments") :else (js/Promise. diff --git a/frontend/src/app/plugins/file.cljs b/frontend/src/app/plugins/file.cljs index 54fcb2f8c3d..15c0bf71889 100644 --- a/frontend/src/app/plugins/file.cljs +++ b/frontend/src/app/plugins/file.cljs @@ -45,10 +45,10 @@ (fn [value] (cond (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :label "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :label "Plugin doesn't have 'content:write' permission") (or (not (string? value)) (empty? value)) - (u/display-not-valid :label value) + (u/not-valid plugin-id :label value) :else (do (swap! data assoc :label value :created-by "user") @@ -145,7 +145,7 @@ (fn [key] (cond (not (string? key)) - (u/display-not-valid :getPluginData-key key) + (u/not-valid plugin-id :getPluginData-key key) :else (let [file (u/locate-file id)] @@ -155,13 +155,13 @@ (fn [key value] (cond (or (not (string? key)) (empty? key)) - (u/display-not-valid :setPluginData-key key) + (u/not-valid plugin-id :setPluginData-key key) (not (string? value)) - (u/display-not-valid :setPluginData-value value) + (u/not-valid plugin-id :setPluginData-value value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :setPluginData "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :setPluginData "Plugin doesn't have 'content:write' permission") :else (st/emit! (dp/set-plugin-data id :file (keyword "plugin" (str plugin-id)) key value)))) @@ -175,10 +175,10 @@ (fn [namespace key] (cond (not (string? namespace)) - (u/display-not-valid :getSharedPluginData-namespace namespace) + (u/not-valid plugin-id :getSharedPluginData-namespace namespace) (not (string? key)) - (u/display-not-valid :getSharedPluginData-key key) + (u/not-valid plugin-id :getSharedPluginData-key key) :else (let [file (u/locate-file id)] @@ -188,16 +188,16 @@ (fn [namespace key value] (cond (or (not (string? namespace)) (empty? namespace)) - (u/display-not-valid :setSharedPluginData-namespace namespace) + (u/not-valid plugin-id :setSharedPluginData-namespace namespace) (or (not (string? key)) (empty? key)) - (u/display-not-valid :setSharedPluginData-key key) + (u/not-valid plugin-id :setSharedPluginData-key key) (not (string? value)) - (u/display-not-valid :setSharedPluginData-value value) + (u/not-valid plugin-id :setSharedPluginData-value value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :setSharedPluginData "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :setSharedPluginData "Plugin doesn't have 'content:write' permission") :else (st/emit! (dp/set-plugin-data id :file (keyword "shared" namespace) key value)))) @@ -206,7 +206,7 @@ (fn [namespace] (cond (not (string? namespace)) - (u/display-not-valid :getSharedPluginDataKeys namespace) + (u/not-valid plugin-id :getSharedPluginDataKeys namespace) :else (let [file (u/locate-file id)] @@ -216,7 +216,7 @@ (fn [] (cond (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :createPage "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :createPage "Plugin doesn't have 'content:write' permission") :else (let [page-id (uuid/next)] diff --git a/frontend/src/app/plugins/flags.cljs b/frontend/src/app/plugins/flags.cljs index a9f1a6dce7e..c28623ac620 100644 --- a/frontend/src/app/plugins/flags.cljs +++ b/frontend/src/app/plugins/flags.cljs @@ -6,17 +6,11 @@ (ns app.plugins.flags (:require - [app.common.data.macros :as dm] [app.main.store :as st] [app.plugins.utils :as u] [app.util.object :as obj] [potok.v2.core :as ptk])) -(defn natural-child-ordering? - [plugin-id] - (boolean - (dm/get-in @st/state [:plugins :flags plugin-id :natural-child-ordering]))) - (defn clear [id] (ptk/reify ::reset @@ -37,13 +31,27 @@ :naturalChildOrdering {:this false :get - (fn [] (natural-child-ordering? plugin-id)) + (fn [] (u/natural-child-ordering? plugin-id)) + + :set + (fn [value] + (cond + (not (boolean? value)) + (u/not-valid plugin-id :naturalChildOrdering value) + + :else + (st/emit! (set-flag plugin-id :natural-child-ordering value))))} + + :throwValidationErrors + {:this false + :get + (fn [] (u/throw-validation-errors? plugin-id)) :set (fn [value] (cond (not (boolean? value)) - (u/display-not-valid :naturalChildOrdering value) + (u/not-valid plugin-id :throwValidationErrors value) :else - (st/emit! (set-flag plugin-id :natural-child-ordering value))))})) + (st/emit! (set-flag plugin-id :throw-validation-errors value))))})) diff --git a/frontend/src/app/plugins/flex.cljs b/frontend/src/app/plugins/flex.cljs index a1c7ef754c6..ff6de684883 100644 --- a/frontend/src/app/plugins/flex.cljs +++ b/frontend/src/app/plugins/flex.cljs @@ -12,7 +12,6 @@ [app.main.data.workspace.shape-layout :as dwsl] [app.main.data.workspace.shapes :as dwsh] [app.main.store :as st] - [app.plugins.flags :refer [natural-child-ordering?]] [app.plugins.register :as r] [app.plugins.utils :as u] [app.util.object :as obj])) @@ -39,10 +38,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/flex-direction-types value)) - (u/display-not-valid :dir value) + (u/not-valid plugin-id :dir value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :dir "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :dir "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-flex-dir value})))))} @@ -55,10 +54,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/wrap-types value)) - (u/display-not-valid :wrap value) + (u/not-valid plugin-id :wrap value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :wrap "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :wrap "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-wrap-type value})))))} @@ -71,10 +70,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/align-items-types value)) - (u/display-not-valid :alignItems value) + (u/not-valid plugin-id :alignItems value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :alignItems "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :alignItems "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-align-items value})))))} @@ -87,10 +86,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/align-content-types value)) - (u/display-not-valid :alignContent value) + (u/not-valid plugin-id :alignContent value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :alignContent "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :alignContent "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-align-content value})))))} @@ -103,10 +102,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/justify-items-types value)) - (u/display-not-valid :justifyItems value) + (u/not-valid plugin-id :justifyItems value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :justifyItems "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :justifyItems "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-justify-items value})))))} @@ -119,10 +118,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/justify-content-types value)) - (u/display-not-valid :justifyContent value) + (u/not-valid plugin-id :justifyContent value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :justifyContent "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :justifyContent "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-justify-content value})))))} @@ -134,10 +133,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :rowGap value) + (u/not-valid plugin-id :rowGap value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :rowGap "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :rowGap "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-gap {:row-gap value}}))))} @@ -149,10 +148,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :columnGap value) + (u/not-valid plugin-id :columnGap value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :columnGap "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :columnGap "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-gap {:column-gap value}}))))} @@ -164,10 +163,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :verticalPadding value) + (u/not-valid plugin-id :verticalPadding value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :verticalPadding "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :verticalPadding "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p1 value :p3 value}}))))} @@ -179,10 +178,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :horizontalPadding value) + (u/not-valid plugin-id :horizontalPadding value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :horizontalPadding "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :horizontalPadding "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p2 value :p4 value}}))))} @@ -195,10 +194,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :topPadding value) + (u/not-valid plugin-id :topPadding value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :topPadding "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :topPadding "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p1 value}}))))} @@ -210,10 +209,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :rightPadding value) + (u/not-valid plugin-id :rightPadding value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :rightPadding "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :rightPadding "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p2 value}}))))} @@ -225,10 +224,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :bottomPadding value) + (u/not-valid plugin-id :bottomPadding value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :bottomPadding "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :bottomPadding "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p3 value}}))))} @@ -240,10 +239,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :leftPadding value) + (u/not-valid plugin-id :leftPadding value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :leftPadding "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :leftPadding "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p4 value}}))))} @@ -256,13 +255,13 @@ (fn [child] (cond (not (shape-proxy? child)) - (u/display-not-valid :appendChild child) + (u/not-valid plugin-id :appendChild child) :else (let [child-id (obj/get child "$id") shape (u/locate-shape file-id page-id id) index - (if (and (natural-child-ordering? plugin-id) (not (ctl/reverse? shape))) + (if (and (u/natural-child-ordering? plugin-id) (not (ctl/reverse? shape))) 0 (count (:shapes shape)))] (st/emit! (dwsh/relocate-shapes #{child-id} id index))))) @@ -275,10 +274,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/item-h-sizing-types value)) - (u/display-not-valid :horizontalSizing value) + (u/not-valid plugin-id :horizontalSizing value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :horizontalSizing "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :horizontalSizing "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-item-h-sizing value})))))} @@ -291,10 +290,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/item-v-sizing-types value)) - (u/display-not-valid :verticalSizing value) + (u/not-valid plugin-id :verticalSizing value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :verticalSizing "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :verticalSizing "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-item-v-sizing value})))))})) @@ -317,10 +316,10 @@ (fn [_ value] (cond (not (boolean? value)) - (u/display-not-valid :absolute value) + (u/not-valid plugin-id :absolute value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :absolute "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :absolute "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-item-absolute value}))))} @@ -332,10 +331,10 @@ (fn [_ value] (cond (sm/valid-safe-int? value) - (u/display-not-valid :zIndex value) + (u/not-valid plugin-id :zIndex value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :zIndex "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :zIndex "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout-child #{id} {:layout-item-z-index value}))))} @@ -348,10 +347,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/item-h-sizing-types value)) - (u/display-not-valid :horizontalPadding value) + (u/not-valid plugin-id :horizontalPadding value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :horizontalPadding "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :horizontalPadding "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout-child #{id} {:layout-item-h-sizing value})))))} @@ -364,10 +363,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/item-v-sizing-types value)) - (u/display-not-valid :verticalSizing value) + (u/not-valid plugin-id :verticalSizing value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :verticalSizing "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :verticalSizing "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout-child #{id} {:layout-item-v-sizing value})))))} @@ -380,10 +379,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/item-align-self-types value)) - (u/display-not-valid :alignSelf value) + (u/not-valid plugin-id :alignSelf value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :alignSelf "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :alignSelf "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout-child #{id} {:layout-item-align-self value})))))} @@ -395,10 +394,10 @@ (fn [_ value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :verticalMargin value) + (u/not-valid plugin-id :verticalMargin value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :verticalMargin "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :verticalMargin "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m1 value :m3 value}}))))} @@ -410,10 +409,10 @@ (fn [_ value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :horizontalMargin value) + (u/not-valid plugin-id :horizontalMargin value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :horizontalMargin "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :horizontalMargin "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m2 value :m4 value}}))))} @@ -425,10 +424,10 @@ (fn [_ value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :topMargin value) + (u/not-valid plugin-id :topMargin value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :topMargin "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :topMargin "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m1 value}}))))} @@ -440,10 +439,10 @@ (fn [_ value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :rightMargin value) + (u/not-valid plugin-id :rightMargin value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :rightMargin "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :rightMargin "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m2 value}}))))} @@ -455,10 +454,10 @@ (fn [_ value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :bottomMargin value) + (u/not-valid plugin-id :bottomMargin value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :bottomMargin "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :bottomMargin "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m3 value}}))))} @@ -470,10 +469,10 @@ (fn [_ value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :leftMargin value) + (u/not-valid plugin-id :leftMargin value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :leftMargin "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :leftMargin "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m4 value}}))))} @@ -485,10 +484,10 @@ (fn [_ value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :maxWidth value) + (u/not-valid plugin-id :maxWidth value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :maxWidth "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :maxWidth "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout-child #{id} {:layout-item-max-w value}))))} @@ -500,10 +499,10 @@ (fn [_ value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :minWidth value) + (u/not-valid plugin-id :minWidth value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :minWidth "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :minWidth "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout-child #{id} {:layout-item-min-w value}))))} @@ -515,10 +514,10 @@ (fn [_ value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :maxHeight value) + (u/not-valid plugin-id :maxHeight value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :maxHeight "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :maxHeight "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout-child #{id} {:layout-item-max-h value}))))} @@ -530,10 +529,10 @@ (fn [_ value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :minHeight value) + (u/not-valid plugin-id :minHeight value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :minHeight "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :minHeight "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout-child #{id} {:layout-item-min-h value}))))})) diff --git a/frontend/src/app/plugins/fonts.cljs b/frontend/src/app/plugins/fonts.cljs index 77602816f6e..13996fabc22 100644 --- a/frontend/src/app/plugins/fonts.cljs +++ b/frontend/src/app/plugins/fonts.cljs @@ -32,7 +32,7 @@ (obj/type-of? p "FontProxy")) (defn font-proxy - [{:keys [id family name variants] :as font}] + [plugin-id {:keys [id family name variants] :as font}] (when (some? font) (let [default-variant (fonts/get-default-variant font)] (obj/reify {:name "FontProxy"} @@ -55,10 +55,10 @@ (fn [text variant] (cond (not (shape/shape-proxy? text)) - (u/display-not-valid :applyToText text) + (u/not-valid plugin-id :applyToText text) (not (r/check-permission (obj/get text "$plugin") "content:write")) - (u/display-not-valid :applyToText "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :applyToText "Plugin doesn't have 'content:write' permission") :else (let [id (obj/get text "$id") @@ -73,10 +73,10 @@ (fn [range variant] (cond (not (text/text-range-proxy? range)) - (u/display-not-valid :applyToRange range) + (u/not-valid plugin-id :applyToRange range) (not (r/check-permission (obj/get range "$plugin") "content:write")) - (u/display-not-valid :applyToRange "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :applyToRange "Plugin doesn't have 'content:write' permission") :else (let [id (obj/get range "$id") @@ -98,53 +98,53 @@ {:get (fn [] (format/format-array - font-proxy + (partial font-proxy plugin-id) (vals @fonts/fontsdb)))} :findById (fn [id] (cond (not (string? id)) - (u/display-not-valid :findbyId id) + (u/not-valid plugin-id :findbyId id) :else (->> (vals @fonts/fontsdb) (d/seek #(str/includes? (str/lower (:id %)) (str/lower id))) - (font-proxy)))) + (font-proxy plugin-id)))) :findByName (fn [name] (cond (not (string? name)) - (u/display-not-valid :findByName name) + (u/not-valid plugin-id :findByName name) :else (->> (vals @fonts/fontsdb) (d/seek #(str/includes? (str/lower (:name %)) (str/lower name))) - (font-proxy)))) + (font-proxy plugin-id)))) :findAllById (fn [id] (cond (not (string? id)) - (u/display-not-valid :findAllById name) + (u/not-valid plugin-id :findAllById name) :else (format/format-array (fn [font] (when (str/includes? (str/lower (:id font)) (str/lower id)) - (font-proxy font))) + (font-proxy plugin-id font))) (vals @fonts/fontsdb)))) :findAllByName (fn [name] (cond (not (string? name)) - (u/display-not-valid :findAllByName name) + (u/not-valid plugin-id :findAllByName name) :else (format/format-array (fn [font] (when (str/includes? (str/lower (:name font)) (str/lower name)) - (font-proxy font))) + (font-proxy plugin-id font))) (vals @fonts/fontsdb)))))) diff --git a/frontend/src/app/plugins/format.cljs b/frontend/src/app/plugins/format.cljs index 9af11c2dded..f0ff928fc8e 100644 --- a/frontend/src/app/plugins/format.cljs +++ b/frontend/src/app/plugins/format.cljs @@ -598,3 +598,10 @@ (case axis :y "horizontal" :x "vertical")) + +(defn format-geom-rect + [{:keys [x y width height]}] + #js {:x x + :y y + :width width + :height height}) diff --git a/frontend/src/app/plugins/grid.cljs b/frontend/src/app/plugins/grid.cljs index f57873ec31b..351b673baf4 100644 --- a/frontend/src/app/plugins/grid.cljs +++ b/frontend/src/app/plugins/grid.cljs @@ -40,10 +40,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/grid-direction-types value)) - (u/display-not-valid :dir value) + (u/not-valid plugin-id :dir value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :dir "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :dir "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-grid-dir value})))))} @@ -64,10 +64,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/align-items-types value)) - (u/display-not-valid :alignItems value) + (u/not-valid plugin-id :alignItems value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :alignItems "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :alignItems "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-align-items value})))))} @@ -80,10 +80,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/align-content-types value)) - (u/display-not-valid :alignContent value) + (u/not-valid plugin-id :alignContent value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :alignContent "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :alignContent "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-align-content value})))))} @@ -96,10 +96,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/justify-items-types value)) - (u/display-not-valid :justifyItems value) + (u/not-valid plugin-id :justifyItems value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :justifyItems "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :justifyItems "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-justify-items value})))))} @@ -112,10 +112,10 @@ (let [value (keyword value)] (cond (not (contains? ctl/justify-content-types value)) - (u/display-not-valid :justifyContent value) + (u/not-valid plugin-id :justifyContent value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :justifyContent "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :justifyContent "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-justify-content value})))))} @@ -127,10 +127,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :rowGap value) + (u/not-valid plugin-id :rowGap value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :rowGap "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :rowGap "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-gap {:row-gap value}}))))} @@ -142,10 +142,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :columnGap value) + (u/not-valid plugin-id :columnGap value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :columnGap "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :columnGap "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-gap {:column-gap value}}))))} @@ -157,10 +157,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :verticalPadding value) + (u/not-valid plugin-id :verticalPadding value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :verticalPadding "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :verticalPadding "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p1 value :p3 value}}))))} @@ -172,10 +172,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :horizontalPadding value) + (u/not-valid plugin-id :horizontalPadding value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :horizontalPadding "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :horizontalPadding "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p2 value :p4 value}}))))} @@ -187,10 +187,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :topPadding value) + (u/not-valid plugin-id :topPadding value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :topPadding "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :topPadding "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p1 value}}))))} @@ -202,10 +202,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :rightPadding value) + (u/not-valid plugin-id :rightPadding value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :righPadding "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :righPadding "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p2 value}}))))} @@ -217,10 +217,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :bottomPadding value) + (u/not-valid plugin-id :bottomPadding value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :bottomPadding "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :bottomPadding "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p3 value}}))))} @@ -232,10 +232,10 @@ (fn [_ value] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :leftPadding value) + (u/not-valid plugin-id :leftPadding value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :leftPadding "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :leftPadding "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p4 value}}))))} @@ -245,14 +245,14 @@ (let [type (keyword type)] (cond (not (contains? ctl/grid-track-types type)) - (u/display-not-valid :addRow-type type) + (u/not-valid plugin-id :addRow-type type) (and (or (= :percent type) (= :flex type) (= :fixed type)) (not (sm/valid-safe-number? value))) - (u/display-not-valid :addRow-value value) + (u/not-valid plugin-id :addRow-value value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :addRow "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :addRow "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/add-layout-track #{id} :row {:type type :value value}))))) @@ -262,17 +262,17 @@ (let [type (keyword type)] (cond (not (sm/valid-safe-int? index)) - (u/display-not-valid :addRowAtIndex-index index) + (u/not-valid plugin-id :addRowAtIndex-index index) (not (contains? ctl/grid-track-types type)) - (u/display-not-valid :addRowAtIndex-type type) + (u/not-valid plugin-id :addRowAtIndex-type type) (and (or (= :percent type) (= :flex type) (= :fixed type)) (not (sm/valid-safe-number? value))) - (u/display-not-valid :addRowAtIndex-value value) + (u/not-valid plugin-id :addRowAtIndex-value value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :addRowAtIndex "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :addRowAtIndex "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/add-layout-track #{id} :row {:type type :value value} index))))) @@ -282,14 +282,14 @@ (let [type (keyword type)] (cond (not (contains? ctl/grid-track-types type)) - (u/display-not-valid :addColumn-type type) + (u/not-valid plugin-id :addColumn-type type) (and (or (= :percent type) (= :flex type) (= :lex type)) (not (sm/valid-safe-number? value))) - (u/display-not-valid :addColumn-value value) + (u/not-valid plugin-id :addColumn-value value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :addColumn "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :addColumn "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/add-layout-track #{id} :column {:type type :value value}))))) @@ -298,17 +298,17 @@ (fn [index type value] (cond (not (sm/valid-safe-int? index)) - (u/display-not-valid :addColumnAtIndex-index index) + (u/not-valid plugin-id :addColumnAtIndex-index index) (not (contains? ctl/grid-track-types type)) - (u/display-not-valid :addColumnAtIndex-type type) + (u/not-valid plugin-id :addColumnAtIndex-type type) (and (or (= :percent type) (= :flex type) (= :fixed type)) (not (sm/valid-safe-number? value))) - (u/display-not-valid :addColumnAtIndex-value value) + (u/not-valid plugin-id :addColumnAtIndex-value value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :addColumnAtIndex "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :addColumnAtIndex "Plugin doesn't have 'content:write' permission") :else (let [type (keyword type)] @@ -318,10 +318,10 @@ (fn [index] (cond (not (sm/valid-safe-int? index)) - (u/display-not-valid :removeRow index) + (u/not-valid plugin-id :removeRow index) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :removeRow "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :removeRow "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/remove-layout-track #{id} :row index)))) @@ -330,10 +330,10 @@ (fn [index] (cond (not (sm/valid-safe-int? index)) - (u/display-not-valid :removeColumn index) + (u/not-valid plugin-id :removeColumn index) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :removeColumn "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :removeColumn "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/remove-layout-track #{id} :column index)))) @@ -343,17 +343,17 @@ (let [type (keyword type)] (cond (not (sm/valid-safe-int? index)) - (u/display-not-valid :setColumn-index index) + (u/not-valid plugin-id :setColumn-index index) (not (contains? ctl/grid-track-types type)) - (u/display-not-valid :setColumn-type type) + (u/not-valid plugin-id :setColumn-type type) (and (or (= :percent type) (= :flex type) (= :fixed type)) (not (sm/valid-safe-number? value))) - (u/display-not-valid :setColumn-value value) + (u/not-valid plugin-id :setColumn-value value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :setColumn "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :setColumn "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/change-layout-track #{id} :column index (d/without-nils {:type type :value value})))))) @@ -363,17 +363,17 @@ (let [type (keyword type)] (cond (not (sm/valid-safe-int? index)) - (u/display-not-valid :setRow-index index) + (u/not-valid plugin-id :setRow-index index) (not (contains? ctl/grid-track-types type)) - (u/display-not-valid :setRow-type type) + (u/not-valid plugin-id :setRow-type type) (and (or (= :percent type) (= :flex type) (= :fixed type)) (not (sm/valid-safe-number? value))) - (u/display-not-valid :setRow-value value) + (u/not-valid plugin-id :setRow-value value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :setRow "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :setRow "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/change-layout-track #{id} :row index (d/without-nils {:type type :value value})))))) @@ -382,7 +382,7 @@ (fn [] (cond (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :remove "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :remove "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/remove-layout #{id})))) @@ -391,16 +391,16 @@ (fn [child row column] (cond (not (shape-proxy? child)) - (u/display-not-valid :appendChild-child child) + (u/not-valid plugin-id :appendChild-child child) (or (< row 0) (not (sm/valid-safe-int? row))) - (u/display-not-valid :appendChild-row row) + (u/not-valid plugin-id :appendChild-row row) (or (< column 0) (not (sm/valid-safe-int? column))) - (u/display-not-valid :appendChild-column column) + (u/not-valid plugin-id :appendChild-column column) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :appendChild "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :appendChild "Plugin doesn't have 'content:write' permission") :else (let [child-id (obj/get child "$id")] @@ -432,13 +432,13 @@ shape (u/proxy->shape self)] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :row-value value) + (u/not-valid plugin-id :row-value value) (nil? cell) - (u/display-not-valid :row-cell "cell not found") + (u/not-valid plugin-id :row-cell "cell not found") (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :row "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :row "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-grid-cell-position (:parent-id shape) (:id cell) {:row value})))))} @@ -452,13 +452,13 @@ cell (locate-cell self)] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :rowSpan-value value) + (u/not-valid plugin-id :rowSpan-value value) (nil? cell) - (u/display-not-valid :rowSpan-cell "cell not found") + (u/not-valid plugin-id :rowSpan-cell "cell not found") (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :rowSpan "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :rowSpan "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-grid-cell-position (:parent-id shape) (:id cell) {:row-span value})))))} @@ -472,13 +472,13 @@ cell (locate-cell self)] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :column-value value) + (u/not-valid plugin-id :column-value value) (nil? cell) - (u/display-not-valid :column-cell "cell not found") + (u/not-valid plugin-id :column-cell "cell not found") (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :column "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :column "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-grid-cell-position (:parent-id shape) (:id cell) {:column value})))))} @@ -492,13 +492,13 @@ cell (locate-cell self)] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :columnSpan-value value) + (u/not-valid plugin-id :columnSpan-value value) (nil? cell) - (u/display-not-valid :columnSpan-cell "cell not found") + (u/not-valid plugin-id :columnSpan-cell "cell not found") (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :columnSpan "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :columnSpan "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-grid-cell-position (:parent-id shape) (:id cell) {:column-span value})))))} @@ -512,13 +512,13 @@ cell (locate-cell self)] (cond (not (string? value)) - (u/display-not-valid :areaName-value value) + (u/not-valid plugin-id :areaName-value value) (nil? cell) - (u/display-not-valid :areaName-cell "cell not found") + (u/not-valid plugin-id :areaName-cell "cell not found") (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :areaName "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :areaName "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-grid-cells (:parent-id shape) #{(:id cell)} {:area-name value})))))} @@ -533,13 +533,13 @@ value (keyword value)] (cond (not (contains? ctl/grid-position-types value)) - (u/display-not-valid :position-value value) + (u/not-valid plugin-id :position-value value) (nil? cell) - (u/display-not-valid :position-cell "cell not found") + (u/not-valid plugin-id :position-cell "cell not found") (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :position "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :position "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/change-cells-mode (:parent-id shape) #{(:id cell)} value)))))} @@ -554,13 +554,13 @@ cell (locate-cell self)] (cond (not (contains? ctl/grid-cell-align-self-types value)) - (u/display-not-valid :alignSelf-value value) + (u/not-valid plugin-id :alignSelf-value value) (nil? cell) - (u/display-not-valid :alignSelf-cell "cell not found") + (u/not-valid plugin-id :alignSelf-cell "cell not found") (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :alignSelf "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :alignSelf "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-grid-cells (:parent-id shape) #{(:id cell)} {:align-self value})))))} @@ -575,13 +575,13 @@ cell (locate-cell self)] (cond (not (contains? ctl/grid-cell-justify-self-types value)) - (u/display-not-valid :justifySelf-value value) + (u/not-valid plugin-id :justifySelf-value value) (nil? cell) - (u/display-not-valid :justifySelf-cell "cell not found") + (u/not-valid plugin-id :justifySelf-cell "cell not found") (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :justifySelf "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :justifySelf "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-grid-cells (:parent-id shape) #{(:id cell)} {:justify-self value})))))}))) diff --git a/frontend/src/app/plugins/history.cljs b/frontend/src/app/plugins/history.cljs index 191dcc0d7ef..25756c47da7 100644 --- a/frontend/src/app/plugins/history.cljs +++ b/frontend/src/app/plugins/history.cljs @@ -24,7 +24,7 @@ (fn [] (cond (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :resize "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :resize "Plugin doesn't have 'content:write' permission") :else (let [id (js/Symbol)] @@ -35,10 +35,10 @@ (fn [block-id] (cond (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :resize "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :resize "Plugin doesn't have 'content:write' permission") (not block-id) - (u/display-not-valid :undoBlockFinish block-id) + (u/not-valid plugin-id :undoBlockFinish block-id) :else (st/emit! (dwu/commit-undo-transaction block-id)))))) diff --git a/frontend/src/app/plugins/library.cljs b/frontend/src/app/plugins/library.cljs index c3dcd9ef284..1022a1a65c7 100644 --- a/frontend/src/app/plugins/library.cljs +++ b/frontend/src/app/plugins/library.cljs @@ -60,10 +60,10 @@ (fn [self value] (cond (not (string? value)) - (u/display-not-valid :name value) + (u/not-valid plugin-id :name value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :name "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :name "Plugin doesn't have 'library:write' permission") :else (let [color (u/proxy->library-color self) @@ -77,10 +77,10 @@ (fn [self value] (cond (not (string? value)) - (u/display-not-valid :path value) + (u/not-valid plugin-id :path value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :path "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :path "Plugin doesn't have 'library:write' permission") :else (let [color (-> (u/proxy->library-color self) @@ -94,10 +94,10 @@ (fn [self value] (cond (or (not (string? value)) (not (clr/valid-hex-color? value))) - (u/display-not-valid :color value) + (u/not-valid plugin-id :color value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :color "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :color "Plugin doesn't have 'library:write' permission") :else (let [color (-> (u/proxy->library-color self) @@ -111,10 +111,10 @@ (fn [self value] (cond (or (not (number? value)) (< value 0) (> value 1)) - (u/display-not-valid :opacity value) + (u/not-valid plugin-id :opacity value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :opacity "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :opacity "Plugin doesn't have 'library:write' permission") :else (let [color (-> (u/proxy->library-color self) @@ -129,10 +129,10 @@ (let [value (parser/parse-gradient value)] (cond (not (sm/validate clr/schema:gradient value)) - (u/display-not-valid :gradient value) + (u/not-valid plugin-id :gradient value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :gradient "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :gradient "Plugin doesn't have 'library:write' permission") :else (let [color (-> (u/proxy->library-color self) @@ -147,10 +147,10 @@ (let [value (parser/parse-image-data value)] (cond (not (sm/validate clr/schema:image value)) - (u/display-not-valid :image value) + (u/not-valid plugin-id :image value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :image "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :image "Plugin doesn't have 'library:write' permission") :else (let [color (-> (u/proxy->library-color self) @@ -161,7 +161,7 @@ (fn [] (cond (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :remove "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :remove "Plugin doesn't have 'library:write' permission") :else (st/emit! (dwl/delete-color {:id id})))) @@ -170,7 +170,7 @@ (fn [] (cond (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :clone "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :clone "Plugin doesn't have 'library:write' permission") :else (let [color-id (uuid/next) @@ -207,7 +207,7 @@ (fn [key] (cond (not (string? key)) - (u/display-not-valid :getPluginData-key key) + (u/not-valid plugin-id :getPluginData-key key) :else (let [color (u/locate-library-color file-id id)] @@ -217,16 +217,16 @@ (fn [key value] (cond (not= file-id (:current-file-id @st/state)) - (u/display-not-valid :setPluginData-non-local-library file-id) + (u/not-valid plugin-id :setPluginData-non-local-library file-id) (not (string? key)) - (u/display-not-valid :setPluginData-key key) + (u/not-valid plugin-id :setPluginData-key key) (and (some? value) (not (string? value))) - (u/display-not-valid :setPluginData-value value) + (u/not-valid plugin-id :setPluginData-value value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :setPluginData "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :setPluginData "Plugin doesn't have 'library:write' permission") :else (st/emit! (dp/set-plugin-data file-id :color id (keyword "plugin" (str plugin-id)) key value)))) @@ -240,10 +240,10 @@ (fn [namespace key] (cond (not (string? namespace)) - (u/display-not-valid :getSharedPluginData-namespace namespace) + (u/not-valid plugin-id :getSharedPluginData-namespace namespace) (not (string? key)) - (u/display-not-valid :getSharedPluginData-key key) + (u/not-valid plugin-id :getSharedPluginData-key key) :else (let [color (u/locate-library-color file-id id)] @@ -253,19 +253,19 @@ (fn [namespace key value] (cond (not= file-id (:current-file-id @st/state)) - (u/display-not-valid :setSharedPluginData-non-local-library file-id) + (u/not-valid plugin-id :setSharedPluginData-non-local-library file-id) (not (string? namespace)) - (u/display-not-valid :setSharedPluginData-namespace namespace) + (u/not-valid plugin-id :setSharedPluginData-namespace namespace) (not (string? key)) - (u/display-not-valid :setSharedPluginData-key key) + (u/not-valid plugin-id :setSharedPluginData-key key) (and (some? value) (not (string? value))) - (u/display-not-valid :setSharedPluginData-value value) + (u/not-valid plugin-id :setSharedPluginData-value value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :setSharedPluginData "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :setSharedPluginData "Plugin doesn't have 'library:write' permission") :else (st/emit! (dp/set-plugin-data file-id :color id (keyword "shared" namespace) key value)))) @@ -274,7 +274,7 @@ (fn [namespace] (cond (not (string? namespace)) - (u/display-not-valid :getSharedPluginDataKeys-namespace namespace) + (u/not-valid plugin-id :getSharedPluginDataKeys-namespace namespace) :else (let [color (u/locate-library-color file-id id)] @@ -301,10 +301,10 @@ (fn [self value] (cond (not (string? value)) - (u/display-not-valid :name value) + (u/not-valid plugin-id :name value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :name "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :name "Plugin doesn't have 'library:write' permission") :else (let [typo (u/proxy->library-typography self) @@ -318,10 +318,10 @@ (fn [self value] (cond (not (string? value)) - (u/display-not-valid :path value) + (u/not-valid plugin-id :path value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :path "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :path "Plugin doesn't have 'library:write' permission") :else (let [typo (-> (u/proxy->library-typography self) @@ -335,10 +335,10 @@ (fn [self value] (cond (not (string? value)) - (u/display-not-valid :fontId value) + (u/not-valid plugin-id :fontId value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :fontId "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :fontId "Plugin doesn't have 'library:write' permission") :else (let [typo (-> (u/proxy->library-typography self) @@ -352,10 +352,10 @@ (fn [self value] (cond (not (string? value)) - (u/display-not-valid :fontFamily value) + (u/not-valid plugin-id :fontFamily value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :fontFamily "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :fontFamily "Plugin doesn't have 'library:write' permission") :else (let [typo (-> (u/proxy->library-typography self) @@ -369,10 +369,10 @@ (fn [self value] (cond (not (string? value)) - (u/display-not-valid :fontVariantId value) + (u/not-valid plugin-id :fontVariantId value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :fontVariantId "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :fontVariantId "Plugin doesn't have 'library:write' permission") :else (let [typo (-> (u/proxy->library-typography self) @@ -386,10 +386,10 @@ (fn [self value] (cond (not (string? value)) - (u/display-not-valid :fontSize value) + (u/not-valid plugin-id :fontSize value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :fontSize "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :fontSize "Plugin doesn't have 'library:write' permission") :else (let [typo (-> (u/proxy->library-typography self) @@ -403,10 +403,10 @@ (fn [self value] (cond (not (string? value)) - (u/display-not-valid :fontWeight value) + (u/not-valid plugin-id :fontWeight value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :fontWeight "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :fontWeight "Plugin doesn't have 'library:write' permission") :else (let [typo (-> (u/proxy->library-typography self) @@ -420,10 +420,10 @@ (fn [self value] (cond (not (string? value)) - (u/display-not-valid :fontStyle value) + (u/not-valid plugin-id :fontStyle value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :fontStyle "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :fontStyle "Plugin doesn't have 'library:write' permission") :else (let [typo (-> (u/proxy->library-typography self) @@ -437,10 +437,10 @@ (fn [self value] (cond (not (string? value)) - (u/display-not-valid :lineHeight value) + (u/not-valid plugin-id :lineHeight value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :lineHeight "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :lineHeight "Plugin doesn't have 'library:write' permission") :else (let [typo (-> (u/proxy->library-typography self) @@ -454,10 +454,10 @@ (fn [self value] (cond (not (string? value)) - (u/display-not-valid :letterSpacing value) + (u/not-valid plugin-id :letterSpacing value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :letterSpacing "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :letterSpacing "Plugin doesn't have 'library:write' permission") :else (let [typo (-> (u/proxy->library-typography self) @@ -471,10 +471,10 @@ (fn [self value] (cond (not (string? value)) - (u/display-not-valid :textTransform value) + (u/not-valid plugin-id :textTransform value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :textTransform "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :textTransform "Plugin doesn't have 'library:write' permission") :else (let [typo (-> (u/proxy->library-typography self) @@ -485,7 +485,7 @@ (fn [] (cond (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :remove "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :remove "Plugin doesn't have 'library:write' permission") :else (st/emit! (dwl/delete-typography {:id id})))) @@ -494,7 +494,7 @@ (fn [] (cond (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :clone "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :clone "Plugin doesn't have 'library:write' permission") :else (let [typo-id (uuid/next) @@ -507,10 +507,10 @@ (fn [shape] (cond (not (shape/shape-proxy? shape)) - (u/display-not-valid :applyToText shape) + (u/not-valid plugin-id :applyToText shape) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :applyToText "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :applyToText "Plugin doesn't have 'content:write' permission") :else (let [shape-id (obj/get shape "$id") @@ -521,10 +521,10 @@ (fn [range] (cond (not (text/text-range-proxy? range)) - (u/display-not-valid :applyToText range) + (u/not-valid plugin-id :applyToText range) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :applyToText "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :applyToText "Plugin doesn't have 'content:write' permission") :else (let [shape-id (obj/get range "$id") @@ -542,7 +542,7 @@ (fn [key] (cond (not (string? key)) - (u/display-not-valid :typography-plugin-data-key key) + (u/not-valid plugin-id :typography-plugin-data-key key) :else (let [typography (u/locate-library-typography file-id id)] @@ -552,16 +552,16 @@ (fn [key value] (cond (not= file-id (:current-file-id @st/state)) - (u/display-not-valid :setPluginData-non-local-library file-id) + (u/not-valid plugin-id :setPluginData-non-local-library file-id) (not (string? key)) - (u/display-not-valid :setPluginData-key key) + (u/not-valid plugin-id :setPluginData-key key) (and (some? value) (not (string? value))) - (u/display-not-valid :setPluginData-value value) + (u/not-valid plugin-id :setPluginData-value value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :setPluginData "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :setPluginData "Plugin doesn't have 'library:write' permission") :else (st/emit! (dp/set-plugin-data file-id :typography id (keyword "plugin" (str plugin-id)) key value)))) @@ -575,10 +575,10 @@ (fn [namespace key] (cond (not (string? namespace)) - (u/display-not-valid :getSharedPluginData-namespace namespace) + (u/not-valid plugin-id :getSharedPluginData-namespace namespace) (not (string? key)) - (u/display-not-valid :getSharedPluginData-key key) + (u/not-valid plugin-id :getSharedPluginData-key key) :else (let [typography (u/locate-library-typography file-id id)] @@ -588,19 +588,19 @@ (fn [namespace key value] (cond (not= file-id (:current-file-id @st/state)) - (u/display-not-valid :setSharedPluginData-non-local-library file-id) + (u/not-valid plugin-id :setSharedPluginData-non-local-library file-id) (not (string? namespace)) - (u/display-not-valid :setSharedPluginData-namespace namespace) + (u/not-valid plugin-id :setSharedPluginData-namespace namespace) (not (string? key)) - (u/display-not-valid :setSharedPluginData-key key) + (u/not-valid plugin-id :setSharedPluginData-key key) (and (some? value) (not (string? value))) - (u/display-not-valid :setSharedPluginData-value value) + (u/not-valid plugin-id :setSharedPluginData-value value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :setSharedPluginData "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :setSharedPluginData "Plugin doesn't have 'library:write' permission") :else (st/emit! (dp/set-plugin-data file-id :typography id (keyword "shared" namespace) key value)))) @@ -609,7 +609,7 @@ (fn [namespace] (cond (not (string? namespace)) - (u/display-not-valid :getSharedPluginDataKeys-namespace namespace) + (u/not-valid plugin-id :getSharedPluginDataKeys-namespace namespace) :else (let [typography (u/locate-library-typography file-id id)] @@ -674,7 +674,7 @@ :removeProperty (fn [pos] (if (not (nat-int? pos)) - (u/display-not-valid :pos pos) + (u/not-valid plugin-id :pos pos) (st/emit! (ev/event {::ev/name "remove-property" ::ev/origin "plugin:remove-property"}) (dwv/remove-property id pos)))) @@ -683,10 +683,10 @@ (fn [pos name] (cond (not (nat-int? pos)) - (u/display-not-valid :pos pos) + (u/not-valid plugin-id :pos pos) (not (string? name)) - (u/display-not-valid :name name) + (u/not-valid plugin-id :name name) :else (st/emit! @@ -715,10 +715,10 @@ (fn [self value] (cond (not (string? value)) - (u/display-not-valid :name value) + (u/not-valid plugin-id :name value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :name "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :name "Plugin doesn't have 'library:write' permission") :else (let [component (u/proxy->library-component self) @@ -732,10 +732,10 @@ (fn [self value] (cond (not (string? value)) - (u/display-not-valid :path value) + (u/not-valid plugin-id :path value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :path "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :path "Plugin doesn't have 'library:write' permission") :else (let [component (u/proxy->library-component self) @@ -746,7 +746,7 @@ (fn [] (cond (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :remove "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :remove "Plugin doesn't have 'library:write' permission") :else (st/emit! (dwl/delete-component {:id id})))) @@ -755,7 +755,7 @@ (fn [] (cond (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :instance "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :instance "Plugin doesn't have 'content:write' permission") :else (let [id-ref (atom nil)] @@ -766,7 +766,7 @@ (fn [key] (cond (not (string? key)) - (u/display-not-valid :component-plugin-data-key key) + (u/not-valid plugin-id :component-plugin-data-key key) :else (let [component (u/locate-library-component file-id id)] @@ -776,16 +776,16 @@ (fn [key value] (cond (not= file-id (:current-file-id @st/state)) - (u/display-not-valid :setPluginData-non-local-library file-id) + (u/not-valid plugin-id :setPluginData-non-local-library file-id) (not (string? key)) - (u/display-not-valid :setPluginData-key key) + (u/not-valid plugin-id :setPluginData-key key) (and (some? value) (not (string? value))) - (u/display-not-valid :setPluginData-value value) + (u/not-valid plugin-id :setPluginData-value value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :setPluginData "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :setPluginData "Plugin doesn't have 'library:write' permission") :else (st/emit! (dp/set-plugin-data file-id :component id (keyword "plugin" (str plugin-id)) key value)))) @@ -799,10 +799,10 @@ (fn [namespace key] (cond (not (string? namespace)) - (u/display-not-valid :component-plugin-data-namespace namespace) + (u/not-valid plugin-id :component-plugin-data-namespace namespace) (not (string? key)) - (u/display-not-valid :component-plugin-data-key key) + (u/not-valid plugin-id :component-plugin-data-key key) :else (let [component (u/locate-library-component file-id id)] @@ -812,19 +812,19 @@ (fn [namespace key value] (cond (not= file-id (:current-file-id @st/state)) - (u/display-not-valid :setSharedPluginData-non-local-library file-id) + (u/not-valid plugin-id :setSharedPluginData-non-local-library file-id) (not (string? namespace)) - (u/display-not-valid :setSharedPluginData-namespace namespace) + (u/not-valid plugin-id :setSharedPluginData-namespace namespace) (not (string? key)) - (u/display-not-valid :setSharedPluginData-key key) + (u/not-valid plugin-id :setSharedPluginData-key key) (and (some? value) (not (string? value))) - (u/display-not-valid :setSharedPluginData-value value) + (u/not-valid plugin-id :setSharedPluginData-value value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :setSharedPluginData "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :setSharedPluginData "Plugin doesn't have 'library:write' permission") :else (st/emit! (dp/set-plugin-data file-id :component id (keyword "shared" namespace) key value)))) @@ -833,7 +833,7 @@ (fn [namespace] (cond (not (string? namespace)) - (u/display-not-valid :component-plugin-data-namespace namespace) + (u/not-valid plugin-id :component-plugin-data-namespace namespace) :else (let [component (u/locate-library-component file-id id)] @@ -901,10 +901,10 @@ (fn [pos value] (cond (not (nat-int? pos)) - (u/display-not-valid :pos (str pos)) + (u/not-valid plugin-id :pos (str pos)) (not (string? value)) - (u/display-not-valid :name value) + (u/not-valid plugin-id :name value) :else (st/emit! @@ -970,7 +970,7 @@ (fn [] (cond (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :createColor "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :createColor "Plugin doesn't have 'library:write' permission") :else (let [color-id (uuid/next)] @@ -981,7 +981,7 @@ (fn [] (cond (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :createTypography "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :createTypography "Plugin doesn't have 'library:write' permission") :else (let [typography-id (uuid/next)] @@ -992,7 +992,7 @@ (fn [shapes] (cond (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :createComponent "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :createComponent "Plugin doesn't have 'library:write' permission") :else (let [id-ref (atom nil) @@ -1005,7 +1005,7 @@ (fn [key] (cond (not (string? key)) - (u/display-not-valid :file-plugin-data-key key) + (u/not-valid plugin-id :file-plugin-data-key key) :else (let [file (u/locate-file file-id)] @@ -1015,13 +1015,13 @@ (fn [key value] (cond (not (string? key)) - (u/display-not-valid :setPluginData-key key) + (u/not-valid plugin-id :setPluginData-key key) (and (some? value) (not (string? value))) - (u/display-not-valid :setPluginData-value value) + (u/not-valid plugin-id :setPluginData-value value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :setPluginData "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :setPluginData "Plugin doesn't have 'library:write' permission") :else (st/emit! (dp/set-plugin-data file-id :file (keyword "plugin" (str plugin-id)) key value)))) @@ -1035,10 +1035,10 @@ (fn [namespace key] (cond (not (string? namespace)) - (u/display-not-valid :file-plugin-data-namespace namespace) + (u/not-valid plugin-id :file-plugin-data-namespace namespace) (not (string? key)) - (u/display-not-valid :file-plugin-data-key key) + (u/not-valid plugin-id :file-plugin-data-key key) :else (let [file (u/locate-file file-id)] @@ -1048,16 +1048,16 @@ (fn [namespace key value] (cond (not (string? namespace)) - (u/display-not-valid :setSharedPluginData-namespace namespace) + (u/not-valid plugin-id :setSharedPluginData-namespace namespace) (not (string? key)) - (u/display-not-valid :setSharedPluginData-key key) + (u/not-valid plugin-id :setSharedPluginData-key key) (and (some? value) (not (string? value))) - (u/display-not-valid :setSharedPluginData-value value) + (u/not-valid plugin-id :setSharedPluginData-value value) (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :setSharedPluginData "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :setSharedPluginData "Plugin doesn't have 'library:write' permission") :else (st/emit! (dp/set-plugin-data file-id :file (keyword "shared" namespace) key value)))) @@ -1066,7 +1066,7 @@ (fn [namespace] (cond (not (string? namespace)) - (u/display-not-valid :namespace namespace) + (u/not-valid plugin-id :namespace namespace) :else (let [file (u/locate-file file-id)] @@ -1110,14 +1110,14 @@ (fn [library-id] (cond (not (r/check-permission plugin-id "library:write")) - (u/display-not-valid :connectLibrary "Plugin doesn't have 'library:write' permission") + (u/not-valid plugin-id :connectLibrary "Plugin doesn't have 'library:write' permission") :else (js/Promise. (fn [resolve reject] (cond (not (string? library-id)) - (do (u/display-not-valid :connectLibrary library-id) + (do (u/not-valid plugin-id :connectLibrary library-id) (reject nil)) :else diff --git a/frontend/src/app/plugins/local_storage.cljs b/frontend/src/app/plugins/local_storage.cljs index cac6529be12..80d9b66b6b0 100644 --- a/frontend/src/app/plugins/local_storage.cljs +++ b/frontend/src/app/plugins/local_storage.cljs @@ -30,10 +30,10 @@ (fn [key] (cond (not (r/check-permission plugin-id "allow:localstorage")) - (u/display-not-valid :getItem "Plugin doesn't have 'allow:localstorage' permission") + (u/not-valid plugin-id :getItem "Plugin doesn't have 'allow:localstorage' permission") (not (string? key)) - (u/display-not-valid :getItem "The key must be a string") + (u/not-valid plugin-id :getItem "The key must be a string") :else (.getItem ^js local-storage (prefix-key plugin-id key)))) @@ -42,10 +42,10 @@ (fn [key value] (cond (not (r/check-permission plugin-id "allow:localstorage")) - (u/display-not-valid :setItem "Plugin doesn't have 'allow:localstorage' permission") + (u/not-valid plugin-id :setItem "Plugin doesn't have 'allow:localstorage' permission") (not (string? key)) - (u/display-not-valid :setItem "The key must be a string") + (u/not-valid plugin-id :setItem "The key must be a string") :else (.setItem ^js local-storage (prefix-key plugin-id key) value))) @@ -54,10 +54,10 @@ (fn [key] (cond (not (r/check-permission plugin-id "allow:localstorage")) - (u/display-not-valid :removeItem "Plugin doesn't have 'allow:localstorage' permission") + (u/not-valid plugin-id :removeItem "Plugin doesn't have 'allow:localstorage' permission") (not (string? key)) - (u/display-not-valid :removeItem "The key must be a string") + (u/not-valid plugin-id :removeItem "The key must be a string") :else (.getItem ^js local-storage (prefix-key plugin-id key)))) diff --git a/frontend/src/app/plugins/page.cljs b/frontend/src/app/plugins/page.cljs index b0302a19399..7bc5726a174 100644 --- a/frontend/src/app/plugins/page.cljs +++ b/frontend/src/app/plugins/page.cljs @@ -59,7 +59,7 @@ (fn [_ value] (cond (or (not (string? value)) (empty? value)) - (u/display-not-valid :name value) + (u/not-valid plugin-id :name value) :else (st/emit! (dwi/update-flow page-id id #(assoc % :name value)))))} @@ -74,7 +74,7 @@ (fn [_ value] (cond (not (shape/shape-proxy? value)) - (u/display-not-valid :startingBoard value) + (u/not-valid plugin-id :startingBoard value) :else (st/emit! (dwi/update-flow page-id id #(assoc % :starting-frame (obj/get value "$id"))))))} @@ -103,10 +103,10 @@ (fn [_ value] (cond (not (string? value)) - (u/display-not-valid :name value) + (u/not-valid plugin-id :name value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :name "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :name "Plugin doesn't have 'content:write' permission") :else (st/emit! (dw/rename-page id value))))} @@ -127,10 +127,10 @@ (fn [_ value] (cond (or (not (string? value)) (not (cc/valid-hex-color? value))) - (u/display-not-valid :background value) + (u/not-valid plugin-id :background value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :background "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :background "Plugin doesn't have 'content:write' permission") :else (st/emit! (dw/change-canvas-color id {:color value}))))} @@ -158,7 +158,7 @@ (fn [shape-id] (cond (not (string? shape-id)) - (u/display-not-valid :getShapeById shape-id) + (u/not-valid plugin-id :getShapeById shape-id) :else (let [shape-id (uuid/parse shape-id) @@ -195,7 +195,7 @@ (fn [key] (cond (not (string? key)) - (u/display-not-valid :page-plugin-data-key key) + (u/not-valid plugin-id :page-plugin-data-key key) :else (let [page (u/locate-page file-id id)] @@ -205,13 +205,13 @@ (fn [key value] (cond (not (string? key)) - (u/display-not-valid :setPluginData-key key) + (u/not-valid plugin-id :setPluginData-key key) (and (some? value) (not (string? value))) - (u/display-not-valid :setPluginData-value value) + (u/not-valid plugin-id :setPluginData-value value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :setPluginData "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :setPluginData "Plugin doesn't have 'content:write' permission") :else (st/emit! (dp/set-plugin-data file-id :page id (keyword "plugin" (str plugin-id)) key value)))) @@ -225,10 +225,10 @@ (fn [namespace key] (cond (not (string? namespace)) - (u/display-not-valid :page-plugin-data-namespace namespace) + (u/not-valid plugin-id :page-plugin-data-namespace namespace) (not (string? key)) - (u/display-not-valid :page-plugin-data-key key) + (u/not-valid plugin-id :page-plugin-data-key key) :else (let [page (u/locate-page file-id id)] @@ -238,16 +238,16 @@ (fn [namespace key value] (cond (not (string? namespace)) - (u/display-not-valid :setSharedPluginData-namespace namespace) + (u/not-valid plugin-id :setSharedPluginData-namespace namespace) (not (string? key)) - (u/display-not-valid :setSharedPluginData-key key) + (u/not-valid plugin-id :setSharedPluginData-key key) (and (some? value) (not (string? value))) - (u/display-not-valid :setSharedPluginData-value value) + (u/not-valid plugin-id :setSharedPluginData-value value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :setSharedPluginData "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :setSharedPluginData "Plugin doesn't have 'content:write' permission") :else (st/emit! (dp/set-plugin-data file-id :page id (keyword "shared" namespace) key value)))) @@ -256,7 +256,7 @@ (fn [self namespace] (cond (not (string? namespace)) - (u/display-not-valid :page-plugin-data-namespace namespace) + (u/not-valid plugin-id :page-plugin-data-namespace namespace) :else (let [page (u/proxy->page self)] @@ -266,7 +266,7 @@ (fn [new-window] (cond (not (r/check-permission plugin-id "content:read")) - (u/display-not-valid :openPage "Plugin doesn't have 'content:read' permission") + (u/not-valid plugin-id :openPage "Plugin doesn't have 'content:read' permission") :else (let [new-window (if (boolean? new-window) new-window false)] @@ -276,10 +276,10 @@ (fn [name frame] (cond (or (not (string? name)) (empty? name)) - (u/display-not-valid :createFlow-name name) + (u/not-valid plugin-id :createFlow-name name) (not (shape/shape-proxy? frame)) - (u/display-not-valid :createFlow-frame frame) + (u/not-valid plugin-id :createFlow-frame frame) :else (let [flow-id (uuid/next)] @@ -290,7 +290,7 @@ (fn [flow] (cond (not (flow-proxy? flow)) - (u/display-not-valid :removeFlow-flow flow) + (u/not-valid plugin-id :removeFlow-flow flow) :else (st/emit! (dwi/remove-flow id (obj/get flow "$id"))))) @@ -300,18 +300,18 @@ (let [shape (u/proxy->shape board)] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :addRulerGuide "Value not a safe number") + (u/not-valid plugin-id :addRulerGuide "Value not a safe number") (not (contains? #{"vertical" "horizontal"} orientation)) - (u/display-not-valid :addRulerGuide "Orientation should be either 'vertical' or 'horizontal'") + (u/not-valid plugin-id :addRulerGuide "Orientation should be either 'vertical' or 'horizontal'") (and (some? shape) (or (not (shape/shape-proxy? board)) (not (cfh/frame-shape? shape)))) - (u/display-not-valid :addRulerGuide "The shape is not a board") + (u/not-valid plugin-id :addRulerGuide "The shape is not a board") (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :addRulerGuide "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :addRulerGuide "Plugin doesn't have 'content:write' permission") :else (let [ruler-id (uuid/next)] @@ -328,10 +328,10 @@ (fn [value] (cond (not (rg/ruler-guide-proxy? value)) - (u/display-not-valid :removeRulerGuide "Guide not provided") + (u/not-valid plugin-id :removeRulerGuide "Guide not provided") (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :removeRulerGuide "Plugin doesn't have 'comment:write' permission") + (u/not-valid plugin-id :removeRulerGuide "Plugin doesn't have 'comment:write' permission") :else (let [guide (u/proxy->ruler-guide value)] @@ -343,17 +343,17 @@ position (parser/parse-point position)] (cond (or (not (string? content)) (empty? content)) - (u/display-not-valid :addCommentThread "Content not valid") + (u/not-valid plugin-id :addCommentThread "Content not valid") (or (not (sm/valid-safe-number? (:x position))) (not (sm/valid-safe-number? (:y position)))) - (u/display-not-valid :addCommentThread "Position not valid") + (u/not-valid plugin-id :addCommentThread "Position not valid") (and (some? board) (or (not (shape/shape-proxy? board)) (not (cfh/frame-shape? shape)))) - (u/display-not-valid :addCommentThread "Board not valid") + (u/not-valid plugin-id :addCommentThread "Board not valid") (not (r/check-permission plugin-id "comment:write")) - (u/display-not-valid :addCommentThread "Plugin doesn't have 'comment:write' permission") + (u/not-valid plugin-id :addCommentThread "Plugin doesn't have 'comment:write' permission") :else (let [position @@ -378,10 +378,10 @@ (fn [thread] (cond (not (pc/comment-thread-proxy? thread)) - (u/display-not-valid :removeCommentThread "Comment thread not valid") + (u/not-valid plugin-id :removeCommentThread "Comment thread not valid") (not (r/check-permission plugin-id "comment:write")) - (u/display-not-valid :removeCommentThread "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :removeCommentThread "Plugin doesn't have 'content:write' permission") :else (js/Promise. @@ -400,7 +400,7 @@ (cond (not (r/check-permission plugin-id "comment:read")) (do - (u/display-not-valid :findCommentThreads "Plugin doesn't have 'comment:read' permission") + (u/not-valid plugin-id :findCommentThreads "Plugin doesn't have 'comment:read' permission") (reject "Plugin doesn't have 'comment:read' permission")) :else diff --git a/frontend/src/app/plugins/public_utils.cljs b/frontend/src/app/plugins/public_utils.cljs index d3ed6a46e22..0bfe911ed56 100644 --- a/frontend/src/app/plugins/public_utils.cljs +++ b/frontend/src/app/plugins/public_utils.cljs @@ -14,10 +14,10 @@ [app.plugins.utils :as u])) (defn ^:export centerShapes - [shapes] + [plugin-id shapes] (cond (not (every? shape/shape-proxy? shapes)) - (u/display-not-valid :centerShapes shapes) + (u/not-valid plugin-id :centerShapes shapes) :else (let [shapes (->> shapes (map u/proxy->shape))] diff --git a/frontend/src/app/plugins/register.cljs b/frontend/src/app/plugins/register.cljs index ebdee92254f..e3792f3fc91 100644 --- a/frontend/src/app/plugins/register.cljs +++ b/frontend/src/app/plugins/register.cljs @@ -17,6 +17,10 @@ [app.util.object :as obj] [beicon.v2.core :as rx])) +;; Needs to be here because moving it to `app.main.data.workspace.mcp` will +;; cause a circular dependency +(def mcp-plugin-id "96dfa740-005d-8020-8007-55ede24a2bae") + ;; Stores the installed plugins information (defonce ^:private registry (atom {})) @@ -78,6 +82,7 @@ (d/without-nils {:plugin-id plugin-id :url (str plugin-url) + :version vers :name name :description desc :host origin @@ -127,5 +132,6 @@ (defn check-permission [plugin-id permission] (or (= plugin-id "00000000-0000-0000-0000-000000000000") + (= plugin-id mcp-plugin-id) (let [{:keys [permissions]} (dm/get-in @registry [:data plugin-id])] (contains? permissions permission)))) diff --git a/frontend/src/app/plugins/ruler_guides.cljs b/frontend/src/app/plugins/ruler_guides.cljs index d9c8e7c6c47..696df230018 100644 --- a/frontend/src/app/plugins/ruler_guides.cljs +++ b/frontend/src/app/plugins/ruler_guides.cljs @@ -44,13 +44,13 @@ (let [shape (u/locate-shape file-id page-id (obj/get value "$id"))] (cond (not (shape-proxy? value)) - (u/display-not-valid :board "The board is not a shape proxy") + (u/not-valid plugin-id :board "The board is not a shape proxy") (not (cfh/frame-shape? shape)) - (u/display-not-valid :board "The shape is not a board") + (u/not-valid plugin-id :board "The shape is not a board") (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :board "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :board "Plugin doesn't have 'content:write' permission") :else (let [board-id (when value (obj/get value "$id")) @@ -78,10 +78,10 @@ (fn [self value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :position "Not valid position") + (u/not-valid plugin-id :position "Not valid position") (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :position "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :position "Plugin doesn't have 'content:write' permission") :else (let [guide (u/proxy->ruler-guide self) diff --git a/frontend/src/app/plugins/shape.cljs b/frontend/src/app/plugins/shape.cljs index 5a0c8f6634f..722822df793 100644 --- a/frontend/src/app/plugins/shape.cljs +++ b/frontend/src/app/plugins/shape.cljs @@ -31,8 +31,8 @@ [app.common.types.shape.radius :as ctsr] [app.common.types.shape.shadow :as ctss] [app.common.types.text :as txt] - [app.common.types.token :as cto] [app.common.uuid :as uuid] + [app.main.data.plugins :as dp] [app.main.data.workspace :as dw] [app.main.data.workspace.groups :as dwg] @@ -47,7 +47,6 @@ [app.main.data.workspace.variants :as dwv] [app.main.repo :as rp] [app.main.store :as st] - [app.plugins.flags :refer [natural-child-ordering?]] [app.plugins.flex :as flex] [app.plugins.format :as format] [app.plugins.grid :as grid] @@ -55,6 +54,7 @@ [app.plugins.register :as r] [app.plugins.ruler-guides :as rg] [app.plugins.text :as text] + [app.plugins.tokens :refer [applied-tokens-plugin->applied-tokens token-attr-plugin->token-attr token-attr?]] [app.plugins.utils :as u] [app.util.http :as http] [app.util.object :as obj] @@ -91,7 +91,7 @@ (let [value (parser/parse-keyword value)] (cond (not (contains? ctsi/event-types value)) - (u/display-not-valid :trigger value) + (u/not-valid plugin-id :trigger value) :else (st/emit! (dwi/update-interaction @@ -107,7 +107,7 @@ (fn [_ value] (cond (or (not (number? value)) (not (pos? value))) - (u/display-not-valid :delay value) + (u/not-valid plugin-id :delay value) :else (st/emit! (dwi/update-interaction @@ -127,7 +127,7 @@ (d/patch-object params))] (cond (not (sm/validate ctsi/schema:interaction interaction)) - (u/display-not-valid :action interaction) + (u/not-valid plugin-id :action interaction) :else (st/emit! (dwi/update-interaction @@ -192,7 +192,8 @@ (assert (uuid? id)) (let [data (u/locate-shape file-id page-id id)] - (-> (obj/reify {:name "ShapeProxy"} + (-> (obj/reify {:name "ShapeProxy" + :on-error (u/handle-error plugin-id)} :$plugin {:enumerable false :get (fn [] plugin-id)} :$id {:enumerable false :get (fn [] id)} :$file {:enumerable false :get (fn [] file-id)} @@ -218,10 +219,10 @@ (not (str/blank? value)))] (cond (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :name "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :name "Plugin doesn't have 'content:write' permission") (not valid?) - (u/display-not-valid :name value) + (u/not-valid plugin-id :name value) :else (st/emit! (dw/rename-shape-or-variant file-id page-id id value)))))} @@ -233,10 +234,10 @@ (fn [self value] (cond (not (boolean? value)) - (u/display-not-valid :blocked value) + (u/not-valid plugin-id :blocked value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :blocked "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :blocked "Plugin doesn't have 'content:write' permission") :else (let [id (obj/get self "$id")] @@ -249,10 +250,10 @@ (fn [self value] (cond (not (boolean? value)) - (u/display-not-valid :hidden value) + (u/not-valid plugin-id :hidden value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :hidden "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :hidden "Plugin doesn't have 'content:write' permission") :else (let [id (obj/get self "$id")] @@ -265,10 +266,10 @@ (fn [self value] (cond (not (boolean? value)) - (u/display-not-valid :visible value) + (u/not-valid plugin-id :visible value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :visible "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :visible "Plugin doesn't have 'content:write' permission") :else (let [id (obj/get self "$id")] @@ -281,10 +282,10 @@ (fn [self value] (cond (not (boolean? value)) - (u/display-not-valid :proportionLock value) + (u/not-valid plugin-id :proportionLock value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :proportionLock "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :proportionLock "Plugin doesn't have 'content:write' permission") :else (let [id (obj/get self "$id")] @@ -299,10 +300,10 @@ value (keyword value)] (cond (not (contains? cts/horizontal-constraint-types value)) - (u/display-not-valid :constraintsHorizontal value) + (u/not-valid plugin-id :constraintsHorizontal value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :constraintsHorizontal "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :constraintsHorizontal "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(assoc % :constraints-h value))))))} @@ -316,10 +317,10 @@ value (keyword value)] (cond (not (contains? cts/vertical-constraint-types value)) - (u/display-not-valid :constraintsVertical value) + (u/not-valid plugin-id :constraintsVertical value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :constraintsVertical "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :constraintsVertical "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(assoc % :constraints-v value))))))} @@ -332,10 +333,10 @@ (let [id (obj/get self "$id")] (cond (or (not (sm/valid-safe-int? value)) (< value 0)) - (u/display-not-valid :borderRadius value) + (u/not-valid plugin-id :borderRadius value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :borderRadius "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :borderRadius "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(ctsr/set-radius-to-all-corners % value))))))} @@ -348,10 +349,10 @@ (let [id (obj/get self "$id")] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :borderRadiusTopLeft value) + (u/not-valid plugin-id :borderRadiusTopLeft value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :borderRadiusTopLeft "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :borderRadiusTopLeft "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(ctsr/set-radius-to-single-corner % :r1 value))))))} @@ -364,10 +365,10 @@ (let [id (obj/get self "$id")] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :borderRadiusTopRight value) + (u/not-valid plugin-id :borderRadiusTopRight value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :borderRadiusTopRight "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :borderRadiusTopRight "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(ctsr/set-radius-to-single-corner % :r2 value))))))} @@ -380,10 +381,10 @@ (let [id (obj/get self "$id")] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :borderRadiusBottomRight value) + (u/not-valid plugin-id :borderRadiusBottomRight value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :borderRadiusBottomRight "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :borderRadiusBottomRight "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(ctsr/set-radius-to-single-corner % :r3 value))))))} @@ -396,10 +397,10 @@ (let [id (obj/get self "$id")] (cond (not (sm/valid-safe-int? value)) - (u/display-not-valid :borderRadiusBottomLeft value) + (u/not-valid plugin-id :borderRadiusBottomLeft value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :borderRadiusBottomLeft "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :borderRadiusBottomLeft "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(ctsr/set-radius-to-single-corner % :r4 value))))))} @@ -412,10 +413,10 @@ (let [id (obj/get self "$id")] (cond (or (not (sm/valid-safe-number? value)) (< value 0) (> value 1)) - (u/display-not-valid :opacity value) + (u/not-valid plugin-id :opacity value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :opacity "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :opacity "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(assoc % :opacity value))))))} @@ -429,10 +430,10 @@ value (keyword value)] (cond (not (contains? cts/blend-modes value)) - (u/display-not-valid :blendMode value) + (u/not-valid plugin-id :blendMode value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :blendMode "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :blendMode "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(assoc % :blend-mode value))))))} @@ -446,10 +447,10 @@ value (mapv #(shadow-defaults (parser/parse-shadow %)) value)] (cond (not (sm/validate [:vector ctss/schema:shadow] value)) - (u/display-not-valid :shadows value) + (u/not-valid plugin-id :shadows value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :shadows "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :shadows "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(assoc % :shadow value))))))} @@ -465,10 +466,10 @@ value (blur-defaults (parser/parse-blur value))] (cond (not (sm/validate ctsb/schema:blur value)) - (u/display-not-valid :blur value) + (u/not-valid plugin-id :blur value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :blur "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :blur "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(assoc % :blur value)))))))} @@ -482,10 +483,10 @@ value (parser/parse-exports value)] (cond (not (sm/validate [:vector ctse/schema:export] value)) - (u/display-not-valid :exports value) + (u/not-valid plugin-id :exports value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :exports "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :exports "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(assoc % :exports value))))))} @@ -499,10 +500,10 @@ (let [id (obj/get self "$id")] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :x value) + (u/not-valid plugin-id :x value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :x "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :x "Plugin doesn't have 'content:write' permission") :else (st/emit! (dw/update-position id @@ -517,10 +518,10 @@ (let [id (obj/get self "$id")] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :y value) + (u/not-valid plugin-id :y value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :y "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :y "Plugin doesn't have 'content:write' permission") :else (st/emit! (dw/update-position id @@ -562,10 +563,10 @@ (fn [self value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :parentX value) + (u/not-valid plugin-id :parentX value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :parentX "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :parentX "Plugin doesn't have 'content:write' permission") :else (let [id (obj/get self "$id") @@ -589,10 +590,10 @@ (fn [self value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :parentY value) + (u/not-valid plugin-id :parentY value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :parentY "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :parentY "Plugin doesn't have 'content:write' permission") :else (let [id (obj/get self "$id") @@ -616,10 +617,10 @@ (fn [self value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :frameX value) + (u/not-valid plugin-id :frameX value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :frameX "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :frameX "Plugin doesn't have 'content:write' permission") :else (let [id (obj/get self "$id") @@ -643,10 +644,10 @@ (fn [self value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :frameY value) + (u/not-valid plugin-id :frameY value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :frameY "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :frameY "Plugin doesn't have 'content:write' permission") :else (let [id (obj/get self "$id") @@ -680,10 +681,10 @@ (fn [self value] (cond (not (number? value)) - (u/display-not-valid :rotation value) + (u/not-valid plugin-id :rotation value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :rotation "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :rotation "Plugin doesn't have 'content:write' permission") :else (let [shape (u/proxy->shape self)] @@ -696,10 +697,10 @@ (fn [self value] (cond (not (boolean? value)) - (u/display-not-valid :flipX value) + (u/not-valid plugin-id :flipX value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :flipX "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :flipX "Plugin doesn't have 'content:write' permission") :else (let [id (obj/get self "$id")] @@ -712,10 +713,10 @@ (fn [self value] (cond (not (boolean? value)) - (u/display-not-valid :flipY value) + (u/not-valid plugin-id :flipY value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :flipY "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :flipY "Plugin doesn't have 'content:write' permission") :else (let [id (obj/get self "$id")] @@ -734,13 +735,13 @@ value (parser/parse-fills value)] (cond (not (sm/validate [:vector types.fills/schema:fill] value)) - (u/display-not-valid :fills value) + (u/not-valid plugin-id :fills value) (cfh/text-shape? shape) (st/emit! (dwt/update-attrs id {:fills value})) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :fills "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :fills "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(assoc % :fills value))))))} @@ -754,10 +755,10 @@ value (parser/parse-strokes value)] (cond (not (sm/validate [:vector cts/schema:stroke] value)) - (u/display-not-valid :strokes value) + (u/not-valid plugin-id :strokes value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :strokes "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :strokes "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(assoc % :strokes value))))))} @@ -802,13 +803,13 @@ (fn [width height] (cond (or (not (sm/valid-safe-number? width)) (<= width 0)) - (u/display-not-valid :resize width) + (u/not-valid plugin-id :resize width) (or (not (sm/valid-safe-number? height)) (<= height 0)) - (u/display-not-valid :resize height) + (u/not-valid plugin-id :resize height) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :resize "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :resize "Plugin doesn't have 'content:write' permission") :else (st/emit! (dw/update-dimensions [id] :width width) @@ -819,13 +820,13 @@ (let [center (when center {:x (obj/get center "x") :y (obj/get center "y")})] (cond (not (number? angle)) - (u/display-not-valid :rotate-angle angle) + (u/not-valid plugin-id :rotate-angle angle) (and (some? center) (or (not (number? (:x center))) (not (number? (:y center))))) - (u/display-not-valid :rotate-center center) + (u/not-valid plugin-id :rotate-center center) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :rotate "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :rotate "Plugin doesn't have 'content:write' permission") :else (st/emit! (dw/increase-rotation [id] angle {:center center :delta? true}))))) @@ -835,7 +836,7 @@ (let [ret-v (atom nil)] (cond (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :clone "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :clone "Plugin doesn't have 'content:write' permission") :else (do (st/emit! (dws/duplicate-shapes #{id} :change-selection? false :return-ref ret-v)) @@ -845,7 +846,7 @@ (fn [] (cond (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :remove "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :remove "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/delete-shapes #{id})))) @@ -855,7 +856,7 @@ (fn [key] (cond (not (string? key)) - (u/display-not-valid :getPluginData key) + (u/not-valid plugin-id :getPluginData key) :else (let [shape (u/locate-shape file-id page-id id)] @@ -865,13 +866,13 @@ (fn [key value] (cond (not (string? key)) - (u/display-not-valid :setPluginData-key key) + (u/not-valid plugin-id :setPluginData-key key) (and (some? value) (not (string? value))) - (u/display-not-valid :setPluginData-value value) + (u/not-valid plugin-id :setPluginData-value value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :setPluginData "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :setPluginData "Plugin doesn't have 'content:write' permission") :else (st/emit! (dp/set-plugin-data file-id :shape id page-id (keyword "plugin" (str plugin-id)) key value)))) @@ -885,10 +886,10 @@ (fn [namespace key] (cond (not (string? namespace)) - (u/display-not-valid :getSharedPluginData-namespace namespace) + (u/not-valid plugin-id :getSharedPluginData-namespace namespace) (not (string? key)) - (u/display-not-valid :getSharedPluginData-key key) + (u/not-valid plugin-id :getSharedPluginData-key key) :else (let [shape (u/locate-shape file-id page-id id)] @@ -898,16 +899,16 @@ (fn [namespace key value] (cond (not (string? namespace)) - (u/display-not-valid :setSharedPluginData-namespace namespace) + (u/not-valid plugin-id :setSharedPluginData-namespace namespace) (not (string? key)) - (u/display-not-valid :setSharedPluginData-key key) + (u/not-valid plugin-id :setSharedPluginData-key key) (and (some? value) (not (string? value))) - (u/display-not-valid :setSharedPluginData-value value) + (u/not-valid plugin-id :setSharedPluginData-value value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :setSharedPluginData "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :setSharedPluginData "Plugin doesn't have 'content:write' permission") :else (st/emit! (dp/set-plugin-data file-id :shape id page-id (keyword "shared" namespace) key value)))) @@ -916,7 +917,7 @@ (fn [namespace] (cond (not (string? namespace)) - (u/display-not-valid :getSharedPluginDataKeys namespace) + (u/not-valid plugin-id :getSharedPluginDataKeys namespace) :else (let [shape (u/locate-shape file-id page-id id)] @@ -931,12 +932,12 @@ (not (cfh/group-shape? shape)) (not (cfh/svg-raw-shape? shape)) (not (cfh/bool-shape? shape))) - (u/display-not-valid :getChildren (:type shape)) + (u/not-valid plugin-id :getChildren (:type shape)) :else (let [is-reversed? (ctl/flex-layout? shape) reverse-fn - (if (and (natural-child-ordering? plugin-id) is-reversed?) + (if (and (u/natural-child-ordering? plugin-id) is-reversed?) reverse identity)] (->> (u/locate-shape file-id page-id id) (:shapes) @@ -951,19 +952,19 @@ (not (cfh/group-shape? shape)) (not (cfh/svg-raw-shape? shape)) (not (cfh/bool-shape? shape))) - (u/display-not-valid :appendChild (:type shape)) + (u/not-valid plugin-id :appendChild (:type shape)) (not (shape-proxy? child)) - (u/display-not-valid :appendChild-child child) + (u/not-valid plugin-id :appendChild-child child) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :appendChild "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :appendChild "Plugin doesn't have 'content:write' permission") :else (let [child-id (obj/get child "$id") is-reversed? (ctl/flex-layout? shape) index - (if (or (not (natural-child-ordering? plugin-id)) is-reversed?) + (if (or (not (u/natural-child-ordering? plugin-id)) is-reversed?) 0 (count (:shapes shape)))] (st/emit! (dwsh/relocate-shapes #{child-id} id index)))))) @@ -976,19 +977,19 @@ (not (cfh/group-shape? shape)) (not (cfh/svg-raw-shape? shape)) (not (cfh/bool-shape? shape))) - (u/display-not-valid :insertChild (:type shape)) + (u/not-valid plugin-id :insertChild (:type shape)) (not (shape-proxy? child)) - (u/display-not-valid :insertChild-child child) + (u/not-valid plugin-id :insertChild-child child) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :insertChild "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :insertChild "Plugin doesn't have 'content:write' permission") :else (let [child-id (obj/get child "$id") is-reversed? (ctl/flex-layout? shape) index - (if (or (not (natural-child-ordering? plugin-id)) is-reversed?) + (if (or (not (u/natural-child-ordering? plugin-id)) is-reversed?) (- (count (:shapes shape)) index) index)] (st/emit! (dwsh/relocate-shapes #{child-id} id index)))))) @@ -999,10 +1000,10 @@ (let [shape (u/locate-shape file-id page-id id)] (cond (not (cfh/frame-shape? shape)) - (u/display-not-valid :addFlexLayout (:type shape)) + (u/not-valid plugin-id :addFlexLayout (:type shape)) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :addFlexLayout "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :addFlexLayout "Plugin doesn't have 'content:write' permission") :else (do (st/emit! (dwsl/create-layout-from-id id :flex :from-frame? true :calculate-params? false)) @@ -1013,10 +1014,10 @@ (let [shape (u/locate-shape file-id page-id id)] (cond (not (cfh/frame-shape? shape)) - (u/display-not-valid :addGridLayout (:type shape)) + (u/not-valid plugin-id :addGridLayout (:type shape)) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :addGridLayout "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :addGridLayout "Plugin doesn't have 'content:write' permission") :else (do (st/emit! (dwsl/create-layout-from-id id :grid :from-frame? true :calculate-params? false)) @@ -1028,10 +1029,10 @@ (let [shape (u/locate-shape file-id page-id id)] (cond (not (cfh/group-shape? shape)) - (u/display-not-valid :makeMask (:type shape)) + (u/not-valid plugin-id :makeMask (:type shape)) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :makeMask "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :makeMask "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwg/mask-group #{id}))))) @@ -1041,10 +1042,10 @@ (let [shape (u/locate-shape file-id page-id id)] (cond (not (cfh/mask-shape? shape)) - (u/display-not-valid :removeMask (:type shape)) + (u/not-valid plugin-id :removeMask (:type shape)) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :removeMask "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :removeMask "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwg/unmask-group #{id}))))) @@ -1055,7 +1056,7 @@ (let [shape (u/locate-shape file-id page-id id)] (cond (and (not (cfh/path-shape? shape)) (not (cfh/bool-shape? shape))) - (u/display-not-valid :toD (:type shape)) + (u/not-valid plugin-id :toD (:type shape)) :else (.toString (:content shape))))) @@ -1066,13 +1067,13 @@ (let [shape (u/locate-shape file-id page-id id)] (cond (not (cfh/text-shape? shape)) - (u/display-not-valid :getRange-shape "shape is not text") + (u/not-valid plugin-id :getRange-shape "shape is not text") (or (not (sm/valid-safe-int? start)) (< start 0) (> start end)) - (u/display-not-valid :getRange-start start) + (u/not-valid plugin-id :getRange-start start) (not (sm/valid-safe-int? end)) - (u/display-not-valid :getRange-end end) + (u/not-valid plugin-id :getRange-end end) :else (text/text-range-proxy plugin-id file-id page-id id start end)))) @@ -1082,13 +1083,13 @@ (let [shape (u/locate-shape file-id page-id id)] (cond (not (lib-typography-proxy? typography)) - (u/display-not-valid :applyTypography-typography typography) + (u/not-valid plugin-id :applyTypography-typography typography) (not (cfh/text-shape? shape)) - (u/display-not-valid :applyTypography-shape (:type shape)) + (u/not-valid plugin-id :applyTypography-shape (:type shape)) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :applyTypography "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :applyTypography "Plugin doesn't have 'content:write' permission") :else (let [typography (u/proxy->library-typography typography)] @@ -1099,10 +1100,10 @@ (fn [index] (cond (not (sm/valid-safe-int? index)) - (u/display-not-valid :setParentIndex index) + (u/not-valid plugin-id :setParentIndex index) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :setParentIndex "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :setParentIndex "Plugin doesn't have 'content:write' permission") :else (st/emit! (dw/set-shape-index file-id page-id id index)))) @@ -1197,7 +1198,7 @@ (let [value (parser/parse-export value)] (cond (not (sm/validate ctse/schema:export value)) - (u/display-not-valid :export value) + (u/not-valid plugin-id :export value) :else (let [shape (u/locate-shape file-id page-id id) @@ -1233,7 +1234,7 @@ (d/patch-object (parser/parse-interaction trigger action delay)))] (cond (not (sm/validate ctsi/schema:interaction interaction)) - (u/display-not-valid :addInteraction interaction) + (u/not-valid plugin-id :addInteraction interaction) :else (let [index (-> (u/locate-shape file-id page-id id) (:interactions []) count)] @@ -1244,7 +1245,7 @@ (fn [interaction] (cond (not (interaction-proxy? interaction)) - (u/display-not-valid :removeInteraction interaction) + (u/not-valid plugin-id :removeInteraction interaction) :else (st/emit! (dwi/remove-interaction {:id id} (obj/get interaction "$index"))))) @@ -1255,16 +1256,16 @@ (let [shape (u/locate-shape file-id page-id id)] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :addRulerGuide "Value not a safe number") + (u/not-valid plugin-id :addRulerGuide "Value not a safe number") (not (contains? #{"vertical" "horizontal"} orientation)) - (u/display-not-valid :addRulerGuide "Orientation should be either 'vertical' or 'horizontal'") + (u/not-valid plugin-id :addRulerGuide "Orientation should be either 'vertical' or 'horizontal'") (not (cfh/frame-shape? shape)) - (u/display-not-valid :addRulerGuide "The shape is not a board") + (u/not-valid plugin-id :addRulerGuide "The shape is not a board") (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :addRulerGuide "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :addRulerGuide "Plugin doesn't have 'content:write' permission") :else (let [id (uuid/next) @@ -1285,10 +1286,10 @@ (fn [_ value] (cond (not (rg/ruler-guide-proxy? value)) - (u/display-not-valid :removeRulerGuide "Guide not provided") + (u/not-valid plugin-id :removeRulerGuide "Guide not provided") (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :removeRulerGuide "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :removeRulerGuide "Plugin doesn't have 'content:write' permission") :else (let [guide (u/proxy->ruler-guide value)] @@ -1298,25 +1299,26 @@ {:this true :get (fn [_] - (let [tokens + (let [applied-tokens (-> (u/locate-shape file-id page-id id) - (get :applied-tokens))] + (get :applied-tokens) + (applied-tokens-plugin->applied-tokens))] (reduce (fn [acc [prop name]] (obj/set! acc (json/write-camel-key prop) name)) #js {} - tokens)))} + applied-tokens)))} :applyToken {:enumerable false :schema [:tuple [:fn token-proxy?] - [:maybe [:set [:and ::sm/keyword [:fn cto/token-attr?]]]]] + [:maybe [:set [:and ::sm/keyword [:fn token-attr?]]]]] :fn (fn [token attrs] (let [token (u/locate-token file-id (obj/get token "$set-id") (obj/get token "$id")) - kw-attrs (into #{} (map keyword attrs))] - (if (some #(not (cto/token-attr? %)) kw-attrs) - (u/display-not-valid :applyToken attrs) + kw-attrs (into #{} (map token-attr-plugin->token-attr attrs))] + (if (some #(not (token-attr? %)) kw-attrs) + (u/not-valid plugin-id :applyToken attrs) (st/emit! (dwta/toggle-token {:token token :attrs kw-attrs @@ -1338,10 +1340,10 @@ (fn [pos value] (cond (not (nat-int? pos)) - (u/display-not-valid :pos pos) + (u/not-valid plugin-id :pos pos) (not (string? value)) - (u/display-not-valid :value value) + (u/not-valid plugin-id :value value) :else (let [shape (u/locate-shape file-id page-id id) @@ -1351,16 +1353,30 @@ :combineAsVariants (fn [ids] - (if (or (not (seq ids)) (not (every? uuid/parse* ids))) - (u/display-not-valid :ids ids) - (let [shape (u/locate-shape file-id page-id id) - component (u/locate-library-component file-id (:component-id shape)) - ids (->> ids + (cond + (or (not (seq ids)) (not (every? uuid/parse* ids))) + (u/not-valid plugin-id :ids ids) + + :else + (let [ids (->> ids (map uuid/uuid) - (into #{id}))] - (when (and component (not (ctk/is-variant? component))) - (st/emit! - (dwv/combine-as-variants ids {:trigger "plugin:combine-as-variants"}))))))) + (into #{id})) + valid? + (every? + (fn [id] + (let [shape (u/locate-shape file-id page-id id) + component (u/locate-library-component file-id (:component-id shape))] + (not (ctk/is-variant? component)))) + ids)] + + (if valid? + (let [variant-id (uuid/next)] + (st/emit! (dwv/combine-as-variants + ids + {:trigger "plugin:combine-as-variants" :variant-id variant-id})) + (shape-proxy plugin-id variant-id)) + + (u/not-valid plugin-id :ids "One of the components is not on the same page or is already a variant")))))) (cond-> (or (cfh/frame-shape? data) (cfh/group-shape? data) (cfh/svg-raw-shape? data) (cfh/bool-shape? data)) (crc/add-properties! @@ -1375,21 +1391,21 @@ (fn [^js self children] (cond (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :children "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :children "Plugin doesn't have 'content:write' permission") (not (every? shape-proxy? children)) - (u/display-not-valid :children "Every children needs to be shape proxies") + (u/not-valid plugin-id :children "Every children needs to be shape proxies") :else (let [shape (u/proxy->shape self) file-id (obj/get self "$file") page-id (obj/get self "$page") - reverse-fn (if (natural-child-ordering? plugin-id) reverse identity) + reverse-fn (if (u/natural-child-ordering? plugin-id) reverse identity) ids (->> children reverse-fn (map #(obj/get % "$id")))] (cond (not= (set ids) (set (:shapes shape))) - (u/display-not-valid :children "Not all children are present in the input") + (u/not-valid plugin-id :children "Not all children are present in the input") :else (st/emit! (dw/reorder-children file-id page-id (:id shape) ids))))))})) @@ -1405,10 +1421,10 @@ (fn [_ value] (cond (not (boolean? value)) - (u/display-not-valid :clipContent value) + (u/not-valid plugin-id :clipContent value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :clipContent "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :clipContent "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(assoc % :show-content (not value))))))} @@ -1421,10 +1437,10 @@ (fn [_ value] (cond (not (boolean? value)) - (u/display-not-valid :showInViewMode value) + (u/not-valid plugin-id :showInViewMode value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :showInViewMode "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :showInViewMode "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(assoc % :hide-in-viewer (not value))))))} @@ -1456,10 +1472,10 @@ value (parser/parse-frame-guides value)] (cond (not (sm/validate [:vector ::ctg/grid] value)) - (u/display-not-valid :guides value) + (u/not-valid plugin-id :guides value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :guides "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :guides "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(assoc % :grids value))))))} @@ -1481,10 +1497,10 @@ value (keyword value)] (cond (not (contains? #{:fix :auto} value)) - (u/display-not-valid :horizontalSizing value) + (u/not-valid plugin-id :horizontalSizing value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :horizontalSizing "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :horizontalSizing "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-item-h-sizing value})))))} @@ -1497,10 +1513,10 @@ value (keyword value)] (cond (not (contains? #{:fix :auto} value)) - (u/display-not-valid :verticalSizing value) + (u/not-valid plugin-id :verticalSizing value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :verticalSizing "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :verticalSizing "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsl/update-layout #{id} {:layout-item-v-sizing value})))))} @@ -1524,10 +1540,10 @@ (let [segments (parser/parse-commands value)] (cond (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :content "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :content "Plugin doesn't have 'content:write' permission") (not (sm/validate path/schema:segments segments)) - (u/display-not-valid :content segments) + (u/not-valid plugin-id :content segments) :else (let [selrect (path/calc-selrect segments) @@ -1550,13 +1566,13 @@ value)] (cond (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :content "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :content "Plugin doesn't have 'content:write' permission") (not (cfh/path-shape? data)) - (u/display-not-valid :content-type type) + (u/not-valid plugin-id :content-type type) (not (sm/validate path/schema:segments segments)) - (u/display-not-valid :content segments) + (u/not-valid plugin-id :content segments) :else (let [selrect (path/calc-selrect segments) diff --git a/frontend/src/app/plugins/text.cljs b/frontend/src/app/plugins/text.cljs index 1a1e83ac200..1154af2366d 100644 --- a/frontend/src/app/plugins/text.cljs +++ b/frontend/src/app/plugins/text.cljs @@ -8,9 +8,10 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.geom.shapes.text :as gst] [app.common.record :as crc] [app.common.schema :as sm] - [app.common.types.shape :as cts] + [app.common.types.fills :as types.fills] [app.common.types.text :as txt] [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.texts :as dwt] @@ -118,10 +119,10 @@ variant (fonts/get-default-variant font)] (cond (not font) - (u/display-not-valid :fontId value) + (u/not-valid plugin-id :fontId value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :fontId "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :fontId "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-text-range id start end (font-data font variant))))))} @@ -140,10 +141,10 @@ variant (fonts/get-default-variant font)] (cond (not (string? value)) - (u/display-not-valid :fontFamily value) + (u/not-valid plugin-id :fontFamily value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :fontFamily "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :fontFamily "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-text-range id start end (font-data font variant))))))} @@ -161,10 +162,10 @@ variant (fonts/get-variant font value)] (cond (not (string? value)) - (u/display-not-valid :fontVariantId value) + (u/not-valid plugin-id :fontVariantId value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :fontVariantId "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :fontVariantId "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-text-range id start end (variant-data variant))))))} @@ -181,10 +182,10 @@ (let [value (str/trim (dm/str value))] (cond (or (empty? value) (not (re-matches font-size-re value))) - (u/display-not-valid :fontSize value) + (u/not-valid plugin-id :fontSize value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :fontSize "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :fontSize "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-text-range id start end {:font-size value})))))} @@ -208,10 +209,10 @@ (fonts/find-variant font {:weight weight}))] (cond (nil? variant) - (u/display-not-valid :fontWeight (dm/str "Font weight '" value "' not supported for the current font")) + (u/not-valid plugin-id :fontWeight (dm/str "Font weight '" value "' not supported for the current font")) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :fontWeight "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :fontWeight "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-text-range id start end (variant-data variant))))))} @@ -234,10 +235,10 @@ (fonts/find-variant font {:style style}))] (cond (nil? variant) - (u/display-not-valid :fontStyle (dm/str "Font style '" value "' not supported for the current font")) + (u/not-valid plugin-id :fontStyle (dm/str "Font style '" value "' not supported for the current font")) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :fontStyle "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :fontStyle "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-text-range id start end (variant-data variant))))))} @@ -254,10 +255,10 @@ (let [value (str/trim (dm/str value))] (cond (or (empty? value) (not (re-matches line-height-re value))) - (u/display-not-valid :lineHeight value) + (u/not-valid plugin-id :lineHeight value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :lineHeight "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :lineHeight "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-text-range id start end {:line-height value})))))} @@ -274,10 +275,10 @@ (let [value (str/trim (dm/str value))] (cond (or (empty? value) (re-matches letter-spacing-re value)) - (u/display-not-valid :letterSpacing value) + (u/not-valid plugin-id :letterSpacing value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :letterSpacing "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :letterSpacing "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-text-range id start end {:letter-spacing value})))))} @@ -293,10 +294,10 @@ (fn [_ value] (cond (and (string? value) (not (re-matches text-transform-re value))) - (u/display-not-valid :textTransform value) + (u/not-valid plugin-id :textTransform value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :textTransform "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :textTransform "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-text-range id start end {:text-transform value}))))} @@ -312,10 +313,10 @@ (fn [_ value] (cond (and (string? value) (re-matches text-decoration-re value)) - (u/display-not-valid :textDecoration value) + (u/not-valid plugin-id :textDecoration value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :textDecoration "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :textDecoration "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-text-range id start end {:text-decoration value}))))} @@ -331,10 +332,10 @@ (fn [_ value] (cond (and (string? value) (re-matches text-direction-re value)) - (u/display-not-valid :direction value) + (u/not-valid plugin-id :direction value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :direction "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :direction "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-text-range id start end {:direction value}))))} @@ -350,10 +351,10 @@ (fn [_ value] (cond (and (string? value) (re-matches text-align-re value)) - (u/display-not-valid :align value) + (u/not-valid plugin-id :align value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :align "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :align "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-text-range id start end {:text-align value}))))} @@ -369,11 +370,11 @@ (fn [_ value] (let [value (parser/parse-fills value)] (cond - (not (sm/validate [:vector ::cts/fill] value)) - (u/display-not-valid :fills value) + (not (sm/validate [:vector types.fills/schema:fill] value)) + (u/not-valid plugin-id :fills value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :fills "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :fills "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-text-range id start end {:fills value})))))} @@ -400,10 +401,10 @@ ;; editor as well (cond (or (not (string? value)) (empty? value)) - (u/display-not-valid :characters value) + (u/not-valid plugin-id :characters value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :characters "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :characters "Plugin doesn't have 'content:write' permission") (contains? (:workspace-editor-state @st/state) id) (let [shape (u/proxy->shape self) @@ -427,10 +428,10 @@ value (keyword value)] (cond (not (contains? #{:auto-width :auto-height :fixed} value)) - (u/display-not-valid :growType value) + (u/not-valid plugin-id :growType value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :growType "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :growType "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwsh/update-shapes [id] #(assoc % :grow-type value))))))} @@ -444,10 +445,10 @@ variant (fonts/get-default-variant font)] (cond (not font) - (u/display-not-valid :fontId value) + (u/not-valid plugin-id :fontId value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :fontId "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :fontId "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-attrs id (font-data font variant))))))} @@ -461,10 +462,10 @@ variant (fonts/get-default-variant font)] (cond (not font) - (u/display-not-valid :fontFamily value) + (u/not-valid plugin-id :fontFamily value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :fontFamily "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :fontFamily "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-attrs id (font-data font variant))))))} @@ -478,10 +479,10 @@ variant (fonts/get-variant font value)] (cond (not variant) - (u/display-not-valid :fontVariantId value) + (u/not-valid plugin-id :fontVariantId value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :fontVariantId "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :fontVariantId "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-attrs id (variant-data variant))))))} @@ -494,10 +495,10 @@ value (str/trim (dm/str value))] (cond (or (empty? value) (not (re-matches font-size-re value))) - (u/display-not-valid :fontSize value) + (u/not-valid plugin-id :fontSize value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :fontSize "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :fontSize "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-attrs id {:font-size value})))))} @@ -516,10 +517,10 @@ (fonts/find-variant font {:weight weight}))] (cond (nil? variant) - (u/display-not-valid :fontWeight (dm/str "Font weight '" value "' not supported for the current font")) + (u/not-valid plugin-id :fontWeight (dm/str "Font weight '" value "' not supported for the current font")) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :fontWeight "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :fontWeight "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-attrs id (variant-data variant))))))} @@ -538,10 +539,10 @@ (fonts/find-variant font {:style style}))] (cond (nil? variant) - (u/display-not-valid :fontStyle (dm/str "Font style '" value "' not supported for the current font")) + (u/not-valid plugin-id :fontStyle (dm/str "Font style '" value "' not supported for the current font")) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :fontStyle "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :fontStyle "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-attrs id (variant-data variant))))))} @@ -554,10 +555,10 @@ value (str/trim (dm/str value))] (cond (or (empty? value) (not (re-matches line-height-re value))) - (u/display-not-valid :lineHeight value) + (u/not-valid plugin-id :lineHeight value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :lineHeight "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :lineHeight "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-attrs id {:line-height value})))))} @@ -570,10 +571,10 @@ value (str/trim (dm/str value))] (cond (or (not (string? value)) (not (re-matches letter-spacing-re value))) - (u/display-not-valid :letterSpacing value) + (u/not-valid plugin-id :letterSpacing value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :letterSpacing "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :letterSpacing "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-attrs id {:letter-spacing value})))))} @@ -585,10 +586,10 @@ (let [id (obj/get self "$id")] (cond (or (not (string? value)) (not (re-matches text-transform-re value))) - (u/display-not-valid :textTransform value) + (u/not-valid plugin-id :textTransform value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :textTransform "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :textTransform "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-attrs id {:text-transform value})))))} @@ -600,10 +601,10 @@ (let [id (obj/get self "$id")] (cond (or (not (string? value)) (not (re-matches text-decoration-re value))) - (u/display-not-valid :textDecoration value) + (u/not-valid plugin-id :textDecoration value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :textDecoration "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :textDecoration "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-attrs id {:text-decoration value})))))} @@ -615,10 +616,10 @@ (let [id (obj/get self "$id")] (cond (or (not (string? value)) (not (re-matches text-direction-re value))) - (u/display-not-valid :textDirection value) + (u/not-valid plugin-id :textDirection value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :textDirection "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :textDirection "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-attrs id {:text-direction value})))))} @@ -630,10 +631,10 @@ (let [id (obj/get self "$id")] (cond (or (not (string? value)) (not (re-matches text-align-re value))) - (u/display-not-valid :align value) + (u/not-valid plugin-id :align value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :align "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :align "Plugin doesn't have 'content:write' permission") :else (st/emit! (dwt/update-attrs id {:text-align value})))))} @@ -645,10 +646,13 @@ (let [id (obj/get self "$id")] (cond (or (not (string? value)) (not (re-matches vertical-align-re value))) - (u/display-not-valid :verticalAlign value) + (u/not-valid plugin-id :verticalAlign value) (not (r/check-permission plugin-id "content:write")) - (u/display-not-valid :verticalAlign "Plugin doesn't have 'content:write' permission") + (u/not-valid plugin-id :verticalAlign "Plugin doesn't have 'content:write' permission") :else - (st/emit! (dwt/update-attrs id {:vertical-align value})))))})) + (st/emit! (dwt/update-attrs id {:vertical-align value})))))} + + {:name "textBounds" + :get #(-> % u/proxy->shape gst/shape->bounds format/format-geom-rect)})) diff --git a/frontend/src/app/plugins/tokens.cljs b/frontend/src/app/plugins/tokens.cljs index 268652e334b..13888f04813 100644 --- a/frontend/src/app/plugins/tokens.cljs +++ b/frontend/src/app/plugins/tokens.cljs @@ -19,18 +19,58 @@ [app.main.store :as st] [app.plugins.utils :as u] [app.util.object :as obj] - [clojure.datafy :refer [datafy]])) + [clojure.datafy :refer [datafy]] + [clojure.set :refer [map-invert]])) ;; === Token +;; Give more semantic names to the shape attributes that tokens can be applied to +(def ^:private map:token-attr->token-attr-plugin + {:r1 :border-radius-top-left + :r2 :border-radius-top-right + :r3 :border-radius-bottom-right + :r4 :border-radius-bottom-left + + :p1 :padding-top-left + :p2 :padding-top-right + :p3 :padding-bottom-right + :p4 :padding-bottom-left + + :m1 :margin-top-left + :m2 :margin-top-right + :m3 :margin-bottom-right + :m4 :margin-bottom-left}) + +(def ^:private map:token-attr-plugin->token-attr + (map-invert map:token-attr->token-attr-plugin)) + +(defn token-attr->token-attr-plugin + [k] + (get map:token-attr->token-attr-plugin k k)) + +(defn token-attr-plugin->token-attr + [k] + (get map:token-attr-plugin->token-attr k k)) + +(defn applied-tokens-plugin->applied-tokens + [value] + (into {} + (map (fn [[k v]] [(token-attr->token-attr-plugin k) v])) + value)) + +(defn token-attr? + [attr] + (cto/token-attr? (token-attr-plugin->token-attr attr))) + (defn- apply-token-to-shapes - [file-id set-id id shape-ids attrs] + [plugin-id file-id set-id id shape-ids attrs] + (let [token (u/locate-token file-id set-id id)] - (if (some #(not (cto/token-attr? %)) attrs) - (u/display-not-valid :applyToSelected attrs) + (if (some #(not (token-attr? %)) attrs) + (u/not-valid plugin-id :applyToSelected attrs) (st/emit! (dwta/toggle-token {:token token - :attrs attrs + :attrs (into #{} (map token-attr-plugin->token-attr) attrs) :shape-ids shape-ids :expand-with-children false}))))) @@ -52,7 +92,7 @@ (defn token-proxy [plugin-id file-id set-id id] (obj/reify {:name "TokenProxy" - :on-error u/handle-error} + :on-error (u/handle-error plugin-id)} :$plugin {:enumerable false :get (constantly plugin-id)} :$file-id {:enumerable false :get (constantly file-id)} :$set-id {:enumerable false :get (constantly set-id)} @@ -146,16 +186,16 @@ {:enumerable false :schema [:tuple [:vector [:fn shape-proxy?]] - [:maybe [:set [:and ::sm/keyword [:fn cto/token-attr?]]]]] + [:maybe [:set [:and ::sm/keyword [:fn token-attr?]]]]] :fn (fn [shapes attrs] - (apply-token-to-shapes file-id set-id id (map #(obj/get % "$id") shapes) attrs))} + (apply-token-to-shapes plugin-id file-id set-id id (map #(obj/get % "$id") shapes) attrs))} :applyToSelected {:enumerable false - :schema [:tuple [:maybe [:set [:and ::sm/keyword [:fn cto/token-attr?]]]]] + :schema [:tuple [:maybe [:set [:and ::sm/keyword [:fn token-attr?]]]]] :fn (fn [attrs] (let [selected (get-in @st/state [:workspace-local :selected])] - (apply-token-to-shapes file-id set-id id selected attrs)))})) + (apply-token-to-shapes plugin-id file-id set-id id selected attrs)))})) ;; === Token Set @@ -165,7 +205,7 @@ (defn token-set-proxy [plugin-id file-id id] (obj/reify {:name "TokenSetProxy" - :on-error u/handle-error} + :on-error (u/handle-error plugin-id)} :$plugin {:enumerable false :get (constantly plugin-id)} :$file-id {:enumerable false :get (constantly file-id)} :$id {:enumerable false :get (constantly id)} @@ -247,15 +287,19 @@ :addToken {:enumerable false :schema (fn [args] - [:tuple (-> (cfo/make-token-schema - (-> (u/locate-tokens-lib file-id) (ctob/get-tokens id)) - (cto/dtcg-token-type->token-type (-> args (first) (get "type")))) - ;; Don't allow plugins to set the id - (sm/dissoc-key :id) - ;; Instruct the json decoder in obj/reify not to process map keys (:key-fn below) - ;; and set a converter that changes DTCG types to internal types (:decode/json). - ;; E.g. "FontFamilies" -> :font-family or "BorderWidth" -> :stroke-width - (sm/update-properties assoc :decode/json cfo/convert-dtcg-token))]) + (let [tokens-tree (-> (u/locate-tokens-lib file-id) + (ctob/get-tokens id) + ;; Convert to the adecuate format for schema + (ctob/tokens-tree))] + [:tuple (-> (cfo/make-token-schema + tokens-tree + (cto/dtcg-token-type->token-type (-> args (first) (get "type")))) + ;; Don't allow plugins to set the id + (sm/dissoc-key :id) + ;; Instruct the json decoder in obj/reify not to process map keys (:key-fn below) + ;; and set a converter that changes DTCG types to internal types (:decode/json). + ;; E.g. "FontFamilies" -> :font-family or "BorderWidth" -> :stroke-width + (sm/update-properties assoc :decode/json cfo/convert-dtcg-token))])) :decode/options {:key-fn identity} :fn (fn [attrs] (let [tokens-lib (u/locate-tokens-lib file-id) @@ -270,7 +314,7 @@ (if resolved-value (do (st/emit! (dwtl/create-token id token)) (token-proxy plugin-id file-id id (:id token))) - (do (u/display-not-valid :addToken (str errors)) + (do (u/not-valid plugin-id :addToken (str errors)) nil))))} :duplicate @@ -287,7 +331,7 @@ (defn token-theme-proxy [plugin-id file-id id] (obj/reify {:name "TokenThemeProxy" - :on-error u/handle-error} + :on-error (u/handle-error plugin-id)} :$plugin {:enumerable false :get (constantly plugin-id)} :$file-id {:enumerable false :get (constantly file-id)} :$id {:enumerable false :get (constantly id)} @@ -394,7 +438,7 @@ (defn tokens-catalog [plugin-id file-id] (obj/reify {:name "TokensCatalog" - :on-error u/handle-error} + :on-error (u/handle-error plugin-id)} :$plugin {:enumerable false :get (constantly plugin-id)} :$id {:enumerable false :get (constantly file-id)} diff --git a/frontend/src/app/plugins/utils.cljs b/frontend/src/app/plugins/utils.cljs index dfb8242a121..19de73fcde8 100644 --- a/frontend/src/app/plugins/utils.cljs +++ b/frontend/src/app/plugins/utils.cljs @@ -9,14 +9,17 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] - [app.common.json :as json] + [app.common.i18n :as i18n :refer [tr]] [app.common.schema :as sm] + [app.common.schema.messages :as csm] + [app.common.types.component :as ctk] [app.common.types.container :as ctn] [app.common.types.file :as ctf] [app.common.types.tokens-lib :as ctob] [app.main.data.helpers :as dsh] [app.main.store :as st] - [app.util.object :as obj])) + [app.util.object :as obj] + [cuerdas.core :as str])) (defn locate-file [id] @@ -221,6 +224,16 @@ (resolve value)))))] [ret-v ret-p])) +(defn natural-child-ordering? + [plugin-id] + (boolean + (dm/get-in @st/state [:plugins :flags plugin-id :natural-child-ordering]))) + +(defn throw-validation-errors? + [plugin-id] + (boolean + (dm/get-in @st/state [:plugins :flags plugin-id :throw-validation-errors]))) + (defn display-not-valid [code value] (if (some? value) @@ -228,34 +241,54 @@ (.error js/console (dm/str "[PENPOT PLUGIN] Value not valid. Code: " code))) nil) +(defn throw-not-valid + [code value] + (if (some? value) + (throw (js/Error. (dm/str "[PENPOT PLUGIN] Value not valid: " value ". Code: " code))) + (throw (js/Error. (dm/str "[PENPOT PLUGIN] Value not valid. Code: " code)))) + nil) + +(defn not-valid + [plugin-id code value] + (if (throw-validation-errors? plugin-id) + (throw-not-valid code value) + (display-not-valid code value))) + (defn reject-not-valid [reject code value] (let [msg (dm/str "[PENPOT PLUGIN] Value not valid: " value ". Code: " code)] (.error js/console msg) (reject msg))) -(defn coerce - "Decodes a javascript object into clj and check against schema. If schema validation fails, - displays a not-valid message with the code and hint provided and returns nil." - [attrs schema code hint] - (let [decoder (sm/decoder schema sm/json-transformer) - explainer (sm/explainer schema) - attrs (-> attrs json/->clj decoder)] - (if-let [explain (explainer attrs)] - (display-not-valid code (str hint " " (sm/humanize-explain explain))) - attrs))) - (defn mixed-value [values] (let [s (set values)] (if (= (count s) 1) (first s) "mixed"))) +(defn error-messages + [explain] + (->> (:errors explain) + (reduce csm/interpret-schema-problem {}) + (mapcat (comp seq val)) + (map (fn [[field {:keys [message]}]] + (tr "plugins.validation.message" (name field) message))) + (str/join ". "))) + (defn handle-error "Function to be used in plugin proxies methods to handle errors and print a readable message to the console." - [cause] - (display-not-valid (ex-message cause) nil) - (if-let [explain (-> cause ex-data ::sm/explain)] - (println (sm/humanize-explain explain)) - (js/console.log (ex-data cause))) - (js/console.log (.-stack cause))) \ No newline at end of file + [plugin-id] + (fn [cause] + (let [message + (if-let [explain (-> cause ex-data ::sm/explain)] + (do + (js/console.error (sm/humanize-explain explain)) + (error-messages explain)) + (ex-data cause))] + (js/console.log (.-stack cause)) + (not-valid plugin-id :error message)))) + +(defn is-main-component-proxy? + [p] + (when-let [shape (proxy->shape p)] + (ctk/main-instance? shape))) diff --git a/frontend/src/app/plugins/viewport.cljs b/frontend/src/app/plugins/viewport.cljs index a581e3ba608..8b5b73ac577 100644 --- a/frontend/src/app/plugins/viewport.cljs +++ b/frontend/src/app/plugins/viewport.cljs @@ -38,10 +38,10 @@ new-y (obj/get value "y")] (cond (not (sm/valid-safe-number? new-x)) - (u/display-not-valid :center-x new-x) + (u/not-valid plugin-id :center-x new-x) (not (sm/valid-safe-number? new-y)) - (u/display-not-valid :center-y new-y) + (u/not-valid plugin-id :center-y new-y) :else (let [vb (dm/get-in @st/state [:workspace-local :vbox]) @@ -63,7 +63,7 @@ (fn [value] (cond (not (sm/valid-safe-number? value)) - (u/display-not-valid :zoom value) + (u/not-valid plugin-id :zoom value) :else (let [z (dm/get-in @st/state [:workspace-local :zoom])] @@ -87,7 +87,7 @@ (fn [shapes] (cond (not (every? ps/shape-proxy? shapes)) - (u/display-not-valid :zoomIntoView "Argument should be valid shapes") + (u/not-valid plugin-id :zoomIntoView "Argument should be valid shapes") :else (let [ids (->> shapes diff --git a/frontend/src/app/util/forms.cljs b/frontend/src/app/util/forms.cljs index 253d32470cd..58bbe3569a1 100644 --- a/frontend/src/app/util/forms.cljs +++ b/frontend/src/app/util/forms.cljs @@ -10,84 +10,10 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.schema :as sm] - [app.util.i18n :as i18n :refer [tr]] + [app.common.schema.messages :as csm] [cuerdas.core :as str] - [malli.core :as m] [rumext.v2 :as mf])) -;; --- Handlers Helpers - -(defn- translate-code - [code] - (if (vector? code) - (tr (nth code 0) (i18n/c (nth code 1))) - (tr code))) - -(defn- handle-error-fn - [props problem] - (let [v-fn (:error/fn props) - result (v-fn problem)] - (if (string? result) - {:message result} - {:message (or (some-> (get result :code) - (translate-code)) - (get result :message) - (tr "errors.invalid-data"))}))) - -(defn- handle-error-message - [props] - {:message (get props :error/message)}) - -(defn- handle-error-code - [props] - (let [code (get props :error/code)] - {:message (translate-code code)})) - -(defn- interpret-schema-problem - [acc {:keys [schema in value type] :as problem}] - (let [props (m/properties schema) - tprops (m/type-properties schema) - field (or (:error/field props) - in) - field (if (vector? field) - field - [field])] - - (if (and (= 1 (count field)) - (contains? acc (first field))) - acc - (cond - (or (nil? field) - (empty? field)) - acc - - (or (= type :malli.core/missing-key) - (nil? value)) - (assoc-in acc field {:message (tr "errors.field-missing")}) - - ;; --- CHECK on schema props - (contains? props :error/fn) - (assoc-in acc field (handle-error-fn props problem)) - - (contains? props :error/message) - (assoc-in acc field (handle-error-message props)) - - (contains? props :error/code) - (assoc-in acc field (handle-error-code props)) - - ;; --- CHECK on type props - (contains? tprops :error/fn) - (assoc-in acc field (handle-error-fn tprops problem)) - - (contains? tprops :error/message) - (assoc-in acc field (handle-error-message tprops)) - - (contains? tprops :error/code) - (assoc-in acc field (handle-error-code tprops)) - - :else - (assoc-in acc field {:message (tr "errors.invalid-data")}))))) - (defn- use-rerender-fn [] (let [state (mf/useState 0) @@ -97,24 +23,6 @@ (fn [] (render-fn inc))))) -(defn- apply-validators - [validators state errors] - (reduce (fn [errors validator-fn] - (merge errors (validator-fn errors (:data state)))) - errors - validators)) - -(defn- collect-schema-errors - [schema validators state] - (let [explain (sm/explain schema (:data state)) - errors (->> (reduce interpret-schema-problem {} (:errors explain)) - (apply-validators validators state))] - - (-> (:errors state) - (merge errors) - (d/without-nils) - (not-empty)))) - (defn- wrap-update-schema-fn [f {:keys [schema validators]}] (fn [& args] @@ -124,7 +32,7 @@ errors (when-not valid? - (collect-schema-errors schema validators state)) + (csm/collect-schema-errors schema validators state)) extra-errors (not-empty (:extra-errors state))] diff --git a/frontend/src/debug.cljs b/frontend/src/debug.cljs index fa2641c7b3f..e586dc3d1a0 100644 --- a/frontend/src/debug.cljs +++ b/frontend/src/debug.cljs @@ -462,3 +462,9 @@ (defn print-last-exception [] (some-> errors/last-exception ex/print-throwable)) + + +(defn ^:export dbg + [o] + (app.common.pprint/pprint o {:level 100 :length 100})) + diff --git a/frontend/translations/en.po b/frontend/translations/en.po index a39cfaf85ce..bfe1c4b6fee 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -338,77 +338,6 @@ msgstr "You're going to restore %s." msgid "dashboard-restore-file-confirmation.title" msgstr "Restore file" -#: src/app/main/ui/settings/access_tokens.cljs:103 -msgid "dashboard.access-tokens.copied-success" -msgstr "Copied token" - -#: src/app/main/ui/settings/access_tokens.cljs:189 -msgid "dashboard.access-tokens.create" -msgstr "Generate new token" - -#: src/app/main/ui/settings/access_tokens.cljs:64 -msgid "dashboard.access-tokens.create.success" -msgstr "Access token created successfully." - -#: src/app/main/ui/settings/access_tokens.cljs:286 -msgid "dashboard.access-tokens.empty.add-one" -msgstr "Press the button \"Generate new token\" to generate one." - -#: src/app/main/ui/settings/access_tokens.cljs:285 -msgid "dashboard.access-tokens.empty.no-access-tokens" -msgstr "You have no tokens so far." - -#: src/app/main/ui/settings/access_tokens.cljs:135 -msgid "dashboard.access-tokens.expiration-180-days" -msgstr "180 days" - -#: src/app/main/ui/settings/access_tokens.cljs:132 -msgid "dashboard.access-tokens.expiration-30-days" -msgstr "30 days" - -#: src/app/main/ui/settings/access_tokens.cljs:133 -msgid "dashboard.access-tokens.expiration-60-days" -msgstr "60 days" - -#: src/app/main/ui/settings/access_tokens.cljs:134 -msgid "dashboard.access-tokens.expiration-90-days" -msgstr "90 days" - -#: src/app/main/ui/settings/access_tokens.cljs:131 -msgid "dashboard.access-tokens.expiration-never" -msgstr "Never" - -#: src/app/main/ui/settings/access_tokens.cljs:268 -msgid "dashboard.access-tokens.expired-on" -msgstr "Expired on %s" - -#: src/app/main/ui/settings/access_tokens.cljs:269 -msgid "dashboard.access-tokens.expires-on" -msgstr "Expires on %s" - -#: src/app/main/ui/settings/access_tokens.cljs:267 -msgid "dashboard.access-tokens.no-expiration" -msgstr "No expiration date" - -#: src/app/main/ui/settings/access_tokens.cljs:184 -msgid "dashboard.access-tokens.personal" -msgstr "Personal access tokens" - -#: src/app/main/ui/settings/access_tokens.cljs:185 -msgid "dashboard.access-tokens.personal.description" -msgstr "" -"Personal access tokens function like an alternative to our login/password " -"authentication system and can be used to allow an application to access the " -"internal Penpot API" - -#: src/app/main/ui/settings/access_tokens.cljs:142 -msgid "dashboard.access-tokens.token-will-expire" -msgstr "The token will expire on %s" - -#: src/app/main/ui/settings/access_tokens.cljs:143 -msgid "dashboard.access-tokens.token-will-not-expire" -msgstr "The token has no expiration date" - #: src/app/main/ui/dashboard/placeholder.cljs:41 msgid "dashboard.add-file" msgstr "Add file" @@ -2138,6 +2067,237 @@ msgstr "Resolved value:" msgid "inspect.tabs.styles.variants-panel" msgstr "Variant Properties" +#: src/app/main/ui/settings/integrations.cljs:189 +msgid "integrations.access-tokens.create" +msgstr "Create new access token" + +#: src/app/main/ui/settings/integrations.cljs:286 +msgid "integrations.access-tokens.empty.add-one" +msgstr "Press the button \"Create new access token\" to generate one." + +#: src/app/main/ui/settings/integrations.cljs:285 +msgid "integrations.access-tokens.empty.no-access-tokens" +msgstr "You have no tokens so far." + +#: src/app/main/ui/settings/integrations.cljs:184 +msgid "integrations.access-tokens.personal" +msgstr "Personal access tokens" + +#: src/app/main/ui/settings/integrations.cljs:185 +msgid "integrations.access-tokens.personal.description" +msgstr "" +"Personal access tokens function like an alternative to our login/password " +"authentication system and can be used to allow an application to access the " +"internal Penpot API" + +#: src/app/main/ui/settings/integrations.cljs:152, src/app/main/ui/settings/integrations.cljs:158 +msgid "integrations.copy-to-clipboard" +msgstr "Copy to clipboard" + +#: src/app/main/ui/settings/integrations.cljs:432 +msgid "integrations.create-access-token.title" +msgstr "Create access token" + +#: src/app/main/ui/settings/integrations.cljs:433 +msgid "integrations.create-access-token.title.created" +msgstr "Access token created" + +#: src/app/main/ui/settings/integrations.cljs:257 +msgid "integrations.delete-token.accept" +msgstr "Delete token" + +#: src/app/main/ui/settings/integrations.cljs:256 +msgid "integrations.delete-token.message" +msgstr "Are you sure you want to delete this token?" + +#: src/app/main/ui/settings/integrations.cljs:255 +msgid "integrations.delete-token.title" +msgstr "Delete token" + +#: src/app/main/ui/settings/integrations.cljs:135 +msgid "integrations.expiration-180-days" +msgstr "180 days" + +#: src/app/main/ui/settings/integrations.cljs:132 +msgid "integrations.expiration-30-days" +msgstr "30 days" + +#: src/app/main/ui/settings/integrations.cljs:133 +msgid "integrations.expiration-60-days" +msgstr "60 days" + +#: src/app/main/ui/settings/integrations.cljs:134 +msgid "integrations.expiration-90-days" +msgstr "90 days" + +#: src/app/main/ui/settings/integrations.cljs:131 +msgid "integrations.expiration-never" +msgstr "Never" + +#: src/app/main/ui/settings/integrations.cljs:268 +msgid "integrations.expired-on" +msgstr "Expired on %s" + +#: src/app/main/ui/settings/integrations.cljs:269 +msgid "integrations.expires-on" +msgstr "Expires on %s" + +#: src/app/main/ui/settings/integrations.cljs:267 +msgid "integrations.no-expiration" +msgstr "No expiration date" + +#: src/app/main/ui/settings/integrations.cljs:130 +msgid "integrations.expiration-date.label" +msgstr "Expiration date" + +#: src/app/main/ui/settings/integrations.cljs:290 +msgid "integrations.generate-mcp-key.title" +msgstr "Generate MCP key" + +#: src/app/main/ui/settings/integrations.cljs:291 +msgid "integrations.generate-mcp-key.title.created" +msgstr "MCP key generated" + +#: src/app/main/ui/settings/integrations.cljs:113 +msgid "integrations.info.mcp-client-config" +msgstr "Add this configuration to your MCP client (e.g. ~/​​​​​​.mcp.json)." + +#: src/app/main/ui/settings/integrations.cljs:183 +msgid "integrations.info.mcp-server" +msgstr "The Penpot MCP Server enables MCP clients to interact directly with Penpot design files." + +#: src/app/main/ui/settings/integrations.cljs:131 +msgid "integrations.token.info.non-recuperable" +msgstr "This unique token is non-recuperable. If you lose it, you will need to create a new one." + +#: src/app/main/ui/settings/integrations.cljs:131 +#: src/app/main/ui/settings/integrations.cljs:131 +msgid "integrations.mcp-key.info.non-recuperable" +msgstr "This unique MCP key is non-recoverable. If you lose it, you will need to create a new one." + +#: src/app/main/ui/settings/integrations.cljs:336 +msgid "integrations.mcp-server.title" +msgstr "MCP Server" + +#: src/app/main/ui/settings/integrations.cljs:336 +msgid "integrations.mcp-server.title.beta" +msgstr "Beta" + +#: src/app/main/ui/settings/integrations.cljs:347 +msgid "integrations.mcp-server.description" +msgstr "The Penpot MCP Server enables MCP clients to interact directly with Penpot design files." + +#: src/app/main/ui/settings/integrations.cljs:353 +msgid "integrations.mcp-server.status" +msgstr "Status" + +#: src/app/main/ui/settings/integrations.cljs:370 +msgid "integrations.mcp-server.status.disabled" +msgstr "Disabled" + +#: src/app/main/ui/settings/integrations.cljs:370 +msgid "integrations.mcp-server.status.enabled" +msgstr "Enabled" + +#: src/app/main/ui/settings/integrations.cljs:363 +msgid "integrations.mcp-server.status.expired.0" +msgstr "The MCP key used to connect to the MCP server has expired. As a result, the connection cannot be established." + +#: src/app/main/ui/settings/integrations.cljs:368 +msgid "integrations.mcp-server.status.expired.1" +msgstr "Please regenerate the MCP key and update your client configuration with the new key." + +#: src/app/main/ui/settings/integrations.cljs:415 +msgid "integrations.mcp-server.mcp-keys.copy" +msgstr "Copy link" + +#: src/app/main/ui/settings/integrations.cljs:422 +msgid "integrations.mcp-server.mcp-keys.help" +msgstr "How to configure MCP clients" + +#: src/app/main/ui/settings/integrations.cljs:405 +msgid "integrations.mcp-server.mcp-keys.info" +msgstr "This is the server url you'll need to configure your MCP client in order to connect it to the Penpot MCP server." + +#: src/app/main/ui/settings/integrations.cljs:387 +msgid "integrations.mcp-server.mcp-keys.regenerate" +msgstr "Regenerate MCP key" + +#: src/app/main/ui/settings/integrations.cljs:381 +msgid "integrations.mcp-server.mcp-keys.title" +msgstr "MCP key" + +#: src/app/main/ui/settings/integrations.cljs:388 +msgid "integrations.mcp-server.mcp-keys.tootip" +msgstr "The MCP key is needed for the MCP client set up" + +#: src/app/main/ui/settings/integrations.cljs:124 +msgid "integrations.name.label" +msgstr "Name" + +#: src/app/main/ui/settings/integrations.cljs:126 +msgid "integrations.name.placeholder" +msgstr "The name can help to know what's the token for" + +#: src/app/main/ui/settings/integrations.cljs:103 +msgid "integrations.notification.success.token-copied" +msgstr "Copied token" + +#: src/app/main/ui/settings/integrations.cljs:103 +#: src/app/main/ui/settings/integrations.cljs:103 +msgid "integrations.notification.success.mcp-key-copied" +msgstr "MCP key copied" + +#: src/app/main/ui/settings/integrations.cljs:64 +msgid "integrations.notification.success.created" +msgstr "Token created successfully" + +#: src/app/main/ui/settings/integrations.cljs:327 +msgid "integrations.notification.success.copied-link" +msgstr "Link copied to clipboard" + +#: src/app/main/ui/settings/integrations.cljs:293 +msgid "integrations.notification.success.mcp-server-disabled" +msgstr "MCP server disabled" + +#: src/app/main/ui/settings/integrations.cljs:299 +msgid "integrations.notification.success.mcp-server-enabled" +msgstr "MCP server enabled" + +#: src/app/main/ui/settings/integrations.cljs:317 +msgid "integrations.regenerate-mcp-key.info" +msgstr "Regenerating the MCP key will immediately revoke the current one. Any application using it will stop working." + +#: src/app/main/ui/settings/integrations.cljs:317 +msgid "integrations.regenerate-mcp-key.title" +msgstr "Regenerate MCP key" + +#: src/app/main/ui/settings/integrations.cljs:318 +msgid "integrations.regenerate-mcp-key.title.created" +msgstr "MCP key regenerated" + +#: src/app/main/ui/settings/integrations.cljs:480 +msgid "integrations.title" +msgstr "Integrations" + +#: src/app/main/ui/settings/integrations.cljs:142 +msgid "integrations.token.will-expire" +msgstr "The token will expire on %s" + +#: src/app/main/ui/settings/integrations.cljs:142 +#: src/app/main/ui/settings/integrations.cljs:142 +msgid "integrations.mcp-key.will-expire" +msgstr "The MCP key will expire on %s" + +#: src/app/main/ui/settings/integrations.cljs:143 +msgid "integrations.token.will-not-expire" +msgstr "The token has no expiration date" + +#: src/app/main/ui/settings/integrations.cljs:143 +#: src/app/main/ui/settings/integrations.cljs:143 +msgid "integrations.mcp-key.will-not-expire" +msgstr "The MCP key has no expiration date" + #: src/app/main/ui/dashboard/comments.cljs:96 msgid "label.mark-all-as-read" msgstr "Mark all as read" @@ -2354,6 +2514,9 @@ msgstr "Director" msgid "labels.discard" msgstr "Discard" +msgid "labels.dismiss" +msgstr "Dismiss" + #: src/app/main/ui/settings/feedback.cljs:134, src/app/main/ui/static.cljs:409 msgid "labels.download" msgstr "Download %s" @@ -2489,6 +2652,10 @@ msgstr "Info" msgid "labels.installed-fonts" msgstr "Installed fonts" +#: src/app/main/ui/settings/sidebar.cljs:123 +msgid "labels.integrations" +msgstr "Integrations" + #: src/app/main/ui/static.cljs:405 msgid "labels.internal-error.desc-message-first" msgstr "Something bad happened." @@ -3148,30 +3315,6 @@ msgstr "Change email" msgid "modals.change-email.title" msgstr "Change your email" -#: src/app/main/ui/settings/access_tokens.cljs:152, src/app/main/ui/settings/access_tokens.cljs:158 -msgid "modals.create-access-token.copy-token" -msgstr "Copy token" - -#: src/app/main/ui/settings/access_tokens.cljs:130 -msgid "modals.create-access-token.expiration-date.label" -msgstr "Expiration date" - -#: src/app/main/ui/settings/access_tokens.cljs:124 -msgid "modals.create-access-token.name.label" -msgstr "Name" - -#: src/app/main/ui/settings/access_tokens.cljs:126 -msgid "modals.create-access-token.name.placeholder" -msgstr "The name can help to know what's the token for" - -#: src/app/main/ui/settings/access_tokens.cljs:178 -msgid "modals.create-access-token.submit-label" -msgstr "Create token" - -#: src/app/main/ui/settings/access_tokens.cljs:111 -msgid "modals.create-access-token.title" -msgstr "Generate access token" - #: src/app/main/ui/dashboard/team.cljs:1127 msgid "modals.create-webhook.submit-label" msgstr "Create webhook" @@ -3188,18 +3331,6 @@ msgstr "Payload URL" msgid "modals.create-webhook.url.placeholder" msgstr "https://example.com/postreceive" -#: src/app/main/ui/settings/access_tokens.cljs:257 -msgid "modals.delete-acces-token.accept" -msgstr "Delete token" - -#: src/app/main/ui/settings/access_tokens.cljs:256 -msgid "modals.delete-acces-token.message" -msgstr "Are you sure you want to delete this token?" - -#: src/app/main/ui/settings/access_tokens.cljs:255 -msgid "modals.delete-acces-token.title" -msgstr "Delete token" - #: src/app/main/ui/settings/delete_account.cljs:56 msgid "modals.delete-account.cancel" msgstr "Cancel and keep my account" @@ -3699,6 +3830,12 @@ msgstr "Invitation sent successfully" msgid "notifications.invitation-link-copied" msgstr "Invitation link copied" +msgid "notifications.mcp.active-in-another-tab" +msgstr "MCP is active in another tab. Switch here?" + +msgid "notifications.mcp.active-in-this-tab" +msgstr "MCP is now active in this tab." + #: src/app/main/ui/settings/delete_account.cljs:24 msgid "notifications.profile-deletion-not-allowed" msgstr "You can't delete your profile. Reassign your teams before proceed." @@ -5093,14 +5230,14 @@ msgstr "Shared Libraries - %s - Penpot" msgid "title.default" msgstr "Penpot - Design Freedom for Teams" -#: src/app/main/ui/settings/access_tokens.cljs:278 -msgid "title.settings.access-tokens" -msgstr "Profile - Access tokens" - #: src/app/main/ui/settings/feedback.cljs:161 msgid "title.settings.feedback" msgstr "Give feedback - Penpot" +#: src/app/main/ui/settings/integrations.cljs:278 +msgid "title.settings.integrations" +msgstr "Integrations - Penpot" + #: src/app/main/ui/settings/notifications.cljs:45 msgid "title.settings.notifications" msgstr "Notifications - Penpot" @@ -5594,6 +5731,18 @@ msgstr "Hide rulers" msgid "workspace.header.menu.hide-textpalette" msgstr "Hide fonts palette" +msgid "workspace.header.menu.mcp.plugin.status.connect" +msgstr "Connect" + +msgid "workspace.header.menu.mcp.plugin.status.disconnect" +msgstr "Disconnect" + +msgid "workspace.header.menu.mcp.server.status.enabled" +msgstr "Manage (Status: enabled)" + +msgid "workspace.header.menu.mcp.server.status.disabled" +msgstr "Manage (Status: disabled)" + #: src/app/main/ui/workspace/main_menu.cljs:884 msgid "workspace.header.menu.option.edit" msgstr "Edit" @@ -5606,6 +5755,9 @@ msgstr "File" msgid "workspace.header.menu.option.help-info" msgstr "Help & info" +msgid "workspace.header.menu.option.mcp" +msgstr "MCP server" + #: src/app/main/ui/workspace/main_menu.cljs:916 #, unused msgid "workspace.header.menu.option.power-up" @@ -7234,11 +7386,14 @@ msgid "workspace.plugins.empty-plugins" msgstr "No plugins installed yet" #: src/app/main/ui/workspace/plugins.cljs:193 -msgid "workspace.plugins.error.manifest" -msgstr "The plugin manifest is incorrect." + msgid "workspace.plugins.error.manifest" + msgstr "The plugin manifest is incorrect." + +msgid "plugins.validation.message" +msgstr "Field %s is invalid: %s" #: src/app/main/data/plugins.cljs:105, src/app/main/ui/workspace/main_menu.cljs:766, src/app/main/ui/workspace/plugins.cljs:84 -msgid "workspace.plugins.error.need-editor" + msgid "workspace.plugins.error.need-editor" msgstr "You need to be an editor to use this plugin" #: src/app/main/ui/workspace/plugins.cljs:189 diff --git a/frontend/translations/es.po b/frontend/translations/es.po index f30b9e12e32..a13e9c56b77 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -347,77 +347,6 @@ msgstr "Vas a restaurar %s." msgid "dashboard-restore-file-confirmation.title" msgstr "Restaurar archivo" -#: src/app/main/ui/settings/access_tokens.cljs:103 -msgid "dashboard.access-tokens.copied-success" -msgstr "Token copiado" - -#: src/app/main/ui/settings/access_tokens.cljs:189 -msgid "dashboard.access-tokens.create" -msgstr "Generar nuevo token" - -#: src/app/main/ui/settings/access_tokens.cljs:64 -msgid "dashboard.access-tokens.create.success" -msgstr "Access token creado con éxito." - -#: src/app/main/ui/settings/access_tokens.cljs:286 -msgid "dashboard.access-tokens.empty.add-one" -msgstr "Pulsa el botón \"Generar nuevo token\" para generar uno." - -#: src/app/main/ui/settings/access_tokens.cljs:285 -msgid "dashboard.access-tokens.empty.no-access-tokens" -msgstr "Todavía no tienes ningún token." - -#: src/app/main/ui/settings/access_tokens.cljs:135 -msgid "dashboard.access-tokens.expiration-180-days" -msgstr "180 días" - -#: src/app/main/ui/settings/access_tokens.cljs:132 -msgid "dashboard.access-tokens.expiration-30-days" -msgstr "30 días" - -#: src/app/main/ui/settings/access_tokens.cljs:133 -msgid "dashboard.access-tokens.expiration-60-days" -msgstr "60 días" - -#: src/app/main/ui/settings/access_tokens.cljs:134 -msgid "dashboard.access-tokens.expiration-90-days" -msgstr "90 días" - -#: src/app/main/ui/settings/access_tokens.cljs:131 -msgid "dashboard.access-tokens.expiration-never" -msgstr "Nunca" - -#: src/app/main/ui/settings/access_tokens.cljs:268 -msgid "dashboard.access-tokens.expired-on" -msgstr "Expiró el %s" - -#: src/app/main/ui/settings/access_tokens.cljs:269 -msgid "dashboard.access-tokens.expires-on" -msgstr "Expira el %s" - -#: src/app/main/ui/settings/access_tokens.cljs:267 -msgid "dashboard.access-tokens.no-expiration" -msgstr "Sin fecha de expiración" - -#: src/app/main/ui/settings/access_tokens.cljs:184 -msgid "dashboard.access-tokens.personal" -msgstr "Access tokens personales" - -#: src/app/main/ui/settings/access_tokens.cljs:185 -msgid "dashboard.access-tokens.personal.description" -msgstr "" -"Los access tokens personales funcionan como una alternativa a nuestro " -"sistema de autenticación usuario/password y se pueden usar para permitir a " -"otras aplicaciones acceso a la API interna de Penpot" - -#: src/app/main/ui/settings/access_tokens.cljs:142 -msgid "dashboard.access-tokens.token-will-expire" -msgstr "El token expirará el %s" - -#: src/app/main/ui/settings/access_tokens.cljs:143 -msgid "dashboard.access-tokens.token-will-not-expire" -msgstr "El token no tiene fecha de expiración" - #: src/app/main/ui/dashboard/placeholder.cljs:41 msgid "dashboard.add-file" msgstr "Añadir archivo" @@ -2105,6 +2034,237 @@ msgstr "Valor resuelto:" msgid "inspect.tabs.styles.variants-panel" msgstr "Propiedades de las variantes" +#: src/app/main/ui/settings/integrations.cljs:189 +msgid "integrations.access-tokens.create" +msgstr "Crear nuevo token de acceso" + +#: src/app/main/ui/settings/integrations.cljs:286 +msgid "integrations.access-tokens.empty.add-one" +msgstr "Pulsa el botón \"Crear nuevo token de accesso\" para generar uno." + +#: src/app/main/ui/settings/integrations.cljs:285 +msgid "integrations.access-tokens.empty.no-access-tokens" +msgstr "Todavía no tienes ningún token." + +#: src/app/main/ui/settings/integrations.cljs:184 +msgid "integrations.access-tokens.personal" +msgstr "Tokens de acceso personales" + +#: src/app/main/ui/settings/integrations.cljs:185 +msgid "integrations.access-tokens.personal.description" +msgstr "" +"Los tokens de accesso personales funcionan como una alternativa a nuestro " +"sistema de autenticación usuario/password y se pueden usar para permitir a " +"otras aplicaciones acceso a la API interna de Penpot" + +#: src/app/main/ui/settings/integrations.cljs:152, src/app/main/ui/settings/integrations.cljs:158 +msgid "integrations.copy-to-clipboard" +msgstr "Copiar al portapapeles" + +#: src/app/main/ui/settings/integrations.cljs:432 +msgid "integrations.create-access-token.title" +msgstr "Crear token de accesso" + +#: src/app/main/ui/settings/integrations.cljs:433 +msgid "integrations.create-access-token.title.created" +msgstr "Token de acceso creado" + +#: src/app/main/ui/settings/integrations.cljs:257 +msgid "integrations.delete-token.accept" +msgstr "Borrar token" + +#: src/app/main/ui/settings/integrations.cljs:256 +msgid "integrations.delete-token.message" +msgstr "¿Seguro que deseas borrar este token?" + +#: src/app/main/ui/settings/integrations.cljs:255 +msgid "integrations.delete-token.title" +msgstr "Borrar token" + +#: src/app/main/ui/settings/integrations.cljs:135 +msgid "integrations.expiration-180-days" +msgstr "180 días" + +#: src/app/main/ui/settings/integrations.cljs:132 +msgid "integrations.expiration-30-days" +msgstr "30 días" + +#: src/app/main/ui/settings/integrations.cljs:133 +msgid "integrations.expiration-60-days" +msgstr "60 días" + +#: src/app/main/ui/settings/integrations.cljs:134 +msgid "integrations.expiration-90-days" +msgstr "90 días" + +#: src/app/main/ui/settings/integrations.cljs:131 +msgid "integrations.expiration-never" +msgstr "Nunca" + +#: src/app/main/ui/settings/integrations.cljs:268 +msgid "integrations.expired-on" +msgstr "Expiró el %s" + +#: src/app/main/ui/settings/integrations.cljs:269 +msgid "integrations.expires-on" +msgstr "Expira el %s" + +#: src/app/main/ui/settings/integrations.cljs:267 +msgid "integrations.no-expiration" +msgstr "Sin fecha de expiración" + +#: src/app/main/ui/settings/integrations.cljs:130 +msgid "integrations.expiration-date.label" +msgstr "Fecha de expiración" + +#: src/app/main/ui/settings/integrations.cljs:290 +msgid "integrations.generate-mcp-key.title" +msgstr "Generar clave MCP" + +#: src/app/main/ui/settings/integrations.cljs:291 +msgid "integrations.generate-mcp-key.title.created" +msgstr "Clave MCP generada" + +#: src/app/main/ui/settings/integrations.cljs:113 +msgid "integrations.info.mcp-client-config" +msgstr "Agrega esta configuración a tu cliente MCP (por ejemplo, ~/.mcp.json)." + +#: src/app/main/ui/settings/integrations.cljs:183 +msgid "integrations.info.mcp-server" +msgstr "El servidor MCP de Penpot permite a los clientes MCP interactuar directamente con los archivos de diseño de Penpot." + +#: src/app/main/ui/settings/integrations.cljs:131 +msgid "integrations.token.info.non-recuperable" +msgstr "Esta clave única no es recuperable. Si la pierdes, tendrás que crear una nueva." + +#: src/app/main/ui/settings/integrations.cljs:131 +#: src/app/main/ui/settings/integrations.cljs:131 +msgid "integrations.mcp-key.info.non-recuperable" +msgstr "Esta clave MCP única no es recuperable. Si la pierdes, tendrás que crear una nueva." + +#: src/app/main/ui/settings/integrations.cljs:336 +msgid "integrations.mcp-server.title" +msgstr "Servidor MCP" + +#: src/app/main/ui/settings/integrations.cljs:336 +msgid "integrations.mcp-server.title.beta" +msgstr "Beta" + +#: src/app/main/ui/settings/integrations.cljs:347 +msgid "integrations.mcp-server.description" +msgstr "El servidor MCP de Penpot permite que los clientes MCP interactúen directamente con los archivos de diseño de Penpot." + +#: src/app/main/ui/settings/integrations.cljs:353 +msgid "integrations.mcp-server.status" +msgstr "Estado" + +#: src/app/main/ui/settings/integrations.cljs:370 +msgid "integrations.mcp-server.status.enabled" +msgstr "Habilitado" + +#: src/app/main/ui/settings/integrations.cljs:370 +msgid "integrations.mcp-server.status.disabled" +msgstr "Deshabilitado" + +#: src/app/main/ui/settings/integrations.cljs:363 +msgid "integrations.mcp-server.status.expired.0" +msgstr "La clave MCP utilizada para conectarse al servidor MCP ha expirado. Como resultado, no se puede establecer la conexión." + +#: src/app/main/ui/settings/integrations.cljs:368 +msgid "integrations.mcp-server.status.expired.1" +msgstr "Por favor, regenera la clave MCP y actualiza la configuración de tu cliente con la nueva clave." + +#: src/app/main/ui/settings/integrations.cljs:415 +msgid "integrations.mcp-server.mcp-keys.copy" +msgstr "Copiar enlace" + +#: src/app/main/ui/settings/integrations.cljs:422 +msgid "integrations.mcp-server.mcp-keys.help" +msgstr "Cómo configurar clientes MCP" + +#: src/app/main/ui/settings/integrations.cljs:405 +msgid "integrations.mcp-server.mcp-keys.info" +msgstr "Esta es la URL del servidor que necesitarás configurar en tu cliente MCP para conectarlo al servidor MCP de Penpot." + +#: src/app/main/ui/settings/integrations.cljs:387 +msgid "integrations.mcp-server.mcp-keys.regenerate" +msgstr "Regenerar clave MCP" + +#: src/app/main/ui/settings/integrations.cljs:381 +msgid "integrations.mcp-server.mcp-keys.title" +msgstr "Clave MCP" + +#: src/app/main/ui/settings/integrations.cljs:388 +msgid "integrations.mcp-server.mcp-keys.tootip" +msgstr "La clave MCP es necesaria para la configuración del cliente MCP" + +#: src/app/main/ui/settings/integrations.cljs:124 +msgid "integrations.name.label" +msgstr "Nombre" + +#: src/app/main/ui/settings/integrations.cljs:126 +msgid "integrations.name.placeholder" +msgstr "El nombre te pude ayudar a saber para qué se utiliza el token" + +#: src/app/main/ui/settings/integrations.cljs:103 +msgid "integrations.notification.success.token-copied" +msgstr "Token copiado" + +#: src/app/main/ui/settings/integrations.cljs:103 +#: src/app/main/ui/settings/integrations.cljs:103 +msgid "integrations.notification.success.mcp-key-copied" +msgstr "Clave MCP copiada" + +#: src/app/main/ui/settings/integrations.cljs:64 +msgid "integrations.notification.success.created" +msgstr "Token creado con éxito" + +#: src/app/main/ui/settings/integrations.cljs:327 +msgid "integrations.notification.success.copied-link" +msgstr "Enlace copiado al portapapeles" + +#: src/app/main/ui/settings/integrations.cljs:293 +msgid "integrations.notification.success.mcp-server-disabled" +msgstr "Servidor MCP deshabilitado" + +#: src/app/main/ui/settings/integrations.cljs:299 +msgid "integrations.notification.success.mcp-server-enabled" +msgstr "Servidor MCP habilitado" + +#: src/app/main/ui/settings/integrations.cljs:317 +msgid "integrations.regenerate-mcp-key.info" +msgstr "Regenerar la clave MCP revocará inmediatamente la actual. Cualquier aplicación que la esté utilizando dejará de funcionar." + +#: src/app/main/ui/settings/integrations.cljs:317 +msgid "integrations.regenerate-mcp-key.title" +msgstr "Regenerar clave MCP" + +#: src/app/main/ui/settings/integrations.cljs:318 +msgid "integrations.regenerate-mcp-key.title.created" +msgstr "Clave MCP regenerada" + +#: src/app/main/ui/settings/integrations.cljs:480 +msgid "integrations.title" +msgstr "Integraciones" + +#: src/app/main/ui/settings/integrations.cljs:142 +msgid "integrations.token.will-expire" +msgstr "El token expirará el %s" + +#: src/app/main/ui/settings/integrations.cljs:142 +#: src/app/main/ui/settings/integrations.cljs:142 +msgid "integrations.mcp-key.will-expire" +msgstr "La clave MCP expirará el %s" + +#: src/app/main/ui/settings/integrations.cljs:143 +msgid "integrations.token.will-not-expire" +msgstr "El token no tiene fecha de expiración" + +#: src/app/main/ui/settings/integrations.cljs:143 +#: src/app/main/ui/settings/integrations.cljs:143 +msgid "integrations.mcp-key.will-not-expire" +msgstr "La clave MCP no tiene fecha de expiración" + #: src/app/main/ui/dashboard/comments.cljs:96 msgid "label.mark-all-as-read" msgstr "Marcar todo como leído" @@ -2321,6 +2481,9 @@ msgstr "Director" msgid "labels.discard" msgstr "Descartar" +msgid "labels.dismiss" +msgstr "Cancelar" + #: src/app/main/ui/settings/feedback.cljs:134, src/app/main/ui/static.cljs:409 msgid "labels.download" msgstr "Descargar %s" @@ -2456,6 +2619,10 @@ msgstr "Información" msgid "labels.installed-fonts" msgstr "Fuentes instaladas" +#: src/app/main/ui/settings/sidebar.cljs:123 +msgid "labels.integrations" +msgstr "Integraciones" + #: src/app/main/ui/static.cljs:405 msgid "labels.internal-error.desc-message-first" msgstr "Ha ocurrido algo extraño." @@ -3111,30 +3278,6 @@ msgstr "Cambiar correo" msgid "modals.change-email.title" msgstr "Cambiar tu correo" -#: src/app/main/ui/settings/access_tokens.cljs:152, src/app/main/ui/settings/access_tokens.cljs:158 -msgid "modals.create-access-token.copy-token" -msgstr "Copiar token" - -#: src/app/main/ui/settings/access_tokens.cljs:130 -msgid "modals.create-access-token.expiration-date.label" -msgstr "Fecha de expiración" - -#: src/app/main/ui/settings/access_tokens.cljs:124 -msgid "modals.create-access-token.name.label" -msgstr "Nombre" - -#: src/app/main/ui/settings/access_tokens.cljs:126 -msgid "modals.create-access-token.name.placeholder" -msgstr "El nombre te pude ayudar a saber para qué se utiliza el token" - -#: src/app/main/ui/settings/access_tokens.cljs:178 -msgid "modals.create-access-token.submit-label" -msgstr "Crear token" - -#: src/app/main/ui/settings/access_tokens.cljs:111 -msgid "modals.create-access-token.title" -msgstr "Generar access token" - #: src/app/main/ui/dashboard/team.cljs:1127 msgid "modals.create-webhook.submit-label" msgstr "Crear webhook" @@ -3151,18 +3294,6 @@ msgstr "Payload URL" msgid "modals.create-webhook.url.placeholder" msgstr "https://example.com/postreceive" -#: src/app/main/ui/settings/access_tokens.cljs:257 -msgid "modals.delete-acces-token.accept" -msgstr "Borrar token" - -#: src/app/main/ui/settings/access_tokens.cljs:256 -msgid "modals.delete-acces-token.message" -msgstr "¿Seguro que deseas borrar este token?" - -#: src/app/main/ui/settings/access_tokens.cljs:255 -msgid "modals.delete-acces-token.title" -msgstr "Borrar token" - #: src/app/main/ui/settings/delete_account.cljs:56 msgid "modals.delete-account.cancel" msgstr "Cancelar y mantener mi cuenta" @@ -3666,6 +3797,12 @@ msgstr "Invitación enviada con éxito" msgid "notifications.invitation-link-copied" msgstr "Enlace de invitacion copiado" +msgid "notifications.mcp.active-in-another-tab" +msgstr "MCP está activo en otra pestaña. ¿Cambiar a esta?" + +msgid "notifications.mcp.active-in-this-tab" +msgstr "MCP está ahora activo en esta pestaña." + #: src/app/main/ui/settings/delete_account.cljs:24 msgid "notifications.profile-deletion-not-allowed" msgstr "No puedes borrar tu perfil. Reasigna tus equipos antes de seguir." @@ -5068,14 +5205,14 @@ msgstr "Bibliotecas Compartidas - %s - Penpot" msgid "title.default" msgstr "Penpot - Diseño Libre para Equipos" -#: src/app/main/ui/settings/access_tokens.cljs:278 -msgid "title.settings.access-tokens" -msgstr "Perfil - Access tokens" - #: src/app/main/ui/settings/feedback.cljs:161 msgid "title.settings.feedback" msgstr "Danos tu opinión - Penpot" +#: src/app/main/ui/settings/integrations.cljs:278 +msgid "title.settings.integrations" +msgstr "Integraciones - Penpot" + #: src/app/main/ui/settings/notifications.cljs:45 msgid "title.settings.notifications" msgstr "Notificaciones - Penpot" @@ -5571,6 +5708,18 @@ msgstr "Ocultar reglas" msgid "workspace.header.menu.hide-textpalette" msgstr "Ocultar paleta de textos" +msgid "workspace.header.menu.mcp.plugin.status.connect" +msgstr "Conectar" + +msgid "workspace.header.menu.mcp.plugin.status.disconnect" +msgstr "Desconectar" + +msgid "workspace.header.menu.mcp.server.status.enabled" +msgstr "Gestionar (estado: habilitado)" + +msgid "workspace.header.menu.mcp.server.status.disabled" +msgstr "Gestionar (estado: deshabilitado)" + #: src/app/main/ui/workspace/main_menu.cljs:884 msgid "workspace.header.menu.option.edit" msgstr "Editar" @@ -5583,6 +5732,9 @@ msgstr "Archivo" msgid "workspace.header.menu.option.help-info" msgstr "Ayuda e información" +msgid "workspace.header.menu.option.mcp" +msgstr "Servidor MCP" + #: src/app/main/ui/workspace/main_menu.cljs:906 msgid "workspace.header.menu.option.preferences" msgstr "Preferencias" diff --git a/mcp/.gitignore b/mcp/.gitignore index 8a245a5dcaa..039f51d722e 100644 --- a/mcp/.gitignore +++ b/mcp/.gitignore @@ -1,6 +1,8 @@ .idea +.claude node_modules dist +*.tgz *.bak *.orig temp diff --git a/mcp/.serena/memories/project_overview.md b/mcp/.serena/memories/project_overview.md index 528976b0771..7e80a5e2d51 100644 --- a/mcp/.serena/memories/project_overview.md +++ b/mcp/.serena/memories/project_overview.md @@ -1,7 +1,10 @@ # Penpot MCP Project Overview - Updated ## Purpose -This project is a Model Context Protocol (MCP) server for Penpot integration. It provides a TypeScript-based server that can be used to extend Penpot's functionality through custom tools with bidirectional WebSocket communication. +This project is a Model Context Protocol (MCP) server for Penpot integration. +The MCP server communicates with a Penpot plugin via WebSockets, allowing +the MCP server to send tasks to the plugin and receive results, +enabling advanced AI-driven features in Penpot. ## Tech Stack - **Language**: TypeScript @@ -13,21 +16,22 @@ This project is a Model Context Protocol (MCP) server for Penpot integration. It ## Project Structure ``` -penpot-mcp/ -├── common/ # Shared type definitions +/ (project root) +├── packages/common/ # Shared type definitions │ ├── src/ │ │ ├── index.ts # Exports for shared types │ │ └── types.ts # PluginTaskResult, request/response interfaces │ └── package.json # @penpot-mcp/common package -├── mcp-server/ # Main MCP server implementation +├── packages/server/ # Main MCP server implementation │ ├── src/ │ │ ├── index.ts # Main server entry point │ │ ├── PenpotMcpServer.ts # Enhanced with request/response correlation │ │ ├── PluginTask.ts # Now supports result promises │ │ ├── tasks/ # PluginTask implementations │ │ └── tools/ # Tool implementations +| ├── data/ # Contains resources, such as API info and prompts │ └── package.json # Includes @penpot-mcp/common dependency -├── penpot-plugin/ # Penpot plugin with response capability +├── packages/plugin/ # Penpot plugin with response capability │ ├── src/ │ │ ├── main.ts # Enhanced WebSocket handling with response forwarding │ │ └── plugin.ts # Now sends task responses back to server @@ -37,55 +41,24 @@ penpot-mcp/ ## Key Tasks +### Adjusting the System Prompt + +The system prompt file is located in `packages/server/data/initial_instructions.md`. + ### Adding a new Tool -1. Implement the tool class in `mcp-server/src/tools/` following the `Tool` interface. +1. Implement the tool class in `packages/server/src/tools/` following the `Tool` interface. IMPORTANT: Do not catch any exceptions in the `executeCore` method. Let them propagate to be handled centrally. 2. Register the tool in `PenpotMcpServer`. -Look at `PrintTextTool` as an example. - -Many tools are linked to tasks that are handled in the plugin, i.e. they have an associated `PluginTask` implementation in `mcp-server/src/tasks/`. +Tools can be associated with a `PluginTask` that is executed in the plugin. +Many tools build on `ExecuteCodePluginTask`, as many operations can be reduced to code execution. ### Adding a new PluginTask -1. Implement the input data interface for the task in `common/src/types.ts`. -2. Implement the `PluginTask` class in `mcp-server/src/tasks/`. -3. Implement the corresponding task handler class in the plugin (`penpot-plugin/src/task-handlers/`). +1. Implement the input data interface for the task in `packages/common/src/types.ts`. +2. Implement the `PluginTask` class in `packages/server/src/tasks/`. +3. Implement the corresponding task handler class in the plugin (`packages/plugin/src/task-handlers/`). * In the success case, call `task.sendSuccess`. * In the failure case, just throw an exception, which will be handled centrally! - * Look at `PrintTextTaskHandler` as an example. -4. Register the task handler in `penpot-plugin/src/plugin.ts` in the `taskHandlers` list. - - -## Key Components - -### Enhanced WebSocket Protocol -- **Request Format**: `{id: string, task: string, params: any}` -- **Response Format**: `{id: string, result: {success: boolean, error?: string, data?: any}}` -- **Request/Response Correlation**: Using unique UUIDs for task tracking -- **Timeout Handling**: 30-second timeout with automatic cleanup -- **Type Safety**: Shared definitions via @penpot-mcp/common package - -### Core Classes -- **PenpotMcpServer**: Enhanced with pending task tracking and response handling -- **PluginTask**: Now creates result promises that resolve when plugin responds -- **Tool implementations**: Now properly await task completion and report results -- **Plugin handlers**: Send structured responses back to server - -### New Features -1. **Bidirectional Communication**: Plugin now responds with success/failure status -2. **Task Result Promises**: Every executePluginTask() sets and returns a promise -3. **Error Reporting**: Failed tasks properly report error messages to tools -4. **Shared Type Safety**: Common package ensures consistency across projects -5. **Timeout Protection**: Tasks don't hang indefinitely (30s limit) -6. **Request Correlation**: Unique IDs match requests to responses - -## Task Flow - -``` -LLM Tool Call → MCP Server → WebSocket (Request) → Plugin → Penpot API - ↑ ↓ - Tool Response ← MCP Server ← WebSocket (Response) ← Plugin Result -``` - +4. Register the task handler in `packages/plugin/src/plugin.ts` in the `taskHandlers` list. diff --git a/mcp/.serena/project.yml b/mcp/.serena/project.yml index c9ed0f73309..e5729836cd8 100644 --- a/mcp/.serena/project.yml +++ b/mcp/.serena/project.yml @@ -1,5 +1,3 @@ - - # whether to use the project's gitignore file to ignore files # Added on 2025-04-07 ignore_all_files_in_gitignore: true @@ -19,7 +17,7 @@ read_only: false # list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. # Below is the complete list of tools for convenience. -# To make sure you have the latest list of tools, and to view their descriptions, +# To make sure you have the latest list of tools, and to view their descriptions, # execute `uv run scripts/print_tool_overview.py`. # # * `activate_project`: Activates a project by name. @@ -62,15 +60,17 @@ excluded_tools: [] # (contrary to the memories, which are loaded on demand). initial_prompt: | IMPORTANT: You use an idiomatic, object-oriented style. - In particular, this implies that, for any non-trivial interfaces, you use interfaces that expect explicitly typed abstractions + In particular, this implies that, for any non-trivial interfaces, you use interfaces that expect explicitly typed abstractions rather than mere functions (i.e. use the strategy pattern, for example). - Comments: + Always read the "project_overview" memory. + + Comments: When describing parameters, methods/functions and classes, you use a precise style, where the initial (elliptical) phrase clearly defines *what* it is. Any details then follow in subsequent sentences. When describing what blocks of code do, you also use an elliptical style and start with a lower-case letter unless - the comment is a lengthy explanation with at least two sentences (in which case you start with a capital letter, as is + the comment is a lengthy explanation with at least two sentences (in which case you start with a capital letter, as is required for sentences). # the name by which the project can be referenced within Serena project_name: "penpot-mcp" @@ -128,3 +128,39 @@ encoding: utf-8 # Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. languages: - typescript + +# time budget (seconds) per tool call for the retrieval of additional symbol information +# such as docstrings or parameter information. +# This overrides the corresponding setting in the global configuration; see the documentation there. +# If null or missing, use the setting from the global configuration. +symbol_info_budget: + +# The language backend to use for this project. +# If not set, the global setting from serena_config.yml is used. +# Valid values: LSP, JetBrains +# Note: the backend is fixed at startup. If a project with a different backend +# is activated post-init, an error will be returned. +language_backend: + +# line ending convention to use when writing source files. +# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) +# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. +line_ending: + +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# Extends the list from the global configuration, merging the two lists. +read_only_memory_patterns: [] + +# list of regex patterns for memories to completely ignore. +# Matching memories will not appear in list_memories or activate_project output +# and cannot be accessed via read_memory or write_memory. +# To access ignored memory files, use the read_file tool on the raw file path. +# Extends the list from the global configuration, merging the two lists. +# Example: ["_archive/.*", "_episodes/.*"] +ignored_memory_patterns: [] + +# advanced configuration option allowing to configure language server-specific options. +# Maps the language key to the options. +# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. +# No documentation on options means no options are available. +ls_specific_settings: {} diff --git a/mcp/README.md b/mcp/README.md index 9e9821c065a..f23af675e91 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -50,31 +50,65 @@ Follow the steps below to enable the integration. ### Prerequisites -The project requires [Node.js](https://nodejs.org/) (tested with v22.x -with corepack). +The project requires [Node.js](https://nodejs.org/) (tested with v22.x). -Following the installation of Node.js, the tools `pnpm` and `npx` -should be available in your terminal. For ensure corepack installed -and enabled correctly, just execute the `./scripts/setup`. +### 1. Starting the MCP Server and the Plugin Server -It is also required to have `caddy` executeable in the path, it is -used for start a local server for generate types documentation from -the current branch. If you want to run it outside devenv where all -dependencies are already provided, please download caddy from -[here](https://caddyserver.com/download). +#### Running a Released Version via npx -You should probably be using penpot devenv, where all this -dependencies are already present and correctly setup. But nothing -prevents you execute this outside of devenv if you satisfy the -specified dependencies. +The easiest way to launch the servers is to use `npx` to run the appropriate +version that matches your Penpot version. +* If you are using the latest Penpot release, e.g. as served on [design.penpot.app](https://design.penpot.app), run: + ```shell + npx -y @penpot/mcp@latest + ``` +* If you are participating in the MCP beta-test, which uses [test-mcp.penpot.dev](https://test-mcp.penpot.dev), run: + ```shell + npx -y @penpot/mcp@beta + ``` -### 1. Build & Launch the MCP Server and the Plugin Server +Once the servers are running, continue with step 2. -If it's your first execution, install the required dependencies: +#### Running the Source Version from the Repository + +The tools `corepack` and `npx` should be available in your terminal. + +On Windows, use the Git Bash terminal to ensure compatibility with the provided scripts. + +##### Clone the Appropriate Branch of the Repository + +> [!IMPORTANT] +> The branches are subject to change in the future. +> Be sure to check the instructions for the latest information on which branch to use. + +Clone the Penpot repository, using the proper branch depending on the +version of Penpot you want to use the MCP server with. + + * For the current Penpot release 2.14, use the `mcp-prod-2.14.1` branch: + + ```shell + git clone https://github.com/penpot/penpot.git --branch mcp-prod-2.14.1 --depth 1 + ``` + + * For the MCP beta-test, use the `staging` branch: + + ```shell + git clone https://github.com/penpot/penpot.git --branch staging --depth 1 + ``` + +Then change into the `mcp` directory: + +```shell +cd penpot/mcp +``` + +##### Build & Launch the MCP Server and the Plugin Server + +If it's your first execution, install the required dependencies. +(If you are using the Penpot devenv, this step is not necessary, as dependencies are already installed.) ```shell -cd mcp/ ./scripts/setup ``` @@ -86,9 +120,9 @@ pnpm run bootstrap This bootstrap command will: - * install dependencies for all components (`pnpm -r run install`) - * build all components (`pnpm -r run build`) - * start all components (`pnpm -r --parallel run start`) + * install dependencies for all components + * build all components + * start all components ### 2. Load the Plugin in Penpot and Establish the Connection @@ -123,6 +157,19 @@ This bootstrap command will: ### 3. Connect an MCP Client +> [!IMPORTANT] +> **Use an appropriate model.** +> +> We recommend that you ... +> * use the most capable model at your disposal. +> You will achieve the best results with frontier models, +> especially when dealing with more complex tasks. +> Weaker models, including most locally hosted ones, +> are unlikely to produce usable results for anything beyond simple tasks. +> * use a vision language model (VLM), as many design tasks necessitate visual +> inspection. +> (If you are using a standard commercial model, it almost certainly supports vision already.) + By default, the server runs on port 4401 and provides: - **Modern Streamable HTTP endpoint**: `http://localhost:4401/mcp` @@ -140,14 +187,9 @@ NOTE: only relevant if you are executing this outside of devenv The `mcp-remote` package can proxy stdio transport to HTTP/SSE, allowing clients that support only stdio to connect to the MCP server indirectly. +Use it to provide the launch command for your MCP client as follows: -1. Install `mcp-remote` globally if you haven't already: - - npm install -g mcp-remote - -2. Use `mcp-remote` to provide the launch command for your MCP client: - - npx -y mcp-remote http://localhost:4401/sse --allow-http + npx -y mcp-remote http://localhost:4401/mcp --allow-http #### Example: Claude Desktop @@ -170,7 +212,7 @@ Add a `penpot` entry under `mcpServers` with the following content: "mcpServers": { "penpot": { "command": "npx", - "args": ["-y", "mcp-remote", "http://localhost:4401/sse", "--allow-http"] + "args": ["-y", "mcp-remote", "http://localhost:4401/mcp", "--allow-http"] } } } @@ -195,37 +237,36 @@ To add the Penpot MCP server to a Claude Code project, issue the command This repository is a monorepo containing four main components: -1. **Common Types** (`common/`): +1. **Common Types** (`packages/common/`): - Shared TypeScript definitions for request/response protocol - Ensures type safety across server and plugin components -2. **Penpot MCP Server** (`mcp-server/`): +2. **Penpot MCP Server** (`packages/server/`): - Provides MCP tools to LLMs for Penpot interaction - Runs a WebSocket server accepting connections from the Penpot MCP plugin - Implements request/response correlation with unique task IDs - Handles task timeouts and proper error reporting -3. **Penpot MCP Plugin** (`penpot-plugin/`): +3. **Penpot MCP Plugin** (`packages/plugin/`): - Connects to the MCP server via WebSocket - Executes tasks in Penpot using the Plugin API - Sends structured responses back to the server# -4. **Helper Scripts** (`python-scripts/`): - - Python scripts that prepare data for the MCP server (development use) +4. **Types Generator** (`types-generator/`): + - Generates data on API types for the MCP server (development use) The core components are written in TypeScript, rendering interactions with the Penpot Plugin API both natural and type-safe. ## Configuration -The Penpot MCP server can be configured using environment variables. All configuration -options use the `PENPOT_MCP_` prefix for consistency. +The Penpot MCP server can be configured using environment variables. ### Server Configuration | Environment Variable | Description | Default | |------------------------------------|----------------------------------------------------------------------------|--------------| -| `PENPOT_MCP_SERVER_LISTEN_ADDRESS` | Address on which the MCP server listens (binds to) | `localhost` | +| `PENPOT_MCP_SERVER_HOST` | Address on which the MCP server listens (binds to) | `localhost` | | `PENPOT_MCP_SERVER_PORT` | Port for the HTTP/SSE server | `4401` | | `PENPOT_MCP_WEBSOCKET_PORT` | Port for the WebSocket server (plugin connection) | `4402` | | `PENPOT_MCP_REPL_PORT` | Port for the REPL server (development/debugging) | `4403` | @@ -243,7 +284,7 @@ options use the `PENPOT_MCP_` prefix for consistency. | Environment Variable | Description | Default | |-------------------------------------------|-----------------------------------------------------------------------------------------|--------------| -| `PENPOT_MCP_PLUGIN_SERVER_LISTEN_ADDRESS` | Address on which the plugin web server listens (single address or comma-separated list) | (local only) | +| `PENPOT_MCP_PLUGIN_SERVER_HOST` | Address on which the plugin web server listens (single address or comma-separated list) | (local only) | ## Beyond Local Execution @@ -263,3 +304,17 @@ you may set the following environment variables to configure the two servers * `PENPOT_MCP_SERVER_ADDRESS=`: This sets the hostname or IP address where the MCP server can be reached. The Penpot MCP Plugin uses this to construct the WebSocket URL as `ws://:` (default port: `4402`). + +## Development + +* The [contribution guidelines for Penpot](../CONTRIBUTING.md) apply +* Auto-formatting: Use `pnpm run fmt` +* Generating API type data: See [types-generator/README.md](types-generator/README.md) +* Versioning: Use `bash scripts/set-version` to set the version for the MCP package (in `package.json`). + - Ensure that at least the major, minor and patch components of the version are always up-to-date. + - The MCP plugin assumes that a mismatch between the MCP version and the Penpot version (as returned by the API) + indicates incompatibility, resulting in the display of a warning message in the plugin UI. +* Packaging and publishing: + 1. Ensure release version is set correctly in package.json (call `bash scripts/set-version` to update it automatically) + 2. Create npm package: `bash scripts/pack` (creates `penpot-mcp-.tgz` for publishing) + 3. Publish to npm: `npm publish penpot-mcp-.tgz --access public` diff --git a/mcp/bin/mcp-local.js b/mcp/bin/mcp-local.js new file mode 100644 index 00000000000..65a2b0f763f --- /dev/null +++ b/mcp/bin/mcp-local.js @@ -0,0 +1,31 @@ +#!/usr/bin/env node + +const { execSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +const root = path.resolve(__dirname, ".."); + +function run(command) { + execSync(command, { cwd: root, stdio: "inherit" }); +} + +// pnpm-lock.yaml is hard-excluded by npm pack; it is shipped as pnpm-lock.dist.yaml +// and restored here before bootstrap runs. +const distLock = path.join(root, "pnpm-lock.dist.yaml"); +const lock = path.join(root, "pnpm-lock.yaml"); +if (fs.existsSync(distLock)) { + fs.copyFileSync(distLock, lock); +} + +try { + run("corepack pnpm run bootstrap"); +} catch (error) { + if (error.code === "ENOENT") { + console.error( + "corepack is required but was not found. It ships with Node.js >= 16." + ); + process.exit(1); + } + process.exit(error.status ?? 1); +} diff --git a/mcp/package.json b/mcp/package.json index fdb75dd03b4..6324fe38987 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -1,15 +1,18 @@ { - "name": "mcp-meta", - "version": "1.0.0", - "description": "", + "name": "@penpot/mcp", + "version": "2.15.0-rc.1.153", + "description": "MCP server for Penpot integration", + "bin": { + "penpot-mcp": "./bin/mcp-local.js" + }, "scripts": { "build": "pnpm -r run build", "build:multi-user": "pnpm -r run build:multi-user", "build:types": "bash ./scripts/build-types", "start": "pnpm -r --parallel run start", - "start:multi-user": "pnpm -r --parallel --filter \"./packages/*\" run start:multi-user", + "start:multi-user": "pnpm -r --parallel run start:multi-user", "bootstrap": "pnpm -r install && pnpm run build && pnpm run start", - "bootstrap:multi-user": "pnpm -r install && pnpm run build:multi-user && pnpm run start:multi-user", + "bootstrap:multi-user": "pnpm -r install && pnpm run build && pnpm run start:multi-user", "fmt": "prettier --write packages/", "fmt:check": "prettier --check packages/" }, @@ -17,8 +20,7 @@ "type": "git", "url": "https://github.com/penpot/penpot.git" }, - "packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264", - "private": true, + "packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268", "devDependencies": { "concurrently": "^9.2.1", "prettier": "^3.0.0" diff --git a/mcp/packages/common/package.json b/mcp/packages/common/package.json index 6c014b34ac4..fc6d9c9cfdf 100644 --- a/mcp/packages/common/package.json +++ b/mcp/packages/common/package.json @@ -4,7 +4,7 @@ "description": "Shared type definitions and interfaces for Penpot MCP", "main": "dist/index.js", "types": "dist/index.d.ts", - "packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264", + "packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268", "scripts": { "build": "tsc --build --clean && tsc --build", "watch": "tsc --watch", diff --git a/mcp/packages/plugin/index.html b/mcp/packages/plugin/index.html index b2c08b5dae2..de2ff5853cc 100644 --- a/mcp/packages/plugin/index.html +++ b/mcp/packages/plugin/index.html @@ -3,12 +3,87 @@ - Penpot plugin example + Penpot MCP Plugin - +
+ -
Not connected
+
+ + Not connected +
+ + + + +
+ + + Execution status + + +
+ Current task +
+ + --- +
+ +
+ Executed code + +
+ +
+
+
diff --git a/mcp/packages/plugin/package.json b/mcp/packages/plugin/package.json index 2fca8aeaae0..0ccf2761812 100644 --- a/mcp/packages/plugin/package.json +++ b/mcp/packages/plugin/package.json @@ -5,9 +5,8 @@ "type": "module", "scripts": { "start": "vite build --watch --config vite.config.ts", - "start:multi-user": "cross-env MULTI_USER_MODE=true vite build --watch --config vite.config.ts", + "start:multi-user": "pnpm run start", "build": "tsc && vite build --config vite.release.config.ts", - "build:multi-user": "tsc && cross-env MULTI_USER_MODE=true vite build --config vite.release.config.ts", "types:check": "tsc --noEmit", "clean": "rm -rf dist/" }, diff --git a/mcp/packages/plugin/public/icon.jpg b/mcp/packages/plugin/public/icon.jpg new file mode 100644 index 0000000000000000000000000000000000000000..9df8dd26a4a08fea0c10604f732f7ceb204b7bf4 GIT binary patch literal 7632 zcmch62UJtdy7s0wY0`TTkRk!8h9X6zgf1NdL1}_?=_1mkh!P-D1f@4YibQ%75h)^F zIw*uLMS4~Ecl0~|@vQ%R=iYy7ehvuDpd@AE!0IU75h0cbSU;A#LK9suBh zAK(m&HwITxvDP=xQ-f=({;uc*z(jZ%09@U??;5CIgP56HKuBhO{{mNc%ht;ScmJCN z_PalV>m2}wpnucmKNXYN*?ZZ74K~4x_b&KwaIo~CO@I5hEr7Fae%tam+t1s>8*HPG zv+o)isDQQ|Xbar_!?yXuw)MD+YySjnBk$_sgL@V(;YNJU-p$w$ypw_#JKzNv0P4Us z-1~#y!Q`F;0I)p(Ac+5GnN2DH)IPc(VY2`XK;R_x-c%pL61I>+Y>z-4TL0 zzJmh*Y!?Cmr8xl5^#cGo;#VD*{ms}QU==$!E_d*A8*l;a0SEvG+yGlZ1hm9~%YYal zeKrZG0K|ku#6*O|#6-j-B*diTH00!DWaRWz)aPiJ=$V{`G8CeDXztdR@Kud}zj3-QhcLBhs z#Ur4_J8J_taBw0d!28F$f_G9pViGcZ0wO}N`U(wzM?i>&PxR};HNyk&3BcE(B{|Pc zN+)*X)&+58PkJ5+hHIe%vy2JZ6}&12Xz+*hIBfrG!j;ngzoiqvIj~=RS^`=?5l9k< z4vCH?{ol#2M&ThNFeAcCl}vY#z8u31{||ySt0x-8S88cj`;w;(xs8rGLy9AwNt#ng{BMPm=-i&q&a3(^Na z&gCer4Qv=;=Dt$y%lSwe&W9gWUB|cGb=D-cW5!Ce`WzcUAqNz8iH%+{hAImk_PHw~ z4ejLBm@=yO<(1pSljB-4T-p`B+=Ku(a>}){<_YUMgDR!Uw5G;+CEbacGF|K0$7SR@ z#w5m8ua7kcrr%aY_q#lg^?K~lQ%C}#K z6fVpmd`TSaJln@Fed)J6x@OulC0^;B!`MsA)PN|?&LiGl3ub~El+3-#ZW zjuPj`IG1Ea=aAUHH!iYpV-{F-;aLqKqpqU(MRR)*KvUm#*6Cf!S_0 zjubyCy^ws(?&0cizKgkPAFFTeb5(~rsoWlKRCH#nE1|yAP$-G!eSgO;uHd;C>E7+5 zmEgR&w=KK+ELzq?Np8}UAH+PN)KmoHgc;Yb6JA&Pn@n{8VNA&&nZzLK;?X|1^b5=- zMKLH4%)Bn1YO*huH~@OtEs9UuUxhAK%ndOjChplML^VFb(xnwP_wYUCc-G-|3z}?_ zvCZW^h}z(5d-_eEOoTUMzC4)XLm7+fBAZVclY)A!OXzs+Q~}dtOHB7?=@TO=w0b%d z9#~W~+h*zH+Tv2tan2Pz%cGoTBlmPofyOl2|I2jfxH-FL)d3l+{7UqO9;Q6HDo#Dl|rt>LI8%*ZYQrF9P(LoCM+hc5|Cj;Z_;mDhT`HH1ZaIL*B=q~4s`eu_3LLCvl4lQJ3M{3t|2Or~~>W4ji@s*is zSrsF$n>$_AD8;b3Q-N_S-g)mx%6&6+r#!kl&)a%=4?-nQvDW%-=TwJ?HQbm7>*XIsk_-ukZMEVgNv4>LwzA2X&$^e7=OFMf)mEVrHmky zhjQZALN|^2=GGR6o?GygR(%@8D%3tYeijsO<@oP-{&4}OKuFe!|7Ai&+q=(8uw@`D zDam7CsOzfMB>N2%j`xVD8n1b2*L-_a;y>SOU-z&YZV}k|K!M@D@{V@O$|49CndJTR z<9tpheJ29jSopY{fB>A|Zmm3!@`IZ>4jRQI@JqdqtA0acN_=ukdexE&4JRdP@pU{9 z^7o`>MZ9K{;E9bz&+KoQM}L2NGptdyJ$a41huxzJGuo8ZEymGxXig1ZW`r)58D+Js z>!D=Wpo1m}?2xrMbZC!!k$cP9cRK@VoY!WP^q(XV-F*`Fmajp_qh5REc0$|1)u$Chl6pN1t-~>kRirLAr8Q3Q3Ad z+2tDMix2&0)lHfyPW8xKVMAG=?>CNwEDYbX(~85)M#RGNO9@_+()VLBh*jxWJI5qP zLdS@o2fpB`l0;rZq1{ui8{Rm*y=;N zQ}>(sn;SH=w0N|CW=yK~Mv~rxdWJl}y$+Sasd*(Vh?M*U0-;I~8jY7#u+eL7X{ zNBLnx#(t5)2g7vrJ46NtN5M!ih+uXon};d#0z3aesK+Iq3o+9Fh!?ju!x~lEtJjD> zc4A}Erk(u`&rtIx!VjY`AEx>~R>Bwx)YSHMeBx>8m5#aYhBbnO#>|nw)xO{DoluX0 z_~mXG(RY0xKH|Uz#(wnck^jw91$x1?b}0cHOz^Lg+qD@>uMa7@Z7mNXE6;-%#fug3 za8~9$2J$+hQ1m{5*`4BMQo4g2=cu{-y2wE8nzlH+%bsywaN=Hep~p$HW^(-XPHq?d ziju#mi{^T#t&68viLF|=(i;AeyPt)YRP~+7hdnW*k=-x6ivsHh&0XJ^OkH%*zdndv z;t){nV}EO&X36DV-MQ>aQyVgpxxsZewD(7oX_{%bJBZp+u?FR=Gr()UDVO`+1lX_ptI=T~QHzWyz&!R&FeOxZyAN5|PSV9`=hta9IdJ>dS{C-1{p z!s>4MlH_#=bWCo$*@(YxN~p{SGi>tds4(dRsLZ+qE>d$ICS*5H=VUb*1XB*yO2v`b z1`DaE?RT>wTiH7(!xXviZ1k}YO$%NqaDLL8klm!vFfndI$8epwv|6@Rdp#n%?f4k7 zeS}~S=WBJ7lu+aRF4!AalN+*D)nTRkO|xrd{sbFv>t|SXoN@V_)^MqJ`uE+cq}1If znC}DoXMm7S@K1Wq6lN>oZ~u;|)IV(uw0Rn?{8Tdd9?TE+Q7)G{bV6 zr%vIW-Q~xuJEnX$W=u#LVG45s&`8DJpZ(rm)Xlv=4QbP!-*{l0@7 zZn=E-TpIqzoAy>G&hV?du_|!FAjEhUo$K%B9MKDC^ZfI6i8VBq*1dFOdvP=N>5$vW?*Q(xEC2GZsiwXS^J%=dR7|d z=sF-NFByP#ZiuPxS9B)cIS?Dd8i`w!3`!#!vnrF*qoeAMbRR4VxoAdeS!I}X`6lF@ zA9^c#Y9v@cb;qr0aClS`jB&qkuZ+lVEm%T8jlIj#0;ml$=BfsC^!T*bB8g?TScDwxE{pXgEP;BaOUU{#aMomkyRdmazG}D}Z|mCD{)1C}d3ldq;Z2p`E^ewa z|8J2@)a!9x+j)T&toc?s)SvxUC zwJTG#sBX*QH}%EE=3fsquyZGT_HKa-GH+hJE6DXqy%Q4O{(VDXl@`+AUp#oy8+1GG z$$2X|t24m13M1&aGT5)UnPd7;_#(WFfrZb`B2sMO-RG-T8}xOY>HYr_C~mxW z<(YCarcvsnHPadFNjEi>%}^gOmMcZ$y;fidkFsHMDPE_ftWH+1+ZgbJ52@E7F4iFg z$~*&1$<6NYoB=yL$`m=Hx2i1Q(vv|_VhRP)K-ecNr)14dX%lsvYK0Ix{nDqQrW)b% z^`ok;HAcptVbTI-NbCc~MwxKQq8kK;%kIZks5zcER{2rTk+m5bFRKwYm$LC@VL zi(5`kKt?LR7Y{@pqBND62vAg9V)o$VJ_AgbJ}2(rJ3&q>o8A)i zLqi9K)ZsepG?&F;6RablS@={QzobVRC_Qu>SpiYhua#IVDNm|UX43+-ywI18N?&L1 z;S{px^t4xsdigbJylO;898zQ)p%_`V>+1PgoZI&ksr(iu8V8&9PwvOX4A51wQZ@FW zUw8&kRs}h$a}^xIPO&xvo>4Y_YnP+X2eTw2@e}u~X4PaeDkaw!Ea@phkj%&1_xleQ@V2RjCJ6Bnj2PkIY2+ z&Arxy33JL5-iY#2h^zoop%0z+SjiEfddJ$|PoM;dh8_9mU=W3+wc2A@kD7FOvr4zu z)$MC^5FV)&%5++fUwDJjPIh}8dD}CHsM+*`)1x-xaF7X#y=p{897Y8NO1Hu3<1Q&tPJ^dBa*Ecv4j5$~V8X3OvjM4@8D$E1Hp#d3~W#!nYID2b}M zM_A>%G&XjQ1g^5HbLF*{&?ncEzUK}NxpyzK3px02&n-yj=0vhTwL>>!cbj#Rs1irE zf?a}iDJc=eKT?$xzhxuiHARUME6xCsJp-j33fBH$*@>iY%>MFkKPab+Oy2g5wN&XI z{}EPjkJJ_0AzNP5^)=2=dwIF2WtCEPRY>-@=r-0my3Dkp0mDXkF$YeZACdcZP54UW zX1no&MpeBVZfWSuSI210X+TjxUPQd5EMRzFNQl`ILPcy|wOdhqZ|e)KOKVAj{v_7X z-*Ja`)Q^6yC53w-#iUk6tYx{K^Pa9fMKJaukeBX z(UE1qPO4x9t4yg$02_2d$wEtS|Fd9&Vl3Vp~r1war3p%kMZYb~3(xIRpKT?%zmn8Z_e)E;^)`th3v&0oGvdlP0 zgk^C0->7~z^LGAqxY;HbJr&zBSL}}>$Vg^p|^uvr7fe$A= z8rY2_X)EOcJ8;_cVLPPNw@fr%x^Dg7ATsc$dj3O96BT`AJ`uMRS#X0=p*7;v&H$>x zQOZG-vrlUdIy)cJy4`_)HD*E?Kz-vr%*U<>icojC^fMWDf60#Ja&GEZpR(CAjQi>& z;_U`aOP_sjof+|3S8!g=*UKi%pU>8rwV&^C&^wDI9(C0~vuZt`c9C->Z-%=FOXb!A zxyF>=WE!hgUg^g>z9C1sr6Nqf7R6JYij)h8Z$SK9x7zW~Xdh=X6e!ME4{9LgWs#*I z_WCMri1ikq6vlLbYmWFl^!C!J38b$P{-ykKx7Mu?#7wXo#`mW%r4{0awZU;rm$hV{ zoY$1ECY7mNgmG`RMg2-TD5LB8JjeNbhLoX3U>KBzBkx&&lwkl;NYVJYb6)cSLly%V z&6Es|!yC!Za==B8GHE1HA&7FI_Sz0>=7gEZY3qO8HoKnm&_|gf%JEe1V?vL+25^sB z)ud7?yA;4Xq?l=*US3OHdF3V{&FVj)^#U7uzhPh;Is__RJDilZ{EIf+oIQew2vL!J zbkWWhK8yDgmypO;J#Kdg5E;v*w4BKyztEs$TF7$qityqA10TB^{p|Z5P*Tu6c}-CO zDvIQ=3lko*i=WgT;yz|f1x6q|8h9n8m($osP<2w%9n(ax@8lpH1RSC9Rb>RnXyAAugZB}yK!3Oe1vy1ecj9F=;1Gh&ZhnM$TG#JMzyZo0xTR+a5h;EcPU z#4f#mQDIv0ZPgoWxe0};Wvtw;k|DE&fO>@qv2)v4v-#)a3DR-7w z1q~BPsq8F(u4(eT!FXk5BqKQQk4W@O62MRH3Lw;K=tvC=&p zkpFJiWW|c1KwJq}E&q$fL5FqZ^)`uqq2#DIb##delG6n1zy}?1`Q@^WUp&=9inZ1? z&tw8{GwwNvi-$a)zy+e;oof`Dj4;Dy*IXNE#9M@yeHXaY;jdFeUh!VV{ZaNzuz`-B z=YL#@KTf2NHW=b3mlunR{ZY(mmP=eMqzZux6X1Sq5f0*iWC@~)Ey%hN`ci+qo7a?{Z@|SW%J<;Sy_Q82qB9-7>K%` zT&BqIyB~>)MFN(W^s+mYc=+~rpeOm;Cb({%U1?O6Q=HnhXvxSYH~McL&cN#q$| zV4zT#dDnOZ|3850k5_>ROJFm8N@b!PB2yR8#$2K|PZcjo2yWm!51&41T*|p zM|MJ1c+->ow<0O6)9r5b>sLlksN~0^dhWcbsY1il6Zl%hZ^(`D=DZxb=sr*FO&1*J z@!XBU7B@`Xt}$%`Th`L}#O9|m+TvC%;v$IU>3xMvqI=8#-G4VIdOKa|M<=fu*H?vZ z>b7mu(>D0wc*Dq+MxSn*4&jl}I(QZ?+3fbr{QUfMR;w>#mJf%|zCs))i)z zP7!m6WTHT{V6N9Pr{B32U!HN2+}0PbtU|x=#N2Ln)-J<0dvx;PTDn=!Y50XI6>FFD zrz!$t1G|S7@6Ds$cU-U(dx1%w<}E`{8*Q;Yn;XwNy+($8{7{ejriUD%%Yzijzdzso z*Mnao_uu2wbFXV1XWiQUH9kv$aBIudHorB^9br$<;jS&Y3EH}y+%FFfgOwflbvv%E z7V#C-49RO)@%Rjvw;0Aa+NRbgOUnns@DtiPpAWIIwx38kLS8X5w^ucXIEvhfdHLU+ M+yAL{;b-Ij2S*HOvH$=8 literal 0 HcmV?d00001 diff --git a/mcp/packages/plugin/public/manifest.json b/mcp/packages/plugin/public/manifest.json index e2a769c7f83..aa97095b301 100644 --- a/mcp/packages/plugin/public/manifest.json +++ b/mcp/packages/plugin/public/manifest.json @@ -1,6 +1,7 @@ { "name": "Penpot MCP Plugin", "code": "plugin.js", + "icon": "icon.jpg", "version": 2, "description": "This plugin enables interaction with the Penpot MCP server", "permissions": ["content:read", "content:write", "library:read", "library:write", "comment:read", "comment:write"] diff --git a/mcp/packages/plugin/src/PenpotUtils.ts b/mcp/packages/plugin/src/PenpotUtils.ts index 964cf70f4cf..2c2cc3e4e5f 100644 --- a/mcp/packages/plugin/src/PenpotUtils.ts +++ b/mcp/packages/plugin/src/PenpotUtils.ts @@ -1,4 +1,4 @@ -import { Board, Fill, FlexLayout, GridLayout, Page, Rectangle, Shape } from "@penpot/plugin-types"; +import { Board, Bounds, Fill, FlexLayout, GridLayout, Page, Rectangle, Shape, Text } from "@penpot/plugin-types"; export class PenpotUtils { /** @@ -189,6 +189,24 @@ export class PenpotUtils { return penpot.generateStyle([shape], { type: "css", includeChildren: true }); } + /** + * Gets the actual rendering bounds of a shape. For most shapes, this is simply the `bounds` property. + * However, for Text shapes, the `bounds` may not reflect the true size of the rendered text content, + * so we use the `textBounds` property instead. + * + * @param shape - The shape to get the bounds for + */ + public static getBounds(shape: Shape): Bounds { + if (shape.type === "text") { + const text = shape as Text; + // TODO: Remove ts-ignore once type definitions are updated + // @ts-ignore + return text.textBounds; + } else { + return shape.bounds; + } + } + /** * Checks if a child shape is fully contained within its parent's bounds. * Visual containment means all edges of the child are within the parent's bounding box. @@ -198,11 +216,13 @@ export class PenpotUtils { * @returns true if child is fully contained within parent bounds, false otherwise */ public static isContainedIn(child: Shape, parent: Shape): boolean { + const childBounds = this.getBounds(child); + const parentBounds = this.getBounds(parent); return ( - child.x >= parent.x && - child.y >= parent.y && - child.x + child.width <= parent.x + parent.width && - child.y + child.height <= parent.y + parent.height + childBounds.x >= parentBounds.x && + childBounds.y >= parentBounds.y && + childBounds.x + childBounds.width <= parentBounds.x + parentBounds.width && + childBounds.y + childBounds.height <= parentBounds.y + parentBounds.height ); } @@ -298,39 +318,16 @@ export class PenpotUtils { /** * Decodes a base64 string to a Uint8Array. - * This is required because the Penpot plugin environment does not provide the atob function. * * @param base64 - The base64-encoded string to decode * @returns The decoded data as a Uint8Array */ - public static atob(base64: string): Uint8Array { - const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - const lookup = new Uint8Array(256); - for (let i = 0; i < chars.length; i++) { - lookup[chars.charCodeAt(i)] = i; + public static base64ToByteArray(base64: string): Uint8Array { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); } - - let bufferLength = base64.length * 0.75; - if (base64[base64.length - 1] === "=") { - bufferLength--; - if (base64[base64.length - 2] === "=") { - bufferLength--; - } - } - - const bytes = new Uint8Array(bufferLength); - let p = 0; - for (let i = 0; i < base64.length; i += 4) { - const encoded1 = lookup[base64.charCodeAt(i)]; - const encoded2 = lookup[base64.charCodeAt(i + 1)]; - const encoded3 = lookup[base64.charCodeAt(i + 2)]; - const encoded4 = lookup[base64.charCodeAt(i + 3)]; - - bytes[p++] = (encoded1 << 2) | (encoded2 >> 4); - bytes[p++] = ((encoded2 & 15) << 4) | (encoded3 >> 2); - bytes[p++] = ((encoded3 & 3) << 6) | (encoded4 & 63); - } - return bytes; } @@ -360,7 +357,7 @@ export class PenpotUtils { height: number | undefined ): Promise { // convert base64 to Uint8Array - const bytes = PenpotUtils.atob(base64); + const bytes = PenpotUtils.base64ToByteArray(base64); // upload the image data to Penpot const imageData = await penpot.uploadMediaData(name, bytes, mimeType); @@ -423,6 +420,11 @@ export class PenpotUtils { * - For mode="fill", it will be whatever format the fill image is stored in. */ public static async exportImage(shape: Shape, mode: "shape" | "fill", asSVG: boolean): Promise { + // Updates are asynchronous in Penpot, so wait a tick to ensure any pending updates are applied before export. + // The constant wait time is a temporary workardound until a better solution for penpot/penpot-mcp#27 + // is implemented. + await new Promise((resolve) => setTimeout(resolve, 200)); + // Perform export switch (mode) { case "shape": return shape.export({ type: asSVG ? "svg" : "png" }); diff --git a/mcp/packages/plugin/src/index.d.ts b/mcp/packages/plugin/src/index.d.ts new file mode 100644 index 00000000000..42587c83046 --- /dev/null +++ b/mcp/packages/plugin/src/index.d.ts @@ -0,0 +1,21 @@ +import "@penpot/plugin-types"; + +declare module "@penpot/plugin-types" { + interface Penpot { + /** The Penpot application version string. */ + version: string; + } +} + +interface McpOptions { + getToken(): string; + getServerUrl(): string; + setMcpStatus(status: string); + on(eventType: "disconnect" | "connect", cb: () => void); +} + +declare global { + const mcp: undefined | McpOptions; +} + +export {}; diff --git a/mcp/packages/plugin/src/main.ts b/mcp/packages/plugin/src/main.ts index 18877d35dd0..40b5bd7ba80 100644 --- a/mcp/packages/plugin/src/main.ts +++ b/mcp/packages/plugin/src/main.ts @@ -1,29 +1,74 @@ import "./style.css"; // get the current theme from the URL -const searchParams = new URLSearchParams(window.location.search); +const searchParams = new URLSearchParams(window.location.hash.split("?")[1]); document.body.dataset.theme = searchParams.get("theme") ?? "light"; -// Determine whether multi-user mode is enabled based on URL parameters -const isMultiUserMode = searchParams.get("multiUser") === "true"; -console.log("Penpot MCP multi-user mode:", isMultiUserMode); - // WebSocket connection management let ws: WebSocket | null = null; -const statusElement = document.getElementById("connection-status"); + +const statusPill = document.getElementById("connection-status") as HTMLElement; +const statusText = document.getElementById("status-text") as HTMLElement; +const currentTaskEl = document.getElementById("current-task") as HTMLElement; +const executedCodeEl = document.getElementById("executed-code") as HTMLTextAreaElement; +const copyCodeBtn = document.getElementById("copy-code-btn") as HTMLButtonElement; +const connectBtn = document.getElementById("connect-btn") as HTMLButtonElement; +const disconnectBtn = document.getElementById("disconnect-btn") as HTMLButtonElement; +const versionWarningEl = document.getElementById("version-warning") as HTMLElement; +const versionWarningTextEl = document.getElementById("version-warning-text") as HTMLElement; + +/** + * Updates the status pill and button visibility based on connection state. + * + * @param code - the connection state code ("idle" | "connecting" | "connected" | "disconnected" | "error") + * @param label - human-readable label to display inside the pill + */ +function updateConnectionStatus(code: string, label: string): void { + if (statusPill) { + statusPill.dataset.status = code; + } + if (statusText) { + statusText.textContent = label; + } + + const isConnected = code === "connected"; + if (connectBtn) connectBtn.hidden = isConnected; + if (disconnectBtn) disconnectBtn.hidden = !isConnected; + + parent.postMessage( + { + type: "update-connection-status", + status: code, + }, + "*" + ); +} /** - * Updates the connection status display element. + * Updates the "Current task" display with the currently executing task name. * - * @param status - the base status text to display - * @param isConnectedState - whether the connection is in a connected state (affects color) - * @param message - optional additional message to append to the status + * @param taskName - the task name to display, or null to reset to "---" */ -function updateConnectionStatus(status: string, isConnectedState: boolean, message?: string): void { - if (statusElement) { - const displayText = message ? `${status}: ${message}` : status; - statusElement.textContent = displayText; - statusElement.style.color = isConnectedState ? "var(--accent-primary)" : "var(--error-700)"; +function updateCurrentTask(taskName: string | null): void { + if (currentTaskEl) { + currentTaskEl.textContent = taskName ?? "---"; + } + if (taskName === null) { + updateExecutedCode(null); + } +} + +/** + * Updates the executed code textarea with the last code run during task execution. + * + * @param code - the code string to display, or null to clear + */ +function updateExecutedCode(code: string | null): void { + if (executedCodeEl) { + executedCodeEl.value = code ?? ""; + } + if (copyCodeBtn) { + copyCodeBtn.disabled = !code; } } @@ -44,31 +89,41 @@ function sendTaskResponse(response: any): void { /** * Establishes a WebSocket connection to the MCP server. */ -function connectToMcpServer(): void { +function connectToMcpServer(baseUrl?: string, token?: string): void { if (ws?.readyState === WebSocket.OPEN) { - updateConnectionStatus("Already connected", true); + updateConnectionStatus("connected", "Connected"); return; } try { - let wsUrl = PENPOT_MCP_WEBSOCKET_URL; - if (isMultiUserMode) { - // TODO obtain proper userToken from penpot - const userToken = "dummyToken"; - wsUrl += `?userToken=${encodeURIComponent(userToken)}`; + let wsUrl = baseUrl || PENPOT_MCP_WEBSOCKET_URL; + let wsError: unknown | undefined; + + if (token) { + wsUrl += `?userToken=${encodeURIComponent(token)}`; } + ws = new WebSocket(wsUrl); - updateConnectionStatus("Connecting...", false); + updateConnectionStatus("connecting", "Connecting..."); ws.onopen = () => { - console.log("Connected to MCP server"); - updateConnectionStatus("Connected to MCP server", true); + setTimeout(() => { + if (ws) { + console.log("Connected to MCP server"); + updateConnectionStatus("connected", "Connected"); + } + }, 100); }; ws.onmessage = (event) => { - console.log("Received from MCP server:", event.data); try { + console.log("Received from MCP server:", event.data); const request = JSON.parse(event.data); + // Track the current task received from the MCP server + if (request.task) { + updateCurrentTask(request.task); + updateExecutedCode(request.params?.code ?? null); + } // Forward the task request to the plugin for execution parent.postMessage(request, "*"); } catch (error) { @@ -77,34 +132,70 @@ function connectToMcpServer(): void { }; ws.onclose = (event: CloseEvent) => { - console.log("Disconnected from MCP server"); - const message = event.reason || undefined; - updateConnectionStatus("Disconnected", false, message); + // If we've send the error update we don't send the disconnect as well + if (!wsError) { + console.log("Disconnected from MCP server"); + const label = event.reason ? `Disconnected: ${event.reason}` : "Disconnected"; + updateConnectionStatus("disconnected", label); + updateCurrentTask(null); + } ws = null; }; ws.onerror = (error) => { console.error("WebSocket error:", error); + wsError = error; // note: WebSocket error events typically don't contain detailed error messages - updateConnectionStatus("Connection error", false); + updateConnectionStatus("error", "Connection error"); }; } catch (error) { console.error("Failed to connect to MCP server:", error); - const message = error instanceof Error ? error.message : undefined; - updateConnectionStatus("Connection failed", false, message); + const reason = error instanceof Error ? error.message : undefined; + const label = reason ? `Connection failed: ${reason}` : "Connection failed"; + updateConnectionStatus("error", label); } } -document.querySelector("[data-handler='connect-mcp']")?.addEventListener("click", () => { +copyCodeBtn?.addEventListener("click", () => { + const code = executedCodeEl?.value; + if (!code) return; + + navigator.clipboard.writeText(code).then(() => { + copyCodeBtn.classList.add("copied"); + setTimeout(() => copyCodeBtn.classList.remove("copied"), 1500); + }); +}); + +connectBtn?.addEventListener("click", () => { connectToMcpServer(); }); +disconnectBtn?.addEventListener("click", () => { + ws?.close(); +}); + // Listen plugin.ts messages window.addEventListener("message", (event) => { - if (event.data.source === "penpot") { + if (event.data.type === "start-server") { + connectToMcpServer(event.data.url, event.data.token); + } + if (event.data.type === "version-mismatch") { + if (versionWarningEl && versionWarningTextEl) { + versionWarningTextEl.innerHTML = + `Version mismatch detected: This version of the MCP server is intended for Penpot ` + + `${event.data.mcpVersion} while the current version is ${event.data.penpotVersion}. ` + + `Executions may not work or produce suboptimal results.`; + versionWarningEl.hidden = false; + } + } + if (event.data.type === "stop-server") { + ws?.close(); + } else if (event.data.source === "penpot") { document.body.dataset.theme = event.data.theme; } else if (event.data.type === "task-response") { // Forward task response back to MCP server sendTaskResponse(event.data.response); } }); + +parent.postMessage({ type: "ui-initialized" }, "*"); diff --git a/mcp/packages/plugin/src/plugin.ts b/mcp/packages/plugin/src/plugin.ts index e6a1fad33e5..3827db70ebe 100644 --- a/mcp/packages/plugin/src/plugin.ts +++ b/mcp/packages/plugin/src/plugin.ts @@ -1,22 +1,58 @@ import { ExecuteCodeTaskHandler } from "./task-handlers/ExecuteCodeTaskHandler"; import { Task, TaskHandler } from "./TaskHandler"; +/** + * Extracts the major.minor.patch prefix from a version string. + * + * @param version - a version string starting with major.minor.patch + * @returns the major.minor.patch prefix, or the original string if it does not match + */ +function extractVersionPrefix(version: string): string { + const match = version.match(/^(\d+\.\d+\.\d+)/); + return match ? match[1] : version; +} + +mcp?.setMcpStatus("connecting"); + /** * Registry of all available task handlers. */ const taskHandlers: TaskHandler[] = [new ExecuteCodeTaskHandler()]; -// Determine whether multi-user mode is enabled based on build-time configuration -declare const IS_MULTI_USER_MODE: boolean; -const isMultiUserMode = typeof IS_MULTI_USER_MODE !== "undefined" ? IS_MULTI_USER_MODE : false; - // Open the plugin UI (main.ts) -penpot.ui.open("Penpot MCP Plugin", `?theme=${penpot.theme}&multiUser=${isMultiUserMode}`, { width: 158, height: 200 }); +penpot.ui.open("Penpot MCP Plugin", `?theme=${penpot.theme}`, { + width: 236, + height: 210, + hidden: !!mcp, +} as any); -// Handle messages -penpot.ui.onMessage((message) => { - // Handle plugin task requests - if (typeof message === "object" && message.task && message.id) { +// Register message handlers +penpot.ui.onMessage((message) => { + if (typeof message === "object" && message.type === "ui-initialized") { + // Check Penpot version compatibility + const penpotVersionPrefix = penpot.version ? extractVersionPrefix(penpot.version) : "<2.15"; // pre-2.15 versions don't have version info + const mcpVersionPrefix = extractVersionPrefix(PENPOT_MCP_VERSION); + console.log(`Penpot version: ${penpotVersionPrefix}, MCP version: ${mcpVersionPrefix}`); + const isLocalPenpotVersion = penpotVersionPrefix == "0.0.0"; + if (penpotVersionPrefix !== mcpVersionPrefix && !isLocalPenpotVersion) { + penpot.ui.sendMessage({ + type: "version-mismatch", + mcpVersion: mcpVersionPrefix, + penpotVersion: penpotVersionPrefix, + }); + } + // Initiate connection to remote MCP server (if enabled) + if (mcp) { + penpot.ui.sendMessage({ + type: "start-server", + url: mcp?.getServerUrl(), + token: mcp?.getToken(), + }); + } + } else if (typeof message === "object" && message.type === "update-connection-status") { + mcp?.setMcpStatus(message.status || "unknown"); + } else if (typeof message === "object" && message.task && message.id) { + // Handle plugin tasks submitted by the MCP server handlePluginTaskRequest(message).catch((error) => { console.error("Error in handlePluginTaskRequest:", error); }); @@ -59,6 +95,21 @@ async function handlePluginTaskRequest(request: { id: string; task: string; para } } +if (mcp) { + mcp.on("disconnect", async () => { + penpot.ui.sendMessage({ + type: "stop-server", + }); + }); + mcp.on("connect", async () => { + penpot.ui.sendMessage({ + type: "start-server", + url: mcp?.getServerUrl(), + token: mcp?.getToken(), + }); + }); +} + // Handle theme change in the iframe penpot.on("themechange", (theme) => { penpot.ui.sendMessage({ diff --git a/mcp/packages/plugin/src/style.css b/mcp/packages/plugin/src/style.css index 030f2204e97..53e0a9da3d8 100644 --- a/mcp/packages/plugin/src/style.css +++ b/mcp/packages/plugin/src/style.css @@ -1,10 +1,190 @@ @import "@penpot/plugin-styles/styles.css"; body { + margin: 0; + padding: 0; +} + +.plugin-container { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding: var(--spacing-16) var(--spacing-8); + box-sizing: border-box; +} + +/* ── Status pill ─────────────────────────────────────────────────── */ + +.status-pill { + display: flex; + align-items: center; + justify-content: center; + gap: var(--spacing-8); + padding: var(--spacing-8) var(--spacing-16); + border-radius: var(--spacing-8); + border: 1px solid var(--background-quaternary); + color: var(--foreground-secondary); + width: 100%; + box-sizing: border-box; +} + +.status-pill[data-status="connected"] { + border-color: var(--accent-primary); + color: var(--accent-primary); +} + +.status-pill[data-status="disconnected"], +.status-pill[data-status="error"] { + border-color: var(--error-500); + color: var(--error-500); +} + +.status-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background-color: currentColor; + flex-shrink: 0; +} + +/* ── Collapsible section ─────────────────────────────────────────── */ + +.collapsible-section { + border: 1px solid var(--background-quaternary); + border-radius: var(--spacing-8); + overflow: hidden; +} + +.collapsible-header { + display: flex; + align-items: center; + gap: var(--spacing-8); + padding: var(--spacing-8) var(--spacing-12); + cursor: pointer; + color: var(--foreground-secondary); + list-style: none; + user-select: none; +} + +.collapsible-header::-webkit-details-marker { + display: none; +} + +.collapsible-arrow { + flex-shrink: 0; + transition: transform 0.2s ease; +} + +details[open] > .collapsible-header .collapsible-arrow { + transform: rotate(90deg); +} + +.collapsible-body { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + padding: var(--spacing-4) var(--spacing-12) var(--spacing-12); + border-top: 1px solid var(--background-quaternary); +} + +/* ── Tool section ────────────────────────────────────────────────── */ + +.tool-label { + color: var(--foreground-secondary); +} + +.tool-display { + display: flex; + align-items: center; + gap: var(--spacing-8); + padding: var(--spacing-8) var(--spacing-12); + border-radius: var(--spacing-8); + background-color: var(--background-tertiary); + color: var(--foreground-secondary); + min-height: 32px; + box-sizing: border-box; +} + +.tool-icon { + flex-shrink: 0; + opacity: 0.7; +} + +/* ── Code section ────────────────────────────────────────────────── */ + +.code-section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: var(--spacing-8); +} + +.code-textarea { + width: 100%; + height: 100px; + resize: vertical; + padding: var(--spacing-8) var(--spacing-12); + border-radius: var(--spacing-8); + border: 1px solid var(--background-quaternary); + background-color: var(--background-tertiary); + color: var(--foreground-secondary); + font-family: monospace; + font-size: 11px; line-height: 1.5; - padding: 10px; + box-sizing: border-box; + outline: none; } -p { - margin-block-end: 0.75rem; +.copy-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: 1px solid var(--background-quaternary); + border-radius: var(--spacing-4); + background-color: transparent; + color: var(--foreground-secondary); + cursor: pointer; + flex-shrink: 0; + transition: + background-color 0.15s ease, + color 0.15s ease; +} + +.copy-btn:hover:not(:disabled) { + background-color: var(--background-tertiary); + color: var(--foreground-primary); +} + +.copy-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.copy-btn.copied { + color: var(--accent-primary); + border-color: var(--accent-primary); +} + +/* ── Version warning ─────────────────────────────────────────────── */ + +.version-warning { + align-items: flex-start; + padding: var(--spacing-8) var(--spacing-12); + border-radius: var(--spacing-8); + border: 1px solid var(--warning-500, #f59e0b); + color: var(--warning-500, #f59e0b); + width: 100%; + box-sizing: border-box; +} + +/* ── Action buttons ──────────────────────────────────────────────── */ + +#connect-btn, +#disconnect-btn { + width: 100%; + margin-top: var(--spacing-4); } diff --git a/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts b/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts index 86fc62710f7..85ed5a32d1c 100644 --- a/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts +++ b/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts @@ -195,10 +195,44 @@ export class ExecuteCodeTaskHandler extends TaskHandler { const context = this.context; const code = task.params.code; - let result: any = await (async (ctx) => { - const fn = new Function(...Object.keys(ctx), `return (async () => { ${code} })();`); - return fn(...Object.values(ctx)); - })(context); + // set the flags naturalChildOrdering and throwValidationErrors to true during code execution. + // TODO: Remove all ts-ignore once Penpot types have been updated + let originalNaturalChildOrdering: any, originalThrowValidationErrors: any; + // @ts-ignore + if (penpot.flags) { + // @ts-ignore + originalNaturalChildOrdering = penpot.flags.naturalChildOrdering; + // @ts-ignore + penpot.flags.naturalChildOrdering = true; + // @ts-ignore + originalThrowValidationErrors = penpot.flags.throwValidationErrors; + // @ts-ignore + penpot.flags.throwValidationErrors = true; + } else { + // TODO: This can be removed once `flags` has been merged to PROD + throw new Error( + "You are using a version of the Penpot MCP server which is incompatible " + + "with the connected Penpot version. " + + "Read the documentation for instructions on which version to use: " + + "https://github.com/penpot/penpot/tree/develop/mcp\n" + + "If you are an LLM, tell the user about this!" + ); + } + + let result: any; + try { + // execute the code in an async function with the context variables as parameters + result = await (async (ctx) => { + const fn = new Function(...Object.keys(ctx), `return (async () => { ${code} })();`); + return fn(...Object.values(ctx)); + })(context); + } finally { + // restore the original value of the flags + // @ts-ignore + penpot.flags.naturalChildOrdering = originalNaturalChildOrdering; + // @ts-ignore + penpot.flags.throwValidationErrors = originalThrowValidationErrors; + } console.log("Code execution result:", result); diff --git a/mcp/packages/plugin/src/vite-env.d.ts b/mcp/packages/plugin/src/vite-env.d.ts index ddbf746e043..252ff654afa 100644 --- a/mcp/packages/plugin/src/vite-env.d.ts +++ b/mcp/packages/plugin/src/vite-env.d.ts @@ -2,3 +2,4 @@ declare const IS_MULTI_USER_MODE: boolean; declare const PENPOT_MCP_WEBSOCKET_URL: string; +declare const PENPOT_MCP_VERSION: string; diff --git a/mcp/packages/plugin/vite.config.ts b/mcp/packages/plugin/vite.config.ts index 38e610f2479..9e79ca7f732 100644 --- a/mcp/packages/plugin/vite.config.ts +++ b/mcp/packages/plugin/vite.config.ts @@ -1,11 +1,16 @@ import { defineConfig } from "vite"; import livePreview from "vite-live-preview"; +import { createRequire } from "module"; + +const require = createRequire(import.meta.url); +const rootPkg = require("../../package.json"); let WS_URI = process.env.WS_URI || "http://localhost:4402"; -let MULTI_USER_MODE = process.env.MULTI_USER_MODE === "true"; +let SERVER_HOST = process.env.PENPOT_MCP_PLUGIN_SERVER_HOST ?? "localhost"; +let MCP_VERSION = JSON.stringify(rootPkg.version); -console.log("Will define IS_MULTI_USER_MODE as:", JSON.stringify(MULTI_USER_MODE)); -console.log("Will define PENPOT_MCP_WEBSOCKET_URL as:", JSON.stringify(WS_URI)); +console.log("PENPOT_MCP_WEBSOCKET_URL:", JSON.stringify(WS_URI)); +console.log("PENPOT_MCP_VERSION:", MCP_VERSION); export default defineConfig({ base: "./", @@ -31,13 +36,13 @@ export default defineConfig({ }, }, preview: { - host: "0.0.0.0", + host: SERVER_HOST, port: 4400, cors: true, allowedHosts: [], }, define: { - IS_MULTI_USER_MODE: JSON.stringify(process.env.MULTI_USER_MODE === "true"), PENPOT_MCP_WEBSOCKET_URL: JSON.stringify(WS_URI), + PENPOT_MCP_VERSION: MCP_VERSION, }, }); diff --git a/mcp/packages/plugin/vite.release.config.ts b/mcp/packages/plugin/vite.release.config.ts index 462fc9f5980..7156a2f85cd 100644 --- a/mcp/packages/plugin/vite.release.config.ts +++ b/mcp/packages/plugin/vite.release.config.ts @@ -4,7 +4,6 @@ import baseConfig from "./vite.config"; export default mergeConfig( baseConfig, defineConfig({ - base: "./", plugins: [], }) ); diff --git a/mcp/packages/server/.gitignore b/mcp/packages/server/.gitignore index e69de29bb2d..54e8e7dc16e 100644 --- a/mcp/packages/server/.gitignore +++ b/mcp/packages/server/.gitignore @@ -0,0 +1 @@ +/pnpm-lock.yaml diff --git a/mcp/packages/server/data/api_types.yml b/mcp/packages/server/data/api_types.yml index 18079c5c3d2..901037c2493 100644 --- a/mcp/packages/server/data/api_types.yml +++ b/mcp/packages/server/data/api_types.yml @@ -11,7 +11,7 @@ Penpot: open: ( name: string, url: string, - options?: { width: number; height: number }, + options?: { width: number; height: number; hidden: boolean }, ) => void; size: { width: number; height: number } | null; resize: (width: number, height: number) => void; @@ -84,6 +84,7 @@ Penpot: distributeHorizontal(shapes: Shape[]): void; distributeVertical(shapes: Shape[]): void; flatten(shapes: Shape[]): Path[]; + createVariantFromComponents(shapes: Board[]): VariantContainer; } ``` @@ -99,7 +100,7 @@ Penpot: open: ( name: string, url: string, - options?: { width: number; height: number }, + options?: { width: number; height: number; hidden: boolean }, ) => void; size: { width: number; height: number } | null; resize: (width: number, height: number) => void; @@ -110,7 +111,7 @@ Penpot: Type Declaration - * open: (name: string, url: string, options?: { width: number; height: number }) => void + * open: ( name: string, url: string, options?: { width: number; height: number; hidden: boolean },) => void Opens the plugin UI. It is possible to develop a plugin without interface (see Palette color example) but if you need, the way to open this UI is using `penpot.ui.open`. There is a minimum and maximum size for this modal and a default size but it's possible to customize it anyway with the options parameter. @@ -823,6 +824,24 @@ Penpot: to flatten Returns Path[] + createVariantFromComponents: |- + ``` + createVariantFromComponents(shapes: Board[]): VariantContainer + ``` + + Combine several standard Components into a VariantComponent. Similar to doing it + with the contextual menu on the Penpot interface. + All the shapes passed as arguments should be main instances. + + Parameters + + * shapes: Board[] + + A list of main instances of the components to combine. + + Returns VariantContainer + + The variant container created ActiveUser: overview: |- Interface ActiveUser @@ -1062,7 +1081,7 @@ Board: rotation: number; strokes: Stroke[]; layoutChild?: LayoutChildProperties; - layoutCell?: LayoutChildProperties; + layoutCell?: LayoutCellProperties; setParentIndex(index: number): void; tokens: { width: string; @@ -1071,10 +1090,10 @@ Board: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -1090,14 +1109,14 @@ Board: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -1114,7 +1133,7 @@ Board: detach(): void; swapComponent(component: LibraryComponent): void; switchVariant(pos: number, value: string): void; - combineAsVariants(ids: string[]): void; + combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; resize(width: number, height: number): void; rotate(angle: number, center?: { x: number; y: number } | null): void; @@ -1193,12 +1212,20 @@ Board: ``` The horizontal sizing behavior of the board. + It can be one of the following values: + + * 'fix': The containers has its own intrinsic fixed size. + * 'auto': The container fits the content. verticalSizing: |- ``` verticalSizing?: "auto" | "fix" ``` The vertical sizing behavior of the board. + It can be one of the following values: + + * 'fix': The containers has its own intrinsic fixed size. + * 'auto': The container fits the content. fills: |- ``` fills: Fill[] @@ -1456,7 +1483,7 @@ Board: Layout properties for children of the shape. layoutCell: |- ``` - readonly layoutCell?: LayoutChildProperties + readonly layoutCell?: LayoutCellProperties ``` Layout properties for cells in a grid layout. @@ -1469,10 +1496,10 @@ Board: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -1488,14 +1515,14 @@ Board: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -1881,7 +1908,7 @@ Board: Returns void combineAsVariants: |- ``` - combineAsVariants(ids: string[]): void + combineAsVariants(ids: string[]): VariantContainer ``` Combine several standard Components into a VariantComponent. Similar to doing it with the contextual menu @@ -1894,7 +1921,9 @@ Board: A list of ids of the main instances of the components to combine with this one. - Returns void + Returns VariantContainer + + The variant container created isVariantHead: |- ``` isVariantHead(): boolean @@ -2171,7 +2200,7 @@ VariantContainer: rotation: number; strokes: Stroke[]; layoutChild?: LayoutChildProperties; - layoutCell?: LayoutChildProperties; + layoutCell?: LayoutCellProperties; setParentIndex(index: number): void; tokens: { width: string; @@ -2180,10 +2209,10 @@ VariantContainer: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -2199,14 +2228,14 @@ VariantContainer: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -2223,7 +2252,7 @@ VariantContainer: detach(): void; swapComponent(component: LibraryComponent): void; switchVariant(pos: number, value: string): void; - combineAsVariants(ids: string[]): void; + combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; resize(width: number, height: number): void; rotate(angle: number, center?: { x: number; y: number } | null): void; @@ -2250,7 +2279,7 @@ VariantContainer: * Board + VariantContainer - Referenced by: ContextTypesUtils + Referenced by: Board, Boolean, Context, ContextTypesUtils, Ellipse, Group, Image, Path, Penpot, Rectangle, ShapeBase, SvgRaw, Text, VariantContainer members: Properties: type: |- @@ -2301,12 +2330,20 @@ VariantContainer: ``` The horizontal sizing behavior of the board. + It can be one of the following values: + + * 'fix': The containers has its own intrinsic fixed size. + * 'auto': The container fits the content. verticalSizing: |- ``` verticalSizing?: "auto" | "fix" ``` The vertical sizing behavior of the board. + It can be one of the following values: + + * 'fix': The containers has its own intrinsic fixed size. + * 'auto': The container fits the content. fills: |- ``` fills: Fill[] @@ -2568,7 +2605,7 @@ VariantContainer: Layout properties for children of the shape. layoutCell: |- ``` - readonly layoutCell?: LayoutChildProperties + readonly layoutCell?: LayoutCellProperties ``` Layout properties for cells in a grid layout. @@ -2581,10 +2618,10 @@ VariantContainer: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -2600,14 +2637,14 @@ VariantContainer: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -2993,7 +3030,7 @@ VariantContainer: Returns void combineAsVariants: |- ``` - combineAsVariants(ids: string[]): void + combineAsVariants(ids: string[]): VariantContainer ``` Combine several standard Components into a VariantComponent. Similar to doing it with the contextual menu @@ -3006,7 +3043,9 @@ VariantContainer: A list of ids of the main instances of the components to combine with this one. - Returns void + Returns VariantContainer + + The variant container created isVariantHead: |- ``` isVariantHead(): boolean @@ -3270,7 +3309,7 @@ Boolean: rotation: number; strokes: Stroke[]; layoutChild?: LayoutChildProperties; - layoutCell?: LayoutChildProperties; + layoutCell?: LayoutCellProperties; setParentIndex(index: number): void; tokens: { width: string; @@ -3279,10 +3318,10 @@ Boolean: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -3298,14 +3337,14 @@ Boolean: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -3322,7 +3361,7 @@ Boolean: detach(): void; swapComponent(component: LibraryComponent): void; switchVariant(pos: number, value: string): void; - combineAsVariants(ids: string[]): void; + combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; resize(width: number, height: number): void; rotate(angle: number, center?: { x: number; y: number } | null): void; @@ -3629,7 +3668,7 @@ Boolean: Layout properties for children of the shape. layoutCell: |- ``` - readonly layoutCell?: LayoutChildProperties + readonly layoutCell?: LayoutCellProperties ``` Layout properties for cells in a grid layout. @@ -3642,10 +3681,10 @@ Boolean: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -3661,14 +3700,14 @@ Boolean: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -4005,7 +4044,7 @@ Boolean: Returns void combineAsVariants: |- ``` - combineAsVariants(ids: string[]): void + combineAsVariants(ids: string[]): VariantContainer ``` Combine several standard Components into a VariantComponent. Similar to doing it with the contextual menu @@ -4018,7 +4057,9 @@ Boolean: A list of ids of the main instances of the components to combine with this one. - Returns void + Returns VariantContainer + + The variant container created isVariantHead: |- ``` isVariantHead(): boolean @@ -4575,8 +4616,8 @@ CommonLayout: leftPadding: number; horizontalSizing: "fill" | "auto" - | "fit-content"; - verticalSizing: "fill" | "auto" | "fit-content"; + | "fix"; + verticalSizing: "fill" | "auto" | "fix"; remove(): void; } ``` @@ -4706,26 +4747,26 @@ CommonLayout: The `leftPadding` property specifies the padding at the left of the container. horizontalSizing: |- ``` - horizontalSizing: "fill" | "auto" | "fit-content" + horizontalSizing: "fill" | "auto" | "fix" ``` The `horizontalSizing` property specifies the horizontal sizing behavior of the container. It can be one of the following values: - * 'fit-content': The container fits the content. - * 'fill': The container fills the available space. - * 'auto': The container size is determined automatically. + * 'fix': The containers has its own intrinsic fixed size. + * 'fill': The container fills the available space. Only can be set if it's inside another layout. + * 'auto': The container fits the content. verticalSizing: |- ``` - verticalSizing: "fill" | "auto" | "fit-content" + verticalSizing: "fill" | "auto" | "fix" ``` The `verticalSizing` property specifies the vertical sizing behavior of the container. It can be one of the following values: - * 'fit-content': The container fits the content. - * 'fill': The container fills the available space. - * 'auto': The container size is determined automatically. + * 'fix': The containers has its own intrinsic fixed size. + * 'fill': The container fills the available space. Only can be set if it's inside another layout. + * 'auto': The container fits the content. Methods: remove: |- ``` @@ -4808,6 +4849,7 @@ Context: distributeHorizontal(shapes: Shape[]): void; distributeVertical(shapes: Shape[]): void; flatten(shapes: Shape[]): Path[]; + createVariantFromComponents(shapes: Board[]): VariantContainer; } ``` members: @@ -5449,6 +5491,24 @@ Context: to flatten Returns Path[] + createVariantFromComponents: |- + ``` + createVariantFromComponents(shapes: Board[]): VariantContainer + ``` + + Combine several standard Components into a VariantComponent. Similar to doing it + with the contextual menu on the Penpot interface. + All the shapes passed as arguments should be main instances. + + Parameters + + * shapes: Board[] + + A list of main instances of the components to combine. + + Returns VariantContainer + + The variant container created ContextGeometryUtils: overview: |- Interface ContextGeometryUtils @@ -5850,7 +5910,7 @@ Ellipse: rotation: number; strokes: Stroke[]; layoutChild?: LayoutChildProperties; - layoutCell?: LayoutChildProperties; + layoutCell?: LayoutCellProperties; setParentIndex(index: number): void; tokens: { width: string; @@ -5859,10 +5919,10 @@ Ellipse: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -5878,14 +5938,14 @@ Ellipse: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -5902,7 +5962,7 @@ Ellipse: detach(): void; swapComponent(component: LibraryComponent): void; switchVariant(pos: number, value: string): void; - combineAsVariants(ids: string[]): void; + combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; resize(width: number, height: number): void; rotate(angle: number, center?: { x: number; y: number } | null): void; @@ -6179,7 +6239,7 @@ Ellipse: Layout properties for children of the shape. layoutCell: |- ``` - readonly layoutCell?: LayoutChildProperties + readonly layoutCell?: LayoutCellProperties ``` Layout properties for cells in a grid layout. @@ -6192,10 +6252,10 @@ Ellipse: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -6211,14 +6271,14 @@ Ellipse: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -6500,7 +6560,7 @@ Ellipse: Returns void combineAsVariants: |- ``` - combineAsVariants(ids: string[]): void + combineAsVariants(ids: string[]): VariantContainer ``` Combine several standard Components into a VariantComponent. Similar to doing it with the contextual menu @@ -6513,7 +6573,9 @@ Ellipse: A list of ids of the main instances of the components to combine with this one. - Returns void + Returns VariantContainer + + The variant container created isVariantHead: |- ``` isVariantHead(): boolean @@ -7244,8 +7306,8 @@ FlexLayout: leftPadding: number; horizontalSizing: "fill" | "auto" - | "fit-content"; - verticalSizing: "fill" | "auto" | "fit-content"; + | "fix"; + verticalSizing: "fill" | "auto" | "fix"; remove(): void; dir: "row" | "row-reverse" | "column" | "column-reverse"; wrap?: "wrap" | "nowrap"; @@ -7379,26 +7441,26 @@ FlexLayout: The `leftPadding` property specifies the padding at the left of the container. horizontalSizing: |- ``` - horizontalSizing: "fill" | "auto" | "fit-content" + horizontalSizing: "fill" | "auto" | "fix" ``` The `horizontalSizing` property specifies the horizontal sizing behavior of the container. It can be one of the following values: - * 'fit-content': The container fits the content. - * 'fill': The container fills the available space. - * 'auto': The container size is determined automatically. + * 'fix': The containers has its own intrinsic fixed size. + * 'fill': The container fills the available space. Only can be set if it's inside another layout. + * 'auto': The container fits the content. verticalSizing: |- ``` - verticalSizing: "fill" | "auto" | "fit-content" + verticalSizing: "fill" | "auto" | "fix" ``` The `verticalSizing` property specifies the vertical sizing behavior of the container. It can be one of the following values: - * 'fit-content': The container fits the content. - * 'fill': The container fills the available space. - * 'auto': The container size is determined automatically. + * 'fix': The containers has its own intrinsic fixed size. + * 'fill': The container fills the available space. Only can be set if it's inside another layout. + * 'auto': The container fits the content. dir: |- ``` dir: "row" | "row-reverse" | "column" | "column-reverse" @@ -7802,8 +7864,8 @@ GridLayout: leftPadding: number; horizontalSizing: "fill" | "auto" - | "fit-content"; - verticalSizing: "fill" | "auto" | "fit-content"; + | "fix"; + verticalSizing: "fill" | "auto" | "fix"; remove(): void; dir: "row" | "column"; rows: Track[]; @@ -7946,26 +8008,26 @@ GridLayout: The `leftPadding` property specifies the padding at the left of the container. horizontalSizing: |- ``` - horizontalSizing: "fill" | "auto" | "fit-content" + horizontalSizing: "fill" | "auto" | "fix" ``` The `horizontalSizing` property specifies the horizontal sizing behavior of the container. It can be one of the following values: - * 'fit-content': The container fits the content. - * 'fill': The container fills the available space. - * 'auto': The container size is determined automatically. + * 'fix': The containers has its own intrinsic fixed size. + * 'fill': The container fills the available space. Only can be set if it's inside another layout. + * 'auto': The container fits the content. verticalSizing: |- ``` - verticalSizing: "fill" | "auto" | "fit-content" + verticalSizing: "fill" | "auto" | "fix" ``` The `verticalSizing` property specifies the vertical sizing behavior of the container. It can be one of the following values: - * 'fit-content': The container fits the content. - * 'fill': The container fills the available space. - * 'auto': The container size is determined automatically. + * 'fix': The containers has its own intrinsic fixed size. + * 'fill': The container fills the available space. Only can be set if it's inside another layout. + * 'auto': The container fits the content. dir: |- ``` dir: "row" | "column" @@ -8279,7 +8341,7 @@ Group: | "mixed"; strokes: Stroke[]; layoutChild?: LayoutChildProperties; - layoutCell?: LayoutChildProperties; + layoutCell?: LayoutCellProperties; setParentIndex(index: number): void; tokens: { width: string; @@ -8288,10 +8350,10 @@ Group: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -8307,14 +8369,14 @@ Group: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -8331,7 +8393,7 @@ Group: detach(): void; swapComponent(component: LibraryComponent): void; switchVariant(pos: number, value: string): void; - combineAsVariants(ids: string[]): void; + combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; resize(width: number, height: number): void; rotate(angle: number, center?: { x: number; y: number } | null): void; @@ -8614,7 +8676,7 @@ Group: Layout properties for children of the shape. layoutCell: |- ``` - readonly layoutCell?: LayoutChildProperties + readonly layoutCell?: LayoutCellProperties ``` Layout properties for cells in a grid layout. @@ -8627,10 +8689,10 @@ Group: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -8646,14 +8708,14 @@ Group: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -9001,7 +9063,7 @@ Group: Returns void combineAsVariants: |- ``` - combineAsVariants(ids: string[]): void + combineAsVariants(ids: string[]): VariantContainer ``` Combine several standard Components into a VariantComponent. Similar to doing it with the contextual menu @@ -9014,7 +9076,9 @@ Group: A list of ids of the main instances of the components to combine with this one. - Returns void + Returns VariantContainer + + The variant container created isVariantHead: |- ``` isVariantHead(): boolean @@ -9523,7 +9587,7 @@ Image: rotation: number; strokes: Stroke[]; layoutChild?: LayoutChildProperties; - layoutCell?: LayoutChildProperties; + layoutCell?: LayoutCellProperties; setParentIndex(index: number): void; tokens: { width: string; @@ -9532,10 +9596,10 @@ Image: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -9551,14 +9615,14 @@ Image: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -9575,7 +9639,7 @@ Image: detach(): void; swapComponent(component: LibraryComponent): void; switchVariant(pos: number, value: string): void; - combineAsVariants(ids: string[]): void; + combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; resize(width: number, height: number): void; rotate(angle: number, center?: { x: number; y: number } | null): void; @@ -9852,7 +9916,7 @@ Image: Layout properties for children of the shape. layoutCell: |- ``` - readonly layoutCell?: LayoutChildProperties + readonly layoutCell?: LayoutCellProperties ``` Layout properties for cells in a grid layout. @@ -9865,10 +9929,10 @@ Image: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -9884,14 +9948,14 @@ Image: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -10173,7 +10237,7 @@ Image: Returns void combineAsVariants: |- ``` - combineAsVariants(ids: string[]): void + combineAsVariants(ids: string[]): VariantContainer ``` Combine several standard Components into a VariantComponent. Similar to doing it with the contextual menu @@ -10186,7 +10250,9 @@ Image: A list of ids of the main instances of the components to combine with this one. - Returns void + Returns VariantContainer + + The variant container created isVariantHead: |- ``` isVariantHead(): boolean @@ -10444,6 +10510,8 @@ LayoutCellProperties: position?: "area" | "auto" | "manual"; } ``` + + Referenced by: Board, Boolean, Ellipse, Group, Image, Path, Rectangle, ShapeBase, SvgRaw, Text, VariantContainer members: Properties: row: |- @@ -12986,7 +13054,7 @@ Path: rotation: number; strokes: Stroke[]; layoutChild?: LayoutChildProperties; - layoutCell?: LayoutChildProperties; + layoutCell?: LayoutCellProperties; setParentIndex(index: number): void; tokens: { width: string; @@ -12995,10 +13063,10 @@ Path: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -13014,14 +13082,14 @@ Path: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -13038,7 +13106,7 @@ Path: detach(): void; swapComponent(component: LibraryComponent): void; switchVariant(pos: number, value: string): void; - combineAsVariants(ids: string[]): void; + combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; resize(width: number, height: number): void; rotate(angle: number, center?: { x: number; y: number } | null): void; @@ -13339,7 +13407,7 @@ Path: Layout properties for children of the shape. layoutCell: |- ``` - readonly layoutCell?: LayoutChildProperties + readonly layoutCell?: LayoutCellProperties ``` Layout properties for cells in a grid layout. @@ -13352,10 +13420,10 @@ Path: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -13371,14 +13439,14 @@ Path: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -13674,7 +13742,7 @@ Path: Returns void combineAsVariants: |- ``` - combineAsVariants(ids: string[]): void + combineAsVariants(ids: string[]): VariantContainer ``` Combine several standard Components into a VariantComponent. Similar to doing it with the contextual menu @@ -13687,7 +13755,9 @@ Path: A list of ids of the main instances of the components to combine with this one. - Returns void + Returns VariantContainer + + The variant container created isVariantHead: |- ``` isVariantHead(): boolean @@ -14313,7 +14383,7 @@ Rectangle: rotation: number; strokes: Stroke[]; layoutChild?: LayoutChildProperties; - layoutCell?: LayoutChildProperties; + layoutCell?: LayoutCellProperties; setParentIndex(index: number): void; tokens: { width: string; @@ -14322,10 +14392,10 @@ Rectangle: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -14341,14 +14411,14 @@ Rectangle: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -14365,7 +14435,7 @@ Rectangle: detach(): void; swapComponent(component: LibraryComponent): void; switchVariant(pos: number, value: string): void; - combineAsVariants(ids: string[]): void; + combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; resize(width: number, height: number): void; rotate(angle: number, center?: { x: number; y: number } | null): void; @@ -14644,7 +14714,7 @@ Rectangle: Layout properties for children of the shape. layoutCell: |- ``` - readonly layoutCell?: LayoutChildProperties + readonly layoutCell?: LayoutCellProperties ``` Layout properties for cells in a grid layout. @@ -14657,10 +14727,10 @@ Rectangle: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -14676,14 +14746,14 @@ Rectangle: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -14965,7 +15035,7 @@ Rectangle: Returns void combineAsVariants: |- ``` - combineAsVariants(ids: string[]): void + combineAsVariants(ids: string[]): VariantContainer ``` Combine several standard Components into a VariantComponent. Similar to doing it with the contextual menu @@ -14978,7 +15048,9 @@ Rectangle: A list of ids of the main instances of the components to combine with this one. - Returns void + Returns VariantContainer + + The variant container created isVariantHead: |- ``` isVariantHead(): boolean @@ -15349,7 +15421,7 @@ ShapeBase: | "mixed"; strokes: Stroke[]; layoutChild?: LayoutChildProperties; - layoutCell?: LayoutChildProperties; + layoutCell?: LayoutCellProperties; setParentIndex(index: number): void; tokens: { width: string; @@ -15358,10 +15430,10 @@ ShapeBase: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -15377,14 +15449,14 @@ ShapeBase: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -15401,7 +15473,7 @@ ShapeBase: detach(): void; swapComponent(component: LibraryComponent): void; switchVariant(pos: number, value: string): void; - combineAsVariants(ids: string[]): void; + combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; resize(width: number, height: number): void; rotate(angle: number, center?: { x: number; y: number } | null): void; @@ -15679,7 +15751,7 @@ ShapeBase: Layout properties for children of the shape. layoutCell: |- ``` - readonly layoutCell?: LayoutChildProperties + readonly layoutCell?: LayoutCellProperties ``` Layout properties for cells in a grid layout. @@ -15692,10 +15764,10 @@ ShapeBase: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -15711,14 +15783,14 @@ ShapeBase: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -16000,7 +16072,7 @@ ShapeBase: Returns void combineAsVariants: |- ``` - combineAsVariants(ids: string[]): void + combineAsVariants(ids: string[]): VariantContainer ``` Combine several standard Components into a VariantComponent. Similar to doing it with the contextual menu @@ -16013,7 +16085,9 @@ ShapeBase: A list of ids of the main instances of the components to combine with this one. - Returns void + Returns VariantContainer + + The variant container created isVariantHead: |- ``` isVariantHead(): boolean @@ -16273,7 +16347,7 @@ Stroke: strokeColorRefFile?: string; strokeColorRefId?: string; strokeOpacity?: number; - strokeStyle?: "svg" | "none" | "mixed" | "solid" | "dotted" | "dashed"; + strokeStyle?: "none" | "svg" | "mixed" | "solid" | "dotted" | "dashed"; strokeWidth?: number; strokeAlignment?: "center" | "inner" | "outer"; strokeCapStart?: StrokeCap; @@ -16312,7 +16386,7 @@ Stroke: Defaults to 1 if omitted. strokeStyle: |- ``` - strokeStyle?: "svg" | "none" | "mixed" | "solid" | "dotted" | "dashed" + strokeStyle?: "none" | "svg" | "mixed" | "solid" | "dotted" | "dashed" ``` The optional style of the stroke. @@ -16415,7 +16489,7 @@ SvgRaw: | "mixed"; strokes: Stroke[]; layoutChild?: LayoutChildProperties; - layoutCell?: LayoutChildProperties; + layoutCell?: LayoutCellProperties; setParentIndex(index: number): void; tokens: { width: string; @@ -16424,10 +16498,10 @@ SvgRaw: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -16443,14 +16517,14 @@ SvgRaw: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -16467,7 +16541,7 @@ SvgRaw: detach(): void; swapComponent(component: LibraryComponent): void; switchVariant(pos: number, value: string): void; - combineAsVariants(ids: string[]): void; + combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; resize(width: number, height: number): void; rotate(angle: number, center?: { x: number; y: number } | null): void; @@ -16739,7 +16813,7 @@ SvgRaw: Layout properties for children of the shape. layoutCell: |- ``` - readonly layoutCell?: LayoutChildProperties + readonly layoutCell?: LayoutCellProperties ``` Layout properties for cells in a grid layout. @@ -16752,10 +16826,10 @@ SvgRaw: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -16771,14 +16845,14 @@ SvgRaw: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -17064,7 +17138,7 @@ SvgRaw: Returns void combineAsVariants: |- ``` - combineAsVariants(ids: string[]): void + combineAsVariants(ids: string[]): VariantContainer ``` Combine several standard Components into a VariantComponent. Similar to doing it with the contextual menu @@ -17077,7 +17151,9 @@ SvgRaw: A list of ids of the main instances of the components to combine with this one. - Returns void + Returns VariantContainer + + The variant container created isVariantHead: |- ``` isVariantHead(): boolean @@ -17334,7 +17410,7 @@ Text: | "mixed"; strokes: Stroke[]; layoutChild?: LayoutChildProperties; - layoutCell?: LayoutChildProperties; + layoutCell?: LayoutCellProperties; setParentIndex(index: number): void; tokens: { width: string; @@ -17343,10 +17419,10 @@ Text: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -17362,14 +17438,14 @@ Text: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -17386,7 +17462,7 @@ Text: detach(): void; swapComponent(component: LibraryComponent): void; switchVariant(pos: number, value: string): void; - combineAsVariants(ids: string[]): void; + combineAsVariants(ids: string[]): VariantContainer; isVariantHead(): boolean; resize(width: number, height: number): void; rotate(angle: number, center?: { x: number; y: number } | null): void; @@ -17421,6 +17497,7 @@ Text: direction: "mixed" | "ltr" | "rtl" | null; align: "center" | "left" | "right" | "mixed" | "justify" | null; verticalAlign: "center" | "top" | "bottom" | null; + textBounds: { x: number; y: number; width: number; height: number }; getRange(start: number, end: number): TextRange; applyTypography(typography: LibraryTypography): void; } @@ -17675,7 +17752,7 @@ Text: Layout properties for children of the shape. layoutCell: |- ``` - readonly layoutCell?: LayoutChildProperties + readonly layoutCell?: LayoutCellProperties ``` Layout properties for cells in a grid layout. @@ -17688,10 +17765,10 @@ Text: x: string; y: string; all: string; - r1: string; - r2: string; - r3: string; - r4: string; + borderRadiusTopLeft: string; + borderRadiusTopRight: string; + borderRadiusBottomRight: string; + borderRadiusBottomLeft: string; shadow: string; strokeColor: string; strokeWidth: string; @@ -17707,14 +17784,14 @@ Text: layoutItemMaxH: string; rowGap: string; columnGap: string; - p1: string; - p2: string; - p3: string; - p4: string; - m1: string; - m2: string; - m3: string; - m4: string; + paddingLeft: string; + paddingTop: string; + paddingRight: string; + paddingBottom: string; + marginLeft: string; + marginTop: string; + marginRight: string; + marginBottom: string; textCase: string; textDecoration: string; typography: string; @@ -17835,6 +17912,13 @@ Text: ``` The vertical alignment of the text shape. It can be a specific alignment or 'mixed' if multiple alignments are used. + textBounds: |- + ``` + readonly textBounds: { x: number; y: number; width: number; height: number } + ``` + + Return the bounding box for the text as a (x, y, width, height) rectangle + This is the box that covers the text even if it overflows its selection rectangle. Methods: getPluginData: |- ``` @@ -18097,7 +18181,7 @@ Text: Returns void combineAsVariants: |- ``` - combineAsVariants(ids: string[]): void + combineAsVariants(ids: string[]): VariantContainer ``` Combine several standard Components into a VariantComponent. Similar to doing it with the contextual menu @@ -18110,7 +18194,9 @@ Text: A list of ids of the main instances of the components to combine with this one. - Returns void + Returns VariantContainer + + The variant container created isVariantHead: |- ``` isVariantHead(): boolean @@ -22608,7 +22694,11 @@ TokenBorderRadiusProps: ================================= ``` - TokenBorderRadiusProps: "r1" | "r2" | "r3" | "r4" + TokenBorderRadiusProps: + | "borderRadiusTopLeft" + | "borderRadiusTopRight" + | "borderRadiusBottomRight" + | "borderRadiusBottomLeft" ``` The properties that a BorderRadius token can be applied to. @@ -22760,14 +22850,14 @@ TokenSpacingProps: TokenSpacingProps: | "rowGap" | "columnGap" - | "p1" - | "p2" - | "p3" - | "p4" - | "m1" - | "m2" - | "m3" - | "m4" + | "paddingLeft" + | "paddingTop" + | "paddingRight" + | "paddingBottom" + | "marginLeft" + | "marginTop" + | "marginRight" + | "marginBottom" ``` The properties that a Spacing token can be applied to. diff --git a/mcp/packages/server/data/base_instructions.md b/mcp/packages/server/data/base_instructions.md new file mode 100644 index 00000000000..10245a2b45f --- /dev/null +++ b/mcp/packages/server/data/base_instructions.md @@ -0,0 +1,2 @@ +You have access to Penpot tools in order to interact with Penpot designs. +Before working with these tools, be sure to read the 'Penpot High-Level Overview' via the `high_level_overview` tool. diff --git a/mcp/packages/server/data/initial_instructions.md b/mcp/packages/server/data/initial_instructions.md index 8aa60b1a1bb..33ba407d23e 100644 --- a/mcp/packages/server/data/initial_instructions.md +++ b/mcp/packages/server/data/initial_instructions.md @@ -1,10 +1,6 @@ You have access to Penpot tools in order to interact with a Penpot design project directly. As a precondition, the user must connect the Penpot design project to the MCP server using the Penpot MCP Plugin. -IMPORTANT: When transferring styles from a Penpot design to code, make sure that you strictly adhere to the design. - NEVER make assumptions about missing values and don't get overly creative (e.g. don't pick your own colours and stick to - non-creative defaults such as white/black if you are lacking information). - # Executing Code One of your key tools is the `execute_code` tool, which allows you to run JavaScript code using the Penpot Plugin API @@ -43,18 +39,30 @@ Actual low-level shape types are `Rectangle`, `Path`, `Text`, `Ellipse`, `Image` * `parentX` and `parentY` (as well as `boardX` and `boardY`) are READ-ONLY computed properties showing position relative to parent/board. To position relative to parent, use `penpotUtils.setParentXY(shape, parentX, parentY)` or manually set `shape.x = parent.x + parentX`. * `width` and `height` are READ-ONLY. Use `resize(width, height)` method to change dimensions. - * `bounds` is a READ-ONLY property. Use `x`, `y` with `resize()` to modify shape bounds. + * `bounds` is READ-ONLY (members: x, y, width, height). To modify the bounding box, change `x`, `y` or apply `resize()`. **Other Writable Properties**: * `name` - Shape name - * `fills`, `strokes` - Styling properties - * `rotation`, `opacity`, `blocked`, `hidden`, `visible` + * `fills: Fill[]`, `strokes: Stroke[]`, `shadows: Shadow[]` - Styling properties + - Setting fills: `shape.fills = [{ fillColor: "#FF0000", fillOpacity: 1 }]`; no fill (transparent): `shape.fills = []`; + - Reusing objects in another shape: `targetShape.fills = sourceShape.fills` or more granular `targetShape.fills = [{ fillOpacity: 1, fillImage: sourceShape.fills[0].fillImage }]` + The objects are not shared references; you can modify properties of the fills in the target shape without affecting the source shape. + - Colors: Use hex strings with caps only (e.g. '#FF5533') + - IMPORTANT: The contents of the arrays are read-only. You cannot modify individual fills/strokes; you need to replace the entire array to change them! + * `borderRadius` - Uniform border radius for all corners + * `borderRadiusTopLeft`, `borderRadiusTopRight`, `borderRadiusBottomRight`, `borderRadiusBottomLeft` - Individual corner radii. + * `blur: Blur` - Blur properties + * `blendMode` - Blend mode (e.g. `"normal"`, `"multiply"`, `"overlay"`, etc.) + * `rotation` (deg), `opacity`, `blocked`, `hidden`, `visible` + * `proportionLock` - Whether width and height are locked to the same ratio + * `constraintsHorizontal` - Horizontal resize constraint (`"left"`, `"right"`, `"center"`, `"leftright"`, `"scale"`) + * `constraintsVertical` - Vertical resize constraint (`"top"`, `"bottom"`, `"center"`, `"topbottom"`, `"scale"`) + * `flipX`, `flipY` - Horizontal/vertical flip **Z-Order**: * The z-order of shapes is determined by the order in the `children` array of the parent shape. Therefore, when creating shapes that should be on top of each other, add them to the parent in the correct order (i.e. add background shapes first, then foreground shapes later). - CRITICAL: NEVER use the broken function `appendChild` to achieve this, ALWAYS use `parent.insertChild(parent.children.length, shape)` * To modify z-order after creation, use these methods: `bringToFront()`, `sendToBack()`, `bringForward()`, `sendBackward()`, and, for precise control, `setParentIndex(index)` (0-based). @@ -67,15 +75,15 @@ Actual low-level shape types are `Rectangle`, `Path`, `Text`, `Ellipse`, `Image` **Hierarchical Structure**: * `parent` - The parent shape (null for root shapes) Note: Hierarchical nesting does not necessarily imply visual containment - * CRITICAL: To add children to a parent shape (e.g. a `Board`): - - ALWAYS use `parent.insertChild(index, shape)` to add a child, e.g. `parent.insertChild(parent.children.length, shape)` to append - - NEVER use `parent.appendChild(shape)` as it is BROKEN and will not insert in a predictable place (except in flex layout boards) + * To add children to a parent shape (e.g. a `Board`): `parent.appendChild(shape)` or `parent.insertChild(index, shape)` * Reparenting: `newParent.appendChild(shape)` or `newParent.insertChild(index, shape)` will move a shape to new parent - Automatically removes the shape from its old parent - Absolute x/y positions are preserved (use `penpotUtils.setParentXY` to adjust relative position) Cloning: Use `shape.clone(): Shape` to create an exact duplicate (including all properties and children) of a shape; same position as original. +Annotations: Don't add text elements to the design that just repeat a shape's name. In the Penpot UI, the name is displayed anyway. + # Images The `Image` type is a legacy type. Images are now typically embedded in a `Fill`, with `fillImage` set to an @@ -87,10 +95,10 @@ Use the `export_shape` and `import_image` tools to export and import images. Boards can have layout systems that automatically control the positioning and spacing of their children: * If a board has a layout system, then child positions are controlled by the layout system. - For every child, key properties of the child within the layout are stored in `child.layoutChild: LayoutChildProperties`: + After adding a shape to the layout as a child, key properties of the child within the layout are controlled in `child.layoutChild: LayoutChildProperties`: - `absolute: boolean` - if true, child position is not controlled by layout system. x/y will set *relative* position within parent! - margins (`topMargin`, `rightMargin`, `bottomMargin`, `leftMargin` or combined `verticalMargin`, `horizontalMargin`) - - sizing (`verticalSizing`, `horizontalSizing`: "fill" | "auto" | "fix") + - sizing (`verticalSizing`, `horizontalSizing`: "fix" | "auto" | "fill") - controls child resizing depending on the layout's sizing mode (see below) - min/max sizes (`minWidth`, `maxWidth`, `minHeight`, `maxHeight`) - `zIndex: number` (higher numbers on top) @@ -99,18 +107,11 @@ Boards can have layout systems that automatically control the positioning and sp - `dir`: "row" | "column" | "row-reverse" | "column-reverse" - Padding: `topPadding`, `rightPadding`, `bottomPadding`, `leftPadding`, or combined `verticalPadding`, `horizontalPadding` - To modify spacing: adjust `rowGap` and `columnGap` properties, not individual child positions. - Optionally, adjust indivudual child margins via `child.layoutChild`. - - When a board has flex layout, - - child positions are controlled by the layout system, not by individual x/y coordinates (unless `child.layoutChild.absolute` is true); - appending or inserting children automatically positions them according to the layout rules. - - CRITICAL: For for dir="column" or dir="row", the order of the `children` array is reversed relative to the visual order! - Therefore, the element that appears first in the array, appears visually at the end (bottom/right) and vice versa. - ALWAYS BEAR IN MIND THAT THE CHILDREN ARRAY ORDER IS REVERSED FOR dir="column" OR dir="row"! - - CRITICAL: The FlexLayout method `board.flex.appendChild` is BROKEN. To append children to a flex layout board such that - they appear visually at the end, ALWAYS use the Board's method `board.appendChild(shape)`; it will insert at the front - of the `children` array for dir="column" or dir="row", which is what you want. So call it in the order of visual appearance. - To insert at a specific index, use `board.insertChild(index, shape)`, bearing in mind the reversed order for dir="column" - or dir="row". + Optionally, adjust individual child margins via `child.layoutChild`. + - When a board has flex layout, child positions are controlled by the layout system, not by individual x/y coordinates (unless `child.layoutChild.absolute` is true); + appending or inserting children automatically positions them according to the layout rules. + - To append children to a flex layout board such that they appear visually at the end, use the Board's method `board.appendChild(shape)`, i.e. call it in the order of visual appearance. + To insert at a specific index, use `board.insertChild(index, shape)`. - Add to a board with `board.addFlexLayout(): FlexLayout`; instance then accessible via `board.flex`. IMPORTANT: When adding a flex layout to a container that already has children, use `penpotUtils.addFlexLayout(container, dir)` instead! This preserves the existing visual order of children. @@ -122,9 +123,14 @@ Boards can have layout systems that automatically control the positioning and sp Check with: `if (board.grid) { ... }` - Properties: `rows`, `columns`, `rowGap`, `columnGap` - Children are positioned via 1-based row/column indices - - Add to grid via `board.flex.appendChild(shape, row, column)` + - Add to grid via `board.grid.appendChild(shape, row, column)` - Modify grid positioning after the fact via `shape.layoutCell: LayoutCellProperties` + * Auto-sizing: both types of layouts have properties `verticalSizing`, `horizontalSizing`: "fix" | "auto" | "fill" + - `fix` (default): no resizing (size determined by shape's own width/height) + - `auto`: size determined by content (container will resize depending on children's dimensions); ALWAYS set this if you want the container size to adapt to contents/margins/spacings! + - `fill`: resize children to fill the container's size (child resizing is controlled by each child's `layoutChild` properties) + * When working with boards: - ALWAYS check if the board has a layout system before attempting to reposition children - Modify layout properties (gaps, padding) instead of trying to set child x/y positions directly @@ -132,13 +138,23 @@ Boards can have layout systems that automatically control the positioning and sp # Text Elements -The rendered content of `Text` element is given by the `characters` property. - -To change the size of the text, change the `fontSize` property; applying `resize()` does NOT change the font size, -it only changes the formal bounding box; if the text does not fit it, it will overflow. -The bounding box is sized automatically as long as the `growType` property is set to "auto-width" or "auto-height". -`resize` always sets `growType` to "fixed", so ALWAYS set it back to "auto-*" if you want automatic sizing - otherwise the bounding box will be meaningless, with the text overflowing! -The auto-sizing is not immediate; sleep for a short time (100ms) if you want to read the updated bounding box. +`Text` elements: + * The text to be rendered is given by the `characters` property. + * To change the size of the text, change the `fontSize` property; applying `resize()` does NOT change the font size, + it only changes the formal bounding box; if the text does not fit it, it will overflow; use `textBounds` for the actual bounding box of the rendered text. + * Property `bounds` is sized automatically (in one dimension) if the `growType` property is set to "auto-width" or "auto-height". + `resize` always sets `growType` to "fixed", so ALWAYS set it back to "auto-width" or "auto-height" if you want automatic sizing! + The auto-sizing is not immediate; sleep for a short time (100ms) if you want to read the updated bounding box. + * Method `getRange(start, end): TextRange` to reference a range of characters as a `TextRange` object, which can be styled separately from the rest of the text; `start` index inclusive, `end` exclusive + * Other Writable font properties: `fontId`, `fontFamily`, `fontWeight`, `fontVariant`, `fontStyle` + - To discover valid values, check available fonts in `penpot.fonts: FontContext` + - `FontContext` provides `Font` instances; each font has property `variants: FontVariant[]` + - Example: Determine available weights for a font using `penpot.fonts.findByName("Laila").variants.map(v => v.fontWeight)` + - To apply a `Font` to a `Text` instance and set all font properties at once: + - `font.applyToText(text: Text, variant?: FontVariant)` + - `applyToRange(range: TextRange, variant?: FontVariant)` + * Further writable properties: `align`, `verticalAlign`, `lineHeight`, `letterSpacing`, `textTransform`, `textDecoration` (see API info) + * Method `applyTypography(typography: LibraryTypography)` # The `penpot` and `penpotUtils` Objects, Exploring Designs @@ -210,19 +226,6 @@ Common tasks - Quick Reference (ALWAYS use penpotUtils for these): }); Always validate against the root container that is supposed to contain the shapes. -# Visual Inspection of Designs - -For many tasks, it can be critical to visually inspect the design. Remember to use the `export_shape` tool for this purpose! - -# Revising Designs - -* Before applying design changes, ask: "Would a designer consider this appropriate?" -* When dealing with containment issues, ask: Is the parent too small OR is the child too large? - Container sizes are usually intentional, check content first. -* Check for reasonable font sizes and typefaces -* The use of flex layouts is encouraged for cases where elements are arranged in rows or columns with consistent spacing/positioning. - Consider converting boards to flex layout when appropriate. - # Asset Libraries Libraries in Penpot are collections of reusable design assets (components, colors, and typographies) that can be shared across files. @@ -242,31 +245,75 @@ Each `Library` object has: * `colors: LibraryColor[]` - Array of colors * `typographies: LibraryTypography[]` - Array of typographies +## Colors and Typographies + +Adding a color: +``` +const newColor: LibraryColor = penpot.library.local.createColor(); +newColor.name = 'Brand Primary'; +newColor.color = '#0066FF'; +``` + +Adding a typography: +``` +const newTypo: LibraryTypography = penpot.library.local.createTypography(); +newTypo.name = 'Heading Large'; +// Set typography properties... +``` + +## Components + Using library components: * find a component in the library by name: - const component: LibraryComponent = library.components.find(comp => comp.name.includes('Button')); + `const component: LibraryComponent = library.components.find(comp => comp.name.includes('Button'));` * create a new instance of the component on the current page: - const instance: Shape = component.instance(); + `const instance: Shape = component.instance();` This returns a `Shape` (often a `Board` containing child elements). After instantiation, modify the instance's properties as desired. * get the reference to the main component shape: - const mainShape: Shape = component.mainInstance(); - -Adding assets to a library: - * const newColor: LibraryColor = penpot.library.local.createColor(); - newColor.name = 'Brand Primary'; - newColor.color = '#0066FF'; - * const newTypo: LibraryTypography = penpot.library.local.createTypography(); - newTypo.name = 'Heading Large'; - // Set typography properties... - * const shapes: Shape[] = [shape1, shape2]; // shapes to include - const newComponent: LibraryComponent = penpot.library.local.createComponent(shapes); - newComponent.name = 'My Button'; + `const mainShape: Shape = component.mainInstance();` + +Adding a component to a library: +``` +const shapes: Shape[] = [shape1, shape2]; // shapes to include +const newComponent: LibraryComponent = penpot.library.local.createComponent(shapes); +newComponent.name = 'My Button'; +``` Detaching: * When creating new design elements based on a component instance/copy, use `shape.detach()` to break the link to the main component, allowing independent modification. * Without detaching, some manipulations will have no effect; e.g. child/descendant removal will not work. +### Variants + +Variants are a system for grouping related component versions along named property axes (e.g. Type, Style), powering a structured swap UI for designers using component instances. + +* `VariantContainer` (extends `Board`): The board that physically groups all variant components together. + - check with `isVariantContainer()` + - property `variants: Variants`. +* `Variants`: Defines the combinations of property values for which component variants can exist and manages the concrete component variants. + - `properties: string[]` (ordered list of property names); `addProperty(): void`, `renameProperty(pos, name)`, `currentValues(property)` + - `variantComponents(): LibraryVariantComponent[]` +* `LibraryVariantComponent` (extends `LibraryComponent`): full library component with metadata, for which `isVariant()` returns true. + - `variantProps: { [property: string]: string }` (this component's value for each property) + - `variantError` (non-null if e.g. two variants share the same combination of property values) + - `setVariantProperty(pos, value)` + +Properties are often addressed positionally: `pos` parameter in various methods = index in `Variants.properties`. + +**Creating a variant group**: +- `penpot.createVariantFromComponents(mainInstances: Board[]): VariantContainer`: Combines several main component instances into a new variant group. + All components end up inside a single new container on the canvas. + The container's `Variants` instance is initialised with one property `Property 1`, with the property values set to the respective component's name. +- After creation, edit properties using `variants.renameProperty(pos, name)`, `variants.addProperty()`, and `comp.setVariantProperty(pos, value)`. + +**Adding a variant to an existing group**: +Use `variantContainer.appendChild(mainInstance)` to move a component's main instance into the container, then set its position manually and assign property values via `setVariantProperty`. + +**Using Variants**: +- `compInstance.switchVariant(pos, value)`: On a component instance, switches to the nearest variant that has the given value at property position `pos`, keeping all other property values the same. +- To instantiate a specific variant, find the right `LibraryVariantComponent` by checking `variantProps`, then call `.instance()`. + # Design Tokens Design tokens are reusable design values (colors, dimensions, typography, etc.) for consistent styling. @@ -274,21 +321,22 @@ Design tokens are reusable design values (colors, dimensions, typography, etc.) The token library: `penpot.library.local.tokens` (type: `TokenCatalog`) * `sets: TokenSet[]` - Token collections (order matters for precedence) * `themes: TokenTheme[]` - Presets that activate specific sets - * `addSet(name: string): TokenSet` - Create new set + * `addSet({name: string}): TokenSet` - Create new set * `addTheme(group: string, name: string): TokenTheme` - Create new theme `TokenSet` contains tokens with unique names: * `active: boolean` - Only active sets affect shapes; use `set.toggleActive()` to change: `if (!set.active) set.toggleActive();` * `tokens: Token[]` - All tokens in set - * `addToken(type: TokenType, name: string, value: TokenValueString): Token` - Creates a token, adding it to the set. + * `addToken({type: TokenType, name: string, value: TokenValueString}): Token` - Creates a token, adding it to the set. - `TokenType`: "color" | "dimension" | "spacing" | "typography" | "shadow" | "opacity" | "borderRadius" | "borderWidth" | "fontWeights" | "fontSizes" | "fontFamilies" | "letterSpacing" | "textDecoration" | "textCase" + - `value`: depends on the type of token (inspect `Token` and related types) - Examples: - const token = set.addToken("color", "color.primary", "#0066FF"); // direct value - const token2 = set.addToken("color", "color.accent", "{color.primary}"); // reference to another token + const token = set.addToken({type: "color", name: "color.primary", value: "#0066FF"}); // direct value + const token2 = set.addToken({type: "color", name: "color.accent", value: "{color.primary}"}); // reference to another token -`Token`: - * `name: string` - Token name (may include group path like "color.base.white") - * `value: string | TokenValueString` - Raw value (may be direct value or reference to another token like "{color.primary}") +`Token`: union type encompassing various token types, with common properties: + * `name: string` - Token name (typically structured, e.g. "color.base.white") + * `value` - Raw value (direct value or reference to another token like "{color.primary}") * `resolvedValue` - Computed final value (follows references) * `type: TokenType` @@ -303,21 +351,21 @@ Applying tokens: (if properties is undefined, use a default property based on the token type - not usually recommended). `TokenProperty` is a union type; possible values are: - "all": applies the token to all properties it can control - - TokenBorderRadiusProps: "r1", "r2", "r3", "r4" + - TokenBorderRadiusProps: "borderRadiusTopLeft", "borderRadiusTopRight", "borderRadiusBottomRight", "borderRadiusBottomLeft" - TokenShadowProps: "shadow" - - TokenColorProps: "fill", "stroke-color" - - TokenDimensionProps: "x", "y", "stroke-width" - - TokenFontFamiliesProps: "font-families" - - TokenFontSizesProps: "font-size" - - TokenFontWeightProps: "font-weight" - - TokenLetterSpacingProps: "letter-spacing" - - TokenNumberProps: "rotation", "line-height" + - TokenColorProps: "fill", "strokeColor" + - TokenDimensionProps: "x", "y", "strokeWidth" + - TokenFontFamiliesProps: "fontFamilies" + - TokenFontSizesProps: "fontSize" + - TokenFontWeightProps: "fontWeight" + - TokenLetterSpacingProps: "letterSpacing" + - TokenNumberProps: "rotation" - TokenOpacityProps: "opacity" - - TokenSizingProps: "width", "height", "layout-item-min-w", "layout-item-max-w", "layout-item-min-h", "layout-item-max-h" - - TokenSpacingProps: "row-gap", "column-gap", "p1", "p2", "p3", "p4", "m1", "m2", "m3", "m4" - - TokenBorderWidthProps: "stroke-width" - - TokenTextCaseProps: "text-case" - - TokenTextDecorationProps: "text-decoration" + - TokenSizingProps: "width", "height", "layoutItemMinW", "layoutItemMaxW", "layoutItemMinH", "layoutItemMaxH" + - TokenSpacingProps: "rowGap", "columnGap", "paddingLeft", "paddingTop", "paddingRight", "paddingBottom", "marginLeft", "marginTop", "marginRight", "marginBottom" + - TokenBorderWidthProps: "strokeWidth" + - TokenTextCaseProps: "textCase" + - TokenTextDecorationProps: "textDecoration" - TokenTypographyProps: "typography" * `token.applyToShapes(shapes, properties)` - Apply from token * Application is **asynchronous** (wait for ~100ms to see the effects) @@ -326,8 +374,27 @@ Applying tokens: - The actual shape properties that the tokens control will reflect the token's resolved value. Removing tokens: - Simply set the respective property directly - token binding is automatically removed, e.g. + Simply set the respective property directly - token binding is automatically removed, e.g. shape.fills = [{ fillColor: "#000000", fillOpacity: 1 }]; // Removes fill token +# Visual Inspection of Designs + +For many tasks, it can be critical to visually inspect the design. Remember to use the `export_shape` tool for this purpose! + +# Creating and Translating Designs + +* When transferring styles from a Penpot design to code, make sure that you strictly adhere to the design. + NEVER make assumptions about missing values and don't get overly creative (e.g. don't pick your own colours and stick to + non-creative defaults such as white/black if you are lacking information). + +# Revising Designs + +* Before applying design changes, ask: "Would a designer consider this appropriate?" +* When dealing with containment issues, ask: Is the parent too small OR is the child too large? + Container sizes are usually intentional, check content first. +* Check for reasonable font sizes and typefaces +* The use of flex layouts is encouraged for cases where elements are arranged in rows or columns with consistent spacing/positioning. + Consider converting boards to flex layout when appropriate. + -- You have hereby read the 'Penpot High-Level Overview' and need not use a tool to read it again. diff --git a/mcp/packages/server/package.json b/mcp/packages/server/package.json index 09e47a4dcec..922be869754 100644 --- a/mcp/packages/server/package.json +++ b/mcp/packages/server/package.json @@ -6,8 +6,7 @@ "main": "dist/index.js", "scripts": { "build:server": "esbuild src/index.ts --bundle --platform=node --target=node18 --format=esm --outfile=dist/index.js --external:@modelcontextprotocol/* --external:ws --external:express --external:class-transformer --external:class-validator --external:reflect-metadata --external:pino --external:pino-pretty --external:js-yaml --external:sharp", - "build": "pnpm run build:server && cp -r src/static dist/static && cp -r data dist/data", - "build:multi-user": "pnpm run build", + "build": "pnpm run build:server && node scripts/copy-resources.js", "build:types": "tsc --emitDeclarationOnly --outDir dist", "start": "node dist/index.js", "start:multi-user": "node dist/index.js --multi-user", @@ -23,7 +22,7 @@ ], "author": "", "license": "MIT", - "packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264", + "packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268", "dependencies": { "@modelcontextprotocol/sdk": "^1.24.0", "class-transformer": "^0.5.1", @@ -39,6 +38,7 @@ "zod": "^4.3.6" }, "devDependencies": { + "cross-env": "^7.0.3", "@penpot/mcp-common": "workspace:../common", "@types/express": "^4.17.0", "@types/js-yaml": "^4.0.9", diff --git a/mcp/packages/server/scripts/copy-resources.js b/mcp/packages/server/scripts/copy-resources.js new file mode 100644 index 00000000000..08b3604f37e --- /dev/null +++ b/mcp/packages/server/scripts/copy-resources.js @@ -0,0 +1,5 @@ +import { cpSync } from "fs"; + +// copy static assets and data to dist +cpSync("src/static", "dist/static", { recursive: true }); +cpSync("data", "dist/data", { recursive: true }); diff --git a/mcp/packages/server/src/ConfigurationLoader.ts b/mcp/packages/server/src/ConfigurationLoader.ts index 390522ff242..2b4b11288ec 100644 --- a/mcp/packages/server/src/ConfigurationLoader.ts +++ b/mcp/packages/server/src/ConfigurationLoader.ts @@ -4,15 +4,12 @@ import { createLogger } from "./logger.js"; /** * Configuration loader for prompts and server settings. - * - * Handles loading and parsing of YAML configuration files, - * providing type-safe access to configuration values with - * appropriate fallbacks for missing files or values. */ export class ConfigurationLoader { private readonly logger = createLogger("ConfigurationLoader"); private readonly baseDir: string; - private initialInstructions: string; + private readonly initialInstructions: string; + private readonly baseInstructions: string; /** * Creates a new configuration loader instance. @@ -22,6 +19,7 @@ export class ConfigurationLoader { constructor(baseDir: string) { this.baseDir = baseDir; this.initialInstructions = this.loadFileContent(join(this.baseDir, "data", "initial_instructions.md")); + this.baseInstructions = this.loadFileContent(join(this.baseDir, "data", "base_instructions.md")); } private loadFileContent(filePath: string): string { @@ -32,11 +30,22 @@ export class ConfigurationLoader { } /** - * Gets the initial instructions for the MCP server. + * Gets the initial instructions for the MCP server corresponding to the + * 'Penpot High-Level Overview' * - * @returns The initial instructions string, or undefined if not configured + * @returns The initial instructions string */ public getInitialInstructions(): string { return this.initialInstructions; } + + /** + * Gets the base instructions which shall be provided to clients when connecting to + * the MCP server + * + * @returns The initial instructions string + */ + public getBaseInstructions(): string { + return this.baseInstructions; + } } diff --git a/mcp/packages/server/src/PenpotMcpServer.ts b/mcp/packages/server/src/PenpotMcpServer.ts index 3aa741dd83a..ae95724a09f 100644 --- a/mcp/packages/server/src/PenpotMcpServer.ts +++ b/mcp/packages/server/src/PenpotMcpServer.ts @@ -21,34 +21,61 @@ export interface SessionContext { userToken?: string; } +/** + * Represents an active Streamable HTTP session, grouping the transport, MCP server, and session metadata. + */ +class StreamableSession { + constructor( + public readonly transport: StreamableHTTPServerTransport, + public readonly userToken: string | undefined, + public lastActiveTime: number + ) {} +} + +/** + * Holds information about a registered tool, including its instance, name, and configuration. + */ +class ToolInfo { + constructor( + public readonly instance: Tool, + public readonly name: string, + public readonly config: { description: string; inputSchema: any } + ) {} +} + export class PenpotMcpServer { + /** + * Timeout, in minutes, for idle Streamable HTTP sessions before they are automatically closed and removed. + */ + private static readonly SESSION_TIMEOUT_MINUTES = 60; + private readonly logger = createLogger("PenpotMcpServer"); - private readonly server: McpServer; - private readonly tools: Map>; + private readonly tools: ToolInfo[]; public readonly configLoader: ConfigurationLoader; private app: any; public readonly pluginBridge: PluginBridge; private readonly replServer: ReplServer; private apiDocs: ApiDocs; + private readonly penpotHighLevelOverview: string; + private readonly connectionInstructions: string; /** * Manages session-specific context, particularly user tokens for each request. */ private readonly sessionContext = new AsyncLocalStorage(); - private readonly transports = { - streamable: {} as Record, - sse: {} as Record, - }; + private readonly streamableTransports: Record = {}; + private readonly sseTransports: Record = {}; public readonly host: string; public readonly port: number; public readonly webSocketPort: number; public readonly replPort: number; + private sessionTimeoutInterval: ReturnType | undefined; constructor(private isMultiUser: boolean = false) { // read port configuration from environment variables - this.host = process.env.PENPOT_MCP_SERVER_HOST ?? "0.0.0.0"; + this.host = process.env.PENPOT_MCP_SERVER_HOST ?? "localhost"; this.port = parseInt(process.env.PENPOT_MCP_SERVER_PORT ?? "4401", 10); this.webSocketPort = parseInt(process.env.PENPOT_MCP_WEBSOCKET_PORT ?? "4402", 10); this.replPort = parseInt(process.env.PENPOT_MCP_REPL_PORT ?? "4403", 10); @@ -56,21 +83,16 @@ export class PenpotMcpServer { this.configLoader = new ConfigurationLoader(process.cwd()); this.apiDocs = new ApiDocs(); - this.server = new McpServer( - { - name: "penpot-mcp-server", - version: "1.0.0", - }, - { - instructions: this.getInitialInstructions(), - } - ); + // prepare instructions + let instructions = this.configLoader.getInitialInstructions(); + instructions = instructions.replace("$api_types", this.apiDocs.getTypeNames().join(", ")); + this.penpotHighLevelOverview = instructions; + this.connectionInstructions = this.configLoader.getBaseInstructions(); + + this.tools = this.initTools(); - this.tools = new Map>(); this.pluginBridge = new PluginBridge(this, this.webSocketPort); this.replServer = new ReplServer(this.pluginBridge, this.replPort); - - this.registerTools(); } /** @@ -104,10 +126,11 @@ export class PenpotMcpServer { return !this.isRemoteMode(); } - public getInitialInstructions(): string { - let instructions = this.configLoader.getInitialInstructions(); - instructions = instructions.replace("$api_types", this.apiDocs.getTypeNames().join(", ")); - return instructions; + /** + * Retrieves the high-level overview instructions explaining core Penpot usage. + */ + public getHighLevelOverviewInstructions(): string { + return this.penpotHighLevelOverview; } /** @@ -119,88 +142,134 @@ export class PenpotMcpServer { return this.sessionContext.getStore(); } - private registerTools(): void { - // Create relevant tool instances (depending on file system access) + private initTools(): ToolInfo[] { const toolInstances: Tool[] = [ new ExecuteCodeTool(this), new HighLevelOverviewTool(this), new PenpotApiInfoTool(this, this.apiDocs), - new ExportShapeTool(this), // tool adapts to file system access internally + new ExportShapeTool(this), ]; if (this.isFileSystemAccessEnabled()) { toolInstances.push(new ImportImageTool(this)); } - for (const tool of toolInstances) { - const toolName = tool.getToolName(); - this.tools.set(toolName, tool); - - // Register each tool with McpServer - this.logger.info(`Registering tool: ${toolName}`); - this.server.registerTool( - toolName, - { - description: tool.getToolDescription(), - inputSchema: tool.getInputSchema(), - }, - async (args) => { - return tool.execute(args); + return toolInstances.map((instance) => { + this.logger.info(`Registering tool: ${instance.getToolName()}`); + return new ToolInfo(instance, instance.getToolName(), { + description: instance.getToolDescription(), + inputSchema: instance.getInputSchema(), + }); + }); + } + + /** + * Creates a fresh {@link McpServer} instance with all tools registered. + */ + private createMcpServer(): McpServer { + const server = new McpServer( + { name: "penpot", version: "1.0.0" }, + { instructions: this.connectionInstructions } + ); + + for (const tool of this.tools) { + server.registerTool(tool.name, tool.config, async (args: any) => tool.instance.execute(args)); + } + + return server; + } + + /** + * Starts a periodic timer that closes and removes Streamable HTTP sessions that have been + * idle for longer than {@link SESSION_TIMEOUT_MINUTES}. + */ + private startSessionTimeoutChecker(): void { + const timeoutMs = PenpotMcpServer.SESSION_TIMEOUT_MINUTES * 60 * 1000; + const checkIntervalMs = timeoutMs / 2; + this.sessionTimeoutInterval = setInterval(() => { + this.logger.info("Checking for stale sessions..."); + const now = Date.now(); + let removed = 0; + for (const session of Object.values(this.streamableTransports)) { + if (now - session.lastActiveTime > timeoutMs) { + session.transport.close(); + removed++; } + } + this.logger.info( + `Removed ${removed} stale session(s); total sessions remaining: ${Object.keys(this.streamableTransports).length}` ); - } + }, checkIntervalMs); } private setupHttpEndpoints(): void { /** - * Modern Streamable HTTP connection endpoint + * Modern Streamable HTTP connection endpoint. + * + * New sessions are created on initialize requests (no mcp-session-id header). + * Subsequent requests for an existing session are routed to the stored transport, + * with the session context populated from the stored userToken. */ this.app.all("/mcp", async (req: any, res: any) => { - const userToken = req.query.userToken as string | undefined; - - await this.sessionContext.run({ userToken }, async () => { + const sessionId = req.headers["mcp-session-id"] as string | undefined; + let userToken: string | undefined = undefined; + let transport: StreamableHTTPServerTransport; + + // obtain transport and user token for the session, either from an existing session or by creating a new one + if (sessionId && this.streamableTransports[sessionId]) { + // existing session: reuse stored transport and token + const session = this.streamableTransports[sessionId]; + transport = session.transport; + userToken = session.userToken; + session.lastActiveTime = Date.now(); + this.logger.info( + `Received request for existing session with id=${sessionId}; userToken=${session.userToken}` + ); + } else { + // new session: create a fresh McpServer and transport + userToken = req.query.userToken as string | undefined; + this.logger.info(`Received new session request; userToken=${userToken}`); const { randomUUID } = await import("node:crypto"); + const server = this.createMcpServer(); + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (id) => { + this.streamableTransports[id] = new StreamableSession(transport, userToken, Date.now()); + this.logger.info( + `Session initialized with id=${id} for userToken=${userToken}; total sessions: ${Object.keys(this.streamableTransports).length}` + ); + }, + }); + transport.onclose = () => { + if (transport.sessionId) { + this.logger.info(`Closing session with id=${transport.sessionId} for userToken=${userToken}`); + delete this.streamableTransports[transport.sessionId]; + } + }; + await server.connect(transport); + } - const sessionId = req.headers["mcp-session-id"] as string | undefined; - let transport: StreamableHTTPServerTransport; - - if (sessionId && this.transports.streamable[sessionId]) { - transport = this.transports.streamable[sessionId]; - } else { - transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: (id: string) => { - this.transports.streamable[id] = transport; - }, - }); - - transport.onclose = () => { - if (transport.sessionId) { - delete this.transports.streamable[transport.sessionId]; - } - }; - - await this.server.connect(transport); - } - + // handle the request + await this.sessionContext.run({ userToken }, async () => { await transport.handleRequest(req, res, req.body); }); }); /** - * Legacy SSE connection endpoint + * Legacy SSE connection endpoint. */ this.app.get("/sse", async (req: any, res: any) => { const userToken = req.query.userToken as string | undefined; await this.sessionContext.run({ userToken }, async () => { const transport = new SSEServerTransport("/messages", res); - this.transports.sse[transport.sessionId] = { transport, userToken }; + this.sseTransports[transport.sessionId] = { transport, userToken }; + const server = this.createMcpServer(); + await server.connect(transport); res.on("close", () => { - delete this.transports.sse[transport.sessionId]; + delete this.sseTransports[transport.sessionId]; + server.close(); }); - - await this.server.connect(transport); }); }); @@ -209,7 +278,7 @@ export class PenpotMcpServer { */ this.app.post("/messages", async (req: any, res: any) => { const sessionId = req.query.sessionId as string; - const session = this.transports.sse[sessionId]; + const session = this.sseTransports[sessionId]; if (session) { await this.sessionContext.run({ userToken: session.userToken }, async () => { @@ -236,8 +305,9 @@ export class PenpotMcpServer { this.logger.info(`Legacy SSE endpoint: http://${this.host}:${this.port}/sse`); this.logger.info(`WebSocket server URL: ws://${this.host}:${this.webSocketPort}`); - // start the REPL server + // start the REPL server and session timeout checker await this.replServer.start(); + this.startSessionTimeoutChecker(); resolve(); }); @@ -251,6 +321,7 @@ export class PenpotMcpServer { */ public async stop(): Promise { this.logger.info("Stopping Penpot MCP Server..."); + clearInterval(this.sessionTimeoutInterval); await this.replServer.stop(); this.logger.info("Penpot MCP Server stopped"); } diff --git a/mcp/packages/server/src/PluginBridge.ts b/mcp/packages/server/src/PluginBridge.ts index 413ec2fa70f..5147d361fdb 100644 --- a/mcp/packages/server/src/PluginBridge.ts +++ b/mcp/packages/server/src/PluginBridge.ts @@ -5,6 +5,8 @@ import { PluginTaskResponse, PluginTaskResult } from "@penpot/mcp-common"; import { createLogger } from "./logger"; import type { PenpotMcpServer } from "./PenpotMcpServer"; +const KEEP_ALIVE_TIME = 30000; // 30 seconds + interface ClientConnection { socket: WebSocket; userToken: string | null; @@ -38,6 +40,8 @@ export class PluginBridge { * channel between the MCP mcpServer and Penpot plugin instances. */ private setupWebSocketHandlers(): void { + let interval: NodeJS.Timeout | undefined; + this.wsServer.on("connection", (ws: WebSocket, request: http.IncomingMessage) => { // extract userToken from query parameters const url = new URL(request.url!, `ws://${request.headers.host}`); @@ -64,6 +68,7 @@ export class PluginBridge { if (this.clientsByToken.has(userToken)) { this.logger.warn("Duplicate connection for given user token; rejecting new connection"); ws.close(1008, "Duplicate connection for given user token; close previous connection first."); + return; } this.clientsByToken.set(userToken, connection); @@ -86,6 +91,9 @@ export class PluginBridge { if (connection?.userToken) { this.clientsByToken.delete(connection.userToken); } + if (interval) { + clearInterval(interval); + } }); ws.on("error", (error) => { @@ -95,7 +103,14 @@ export class PluginBridge { if (connection?.userToken) { this.clientsByToken.delete(connection.userToken); } + if (interval) { + clearInterval(interval); + } }); + + interval = setInterval(() => { + ws?.ping(); + }, KEEP_ALIVE_TIME); }); this.logger.info("WebSocket mcpServer started on port %d", this.port); diff --git a/mcp/packages/server/src/Tool.ts b/mcp/packages/server/src/Tool.ts index 65cfe539bdf..df4e1f22665 100644 --- a/mcp/packages/server/src/Tool.ts +++ b/mcp/packages/server/src/Tool.ts @@ -22,6 +22,9 @@ export class EmptyToolArgs { export abstract class Tool { private readonly logger = createLogger("Tool"); + /** monotonically increasing counter for unique tool execution IDs */ + private static executionCounter = 0; + protected constructor( protected mcpServer: PenpotMcpServer, private inputSchema: z.ZodRawShape @@ -34,17 +37,21 @@ export abstract class Tool { * delegating to the type-safe implementation. */ async execute(args: unknown): Promise { + const executionId = ++Tool.executionCounter; try { let argsInstance: TArgs = args as TArgs; - this.logger.info("Executing tool: %s; arguments: %s", this.getToolName(), this.formatArgs(argsInstance)); + this.logger.info("Tool execution #%d starting: %s", executionId, this.getToolName()); + if (this.logger.isLevelEnabled("debug")) { + this.logger.debug("Tool execution #%d arguments: %s", executionId, this.formatArgs(argsInstance)); + } // execute the actual tool logic let result = await this.executeCore(argsInstance); - this.logger.info("Tool execution completed: %s", this.getToolName()); + this.logger.info("Tool execution #%d complete: %s", executionId, this.getToolName()); return result; } catch (error) { - this.logger.error(error); + this.logger.error("Tool execution #%d failed: %s; error: %s", executionId, this.getToolName(), error); return new TextResponse(`Tool execution failed: ${String(error)}`); } } diff --git a/mcp/packages/server/src/tools/ExecuteCodeTool.ts b/mcp/packages/server/src/tools/ExecuteCodeTool.ts index adcb6339fa0..6a514e0cf47 100644 --- a/mcp/packages/server/src/tools/ExecuteCodeTool.ts +++ b/mcp/packages/server/src/tools/ExecuteCodeTool.ts @@ -53,9 +53,10 @@ export class ExecuteCodeTool extends Tool { "could come in handy later should be stored in `storage` instead of just a fleeting variable; " + "you can also store functions and thus build up a library).\n" + "Think of the code being executed as the body of a function: " + - "The tool call returns whatever you return in the applicable `return` statement, if any.\n" + + "The tool call returns whatever you return in the applicable `return` statement, if any. " + + "You can return arbitrary JS objects; no need to apply JSON.stringify.\n" + "If an exception occurs, the exception's message will be returned to you.\n" + - "Any output that you generate via the `console` object will be returned to you separately; so you may use it" + + "Any output that you generate via the `console` object will be returned to you separately; so you may use it " + "to track what your code is doing, but you should *only* do so only if there is an ACTUAL NEED for this! " + "VERY IMPORTANT: Don't use logging prematurely! NEVER log the data you are returning, as you will otherwise receive it twice!\n" + "VERY IMPORTANT: In general, try a simple approach first, and only if it fails, try more complex code that involves " + diff --git a/mcp/packages/server/src/tools/ExportShapeTool.ts b/mcp/packages/server/src/tools/ExportShapeTool.ts index d032b0b7705..7b8ceed4a6f 100644 --- a/mcp/packages/server/src/tools/ExportShapeTool.ts +++ b/mcp/packages/server/src/tools/ExportShapeTool.ts @@ -16,8 +16,8 @@ export class ExportShapeArgs { .string() .min(1, "shapeId cannot be empty") .describe( - "Identifier of the shape to export. Use the special identifier 'selection' to " + - "export the first shape currently selected by the user." + "Identifier of the shape to export. " + + "Special identifiers you can use: 'selection' (first shape currently selected by the user), 'page' (entire current page)" ), format: z.enum(["svg", "png"]).default("png").describe("The output format, either 'png' (default) or 'svg'."), mode: z @@ -71,7 +71,7 @@ export class ExportShapeTool extends Tool { public getToolDescription(): string { let description = "Exports a shape (or a shape's image fill) from the Penpot design to a PNG or SVG image, " + - "such that you can get an impression of what it looks like. "; + "such that you can get an impression of what it looks like."; if (this.mcpServer.isFileSystemAccessEnabled()) { description += "\nAlternatively, you can save it to a file."; } @@ -88,6 +88,8 @@ export class ExportShapeTool extends Tool { let shapeCode: string; if (args.shapeId === "selection") { shapeCode = `penpot.selection[0]`; + } else if (args.shapeId === "page") { + shapeCode = `penpot.root`; } else { shapeCode = `penpotUtils.findShapeById("${args.shapeId}")`; } diff --git a/mcp/packages/server/src/tools/HighLevelOverviewTool.ts b/mcp/packages/server/src/tools/HighLevelOverviewTool.ts index ada88297710..b16edcb68b8 100644 --- a/mcp/packages/server/src/tools/HighLevelOverviewTool.ts +++ b/mcp/packages/server/src/tools/HighLevelOverviewTool.ts @@ -21,6 +21,6 @@ export class HighLevelOverviewTool extends Tool { } protected async executeCore(args: EmptyToolArgs): Promise { - return new TextResponse(this.mcpServer.getInitialInstructions()); + return new TextResponse(this.mcpServer.getHighLevelOverviewInstructions()); } } diff --git a/mcp/pnpm-lock.yaml b/mcp/pnpm-lock.yaml index 15088c97913..c90b714cc5f 100644 --- a/mcp/pnpm-lock.yaml +++ b/mcp/pnpm-lock.yaml @@ -97,6 +97,9 @@ importers: '@types/ws': specifier: ^8.5.10 version: 8.18.1 + cross-env: + specifier: ^7.0.3 + version: 7.0.3 esbuild: specifier: ^0.25.0 version: 0.25.12 diff --git a/mcp/scripts/build b/mcp/scripts/build index 41af7a46849..09523164fe4 100755 --- a/mcp/scripts/build +++ b/mcp/scripts/build @@ -25,12 +25,12 @@ set -e popd pnpm -r --filter "!mcp-plugin" install; -pnpm -r --filter "mcp-server" run build:multi-user; +pnpm -r --filter "mcp-server" run build; rsync -avr packages/server/dist/ ./dist/; cp packages/server/package.json ./dist/; -cp packages/server/pnpm-lock.yaml ./dist/; +cp pnpm-lock.yaml ./dist/; touch ./dist/pnpm-workspace.yaml; diff --git a/mcp/scripts/pack b/mcp/scripts/pack new file mode 100644 index 00000000000..16a869cf00f --- /dev/null +++ b/mcp/scripts/pack @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +set -euo pipefail + +cd "$(dirname "$0")/.." + +# pnpm-lock.yaml is hard-excluded by npm, but we need it; ship it under a neutral name +cp pnpm-lock.yaml pnpm-lock.dist.yaml +trap 'rm -f pnpm-lock.dist.yaml' EXIT + +npm pack diff --git a/mcp/scripts/set-version b/mcp/scripts/set-version new file mode 100644 index 00000000000..170aa6267d8 --- /dev/null +++ b/mcp/scripts/set-version @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# +# Derives a valid npm semver version from the Git tag produced by +# `git describe` and writes it into the root package.json. +# +# Examples of the conversion: +# 2.14.0 -> 2.14.0 +# 2.14.0-RC1 -> 2.14.0-rc.1 +# 2.14.0-RC1-140-g9f2ca9965 -> 2.14.0-rc.1.140 +# 2.14.0-140-g9f2ca9965 -> 2.14.1-dev.140 +# +# The last case (commits after a release tag) bumps the patch level so +# the resulting semver sorts higher than the release. + +set -euo pipefail + +raw=$(git describe --tags --match "*.*.*") + +# Parse: ..[-