feat(mcp): implement OpenPencil file format and MCP server integration

- Introduce support for .op file format alongside .pen
- Add MCP server functionality for document management and tool operations
- Implement batch processing tools for design and variable management
- Enhance save and open dialogs to accommodate new file format
- Update dependencies and scripts for MCP server compilation and execution
This commit is contained in:
Kayshen-X 2026-02-22 11:48:52 +08:00
parent 5bc192e451
commit e0d8e4dea8
24 changed files with 2689 additions and 114 deletions

147
bun.lock
View file

@ -7,6 +7,7 @@
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.47",
"@anthropic-ai/sdk": "^0.77.0",
"@modelcontextprotocol/sdk": "^1.12.1",
"@opencode-ai/sdk": "1.2.6",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
@ -209,6 +210,8 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.10.tgz", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
"@hono/node-server": ["@hono/node-server@1.19.9", "https://registry.npmmirror.com/@hono/node-server/-/node-server-1.19.9.tgz", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "https://registry.npmmirror.com/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "https://registry.npmmirror.com/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
@ -259,6 +262,8 @@
"@malept/flatpak-bundler": ["@malept/flatpak-bundler@0.4.0", "https://registry.npmmirror.com/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.0", "lodash": "^4.17.15", "tmp-promise": "^3.0.2" } }, "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.26.0", "https://registry.npmmirror.com/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
"@npmcli/agent": ["@npmcli/agent@3.0.0", "https://registry.npmmirror.com/@npmcli/agent/-/agent-3.0.0.tgz", { "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "lru-cache": "^10.0.1", "socks-proxy-agent": "^8.0.3" } }, "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q=="],
@ -595,11 +600,15 @@
"abbrev": ["abbrev@3.0.1", "https://registry.npmmirror.com/abbrev/-/abbrev-3.0.1.tgz", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="],
"accepts": ["accepts@2.0.0", "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"acorn": ["acorn@8.15.0", "https://registry.npmmirror.com/acorn/-/acorn-8.15.0.tgz", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"agent-base": ["agent-base@7.1.4", "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"ajv": ["ajv@6.12.6", "https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
"ajv": ["ajv@8.18.0", "https://registry.npmmirror.com/ajv/-/ajv-8.18.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"ajv-formats": ["ajv-formats@3.0.1", "https://registry.npmmirror.com/ajv-formats/-/ajv-formats-3.0.1.tgz", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
"ajv-keywords": ["ajv-keywords@3.5.2", "https://registry.npmmirror.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz", { "peerDependencies": { "ajv": "^6.9.1" } }, "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="],
@ -651,6 +660,8 @@
"bl": ["bl@4.1.0", "https://registry.npmmirror.com/bl/-/bl-4.1.0.tgz", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
"body-parser": ["body-parser@2.2.2", "https://registry.npmmirror.com/body-parser/-/body-parser-2.2.2.tgz", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
"boolbase": ["boolbase@1.0.0", "https://registry.npmmirror.com/boolbase/-/boolbase-1.0.0.tgz", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
"boolean": ["boolean@3.2.0", "https://registry.npmmirror.com/boolean/-/boolean-3.2.0.tgz", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
@ -671,6 +682,8 @@
"builder-util-runtime": ["builder-util-runtime@9.5.1", "https://registry.npmmirror.com/builder-util-runtime/-/builder-util-runtime-9.5.1.tgz", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="],
"bytes": ["bytes@3.1.2", "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
"cac": ["cac@6.7.14", "https://registry.npmmirror.com/cac/-/cac-6.7.14.tgz", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
"cacache": ["cacache@19.0.1", "https://registry.npmmirror.com/cacache/-/cacache-19.0.1.tgz", { "dependencies": { "@npmcli/fs": "^4.0.0", "fs-minipass": "^3.0.0", "glob": "^10.2.2", "lru-cache": "^10.0.1", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", "ssri": "^12.0.0", "tar": "^7.4.3", "unique-filename": "^4.0.0" } }, "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ=="],
@ -681,6 +694,8 @@
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"caniuse-lite": ["caniuse-lite@1.0.30001770", "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz", {}, "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw=="],
"canvas": ["canvas@3.2.1", "https://registry.npmmirror.com/canvas/-/canvas-3.2.1.tgz", { "dependencies": { "node-addon-api": "^7.0.0", "prebuild-install": "^7.1.3" } }, "sha512-ej1sPFR5+0YWtaVp6S1N1FVz69TQCqmrkGeRvQxZeAB1nAIcjNTHVwrZtYtWFFBmQsF40/uDLehsW5KuYC99mg=="],
@ -733,12 +748,22 @@
"consola": ["consola@3.4.2", "https://registry.npmmirror.com/consola/-/consola-3.4.2.tgz", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
"content-disposition": ["content-disposition@1.0.1", "https://registry.npmmirror.com/content-disposition/-/content-disposition-1.0.1.tgz", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="],
"content-type": ["content-type@1.0.5", "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"convert-source-map": ["convert-source-map@2.0.0", "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie": ["cookie@0.7.2", "https://registry.npmmirror.com/cookie/-/cookie-0.7.2.tgz", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"cookie-es": ["cookie-es@2.0.0", "https://registry.npmmirror.com/cookie-es/-/cookie-es-2.0.0.tgz", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="],
"cookie-signature": ["cookie-signature@1.2.2", "https://registry.npmmirror.com/cookie-signature/-/cookie-signature-1.2.2.tgz", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"core-util-is": ["core-util-is@1.0.2", "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.2.tgz", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="],
"cors": ["cors@2.8.6", "https://registry.npmmirror.com/cors/-/cors-2.8.6.tgz", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
"crc": ["crc@3.8.0", "https://registry.npmmirror.com/crc/-/crc-3.8.0.tgz", { "dependencies": { "buffer": "^5.1.0" } }, "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ=="],
"cross-dirname": ["cross-dirname@0.1.0", "https://registry.npmmirror.com/cross-dirname/-/cross-dirname-0.1.0.tgz", {}, "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q=="],
@ -781,6 +806,8 @@
"delayed-stream": ["delayed-stream@1.0.0", "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"depd": ["depd@2.0.0", "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"dequal": ["dequal@2.0.3", "https://registry.npmmirror.com/dequal/-/dequal-2.0.3.tgz", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"detect-libc": ["detect-libc@2.1.2", "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
@ -815,6 +842,8 @@
"eastasianwidth": ["eastasianwidth@0.2.0", "https://registry.npmmirror.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
"ee-first": ["ee-first@1.1.1", "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"ejs": ["ejs@3.1.10", "https://registry.npmmirror.com/ejs/-/ejs-3.1.10.tgz", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
"electron": ["electron@35.7.5", "https://registry.npmmirror.com/electron/-/electron-35.7.5.tgz", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^22.7.7", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-dnL+JvLraKZl7iusXTVTGYs10TKfzUi30uEDTqsmTm0guN9V2tbOjTzyIZbh9n3ygUjgEYyo+igAwMRXIi3IPw=="],
@ -831,6 +860,8 @@
"emoji-regex": ["emoji-regex@8.0.0", "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"encodeurl": ["encodeurl@2.0.0", "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"encoding": ["encoding@0.1.13", "https://registry.npmmirror.com/encoding/-/encoding-0.1.13.tgz", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="],
"encoding-sniffer": ["encoding-sniffer@0.2.1", "https://registry.npmmirror.com/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw=="],
@ -861,18 +892,30 @@
"escalade": ["escalade@3.2.0", "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-html": ["escape-html@1.0.3", "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"esprima": ["esprima@4.0.1", "https://registry.npmmirror.com/esprima/-/esprima-4.0.1.tgz", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
"estree-walker": ["estree-walker@3.0.3", "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"etag": ["etag@1.8.1", "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"eventsource": ["eventsource@3.0.7", "https://registry.npmmirror.com/eventsource/-/eventsource-3.0.7.tgz", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
"eventsource-parser": ["eventsource-parser@3.0.6", "https://registry.npmmirror.com/eventsource-parser/-/eventsource-parser-3.0.6.tgz", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
"expand-template": ["expand-template@2.0.3", "https://registry.npmmirror.com/expand-template/-/expand-template-2.0.3.tgz", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="],
"expect-type": ["expect-type@1.3.0", "https://registry.npmmirror.com/expect-type/-/expect-type-1.3.0.tgz", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
"exponential-backoff": ["exponential-backoff@3.1.3", "https://registry.npmmirror.com/exponential-backoff/-/exponential-backoff-3.1.3.tgz", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="],
"express": ["express@5.2.1", "https://registry.npmmirror.com/express/-/express-5.2.1.tgz", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
"express-rate-limit": ["express-rate-limit@8.2.1", "https://registry.npmmirror.com/express-rate-limit/-/express-rate-limit-8.2.1.tgz", { "dependencies": { "ip-address": "10.0.1" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g=="],
"exsolve": ["exsolve@1.0.8", "https://registry.npmmirror.com/exsolve/-/exsolve-1.0.8.tgz", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
"extract-zip": ["extract-zip@2.0.1", "https://registry.npmmirror.com/extract-zip/-/extract-zip-2.0.1.tgz", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="],
@ -885,6 +928,8 @@
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
"fast-uri": ["fast-uri@3.1.0", "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.1.0.tgz", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
"fd-slicer": ["fd-slicer@1.1.0", "https://registry.npmmirror.com/fd-slicer/-/fd-slicer-1.1.0.tgz", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="],
"fdir": ["fdir@6.5.0", "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
@ -893,10 +938,16 @@
"fill-range": ["fill-range@7.1.1", "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"finalhandler": ["finalhandler@2.1.1", "https://registry.npmmirror.com/finalhandler/-/finalhandler-2.1.1.tgz", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
"foreground-child": ["foreground-child@3.3.1", "https://registry.npmmirror.com/foreground-child/-/foreground-child-3.3.1.tgz", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
"form-data": ["form-data@4.0.5", "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
"forwarded": ["forwarded@0.2.0", "https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"fresh": ["fresh@2.0.0", "https://registry.npmmirror.com/fresh/-/fresh-2.0.0.tgz", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"fs-constants": ["fs-constants@1.0.0", "https://registry.npmmirror.com/fs-constants/-/fs-constants-1.0.0.tgz", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
"fs-extra": ["fs-extra@10.1.0", "https://registry.npmmirror.com/fs-extra/-/fs-extra-10.1.0.tgz", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
@ -957,6 +1008,8 @@
"hasown": ["hasown@2.0.2", "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"hono": ["hono@4.12.1", "https://registry.npmmirror.com/hono/-/hono-4.12.1.tgz", {}, "sha512-hi9afu8g0lfJVLolxElAZGANCTTl6bewIdsRNhaywfP9K8BPf++F2z6OLrYGIinUwpRKzbZHMhPwvc0ZEpAwGw=="],
"hookable": ["hookable@6.0.1", "https://registry.npmmirror.com/hookable/-/hookable-6.0.1.tgz", {}, "sha512-uKGyY8BuzN/a5gvzvA+3FVWo0+wUjgtfSdnmjtrOVwQCZPHpHDH2WRO3VZSOeluYrHoDCiXFffZXs8Dj1ULWtw=="],
"hosted-git-info": ["hosted-git-info@4.1.0", "https://registry.npmmirror.com/hosted-git-info/-/hosted-git-info-4.1.0.tgz", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="],
@ -967,6 +1020,8 @@
"http-cache-semantics": ["http-cache-semantics@4.2.0", "https://registry.npmmirror.com/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="],
"http-errors": ["http-errors@2.0.1", "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"http-proxy-agent": ["http-proxy-agent@7.0.2", "https://registry.npmmirror.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
"http2-wrapper": ["http2-wrapper@1.0.3", "https://registry.npmmirror.com/http2-wrapper/-/http2-wrapper-1.0.3.tgz", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="],
@ -987,7 +1042,9 @@
"ini": ["ini@1.3.8", "https://registry.npmmirror.com/ini/-/ini-1.3.8.tgz", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
"ip-address": ["ip-address@10.1.0", "https://registry.npmmirror.com/ip-address/-/ip-address-10.1.0.tgz", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
"ip-address": ["ip-address@10.0.1", "https://registry.npmmirror.com/ip-address/-/ip-address-10.0.1.tgz", {}, "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"is-binary-path": ["is-binary-path@2.1.0", "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
@ -1003,6 +1060,8 @@
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "https://registry.npmmirror.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
"is-promise": ["is-promise@4.0.0", "https://registry.npmmirror.com/is-promise/-/is-promise-4.0.0.tgz", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
"is-unicode-supported": ["is-unicode-supported@0.1.0", "https://registry.npmmirror.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="],
"isbinaryfile": ["isbinaryfile@5.0.7", "https://registry.npmmirror.com/isbinaryfile/-/isbinaryfile-5.0.7.tgz", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="],
@ -1017,6 +1076,8 @@
"jiti": ["jiti@2.6.1", "https://registry.npmmirror.com/jiti/-/jiti-2.6.1.tgz", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"jose": ["jose@6.1.3", "https://registry.npmmirror.com/jose/-/jose-6.1.3.tgz", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
"js-tokens": ["js-tokens@4.0.0", "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.1.1", "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.1.tgz", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
@ -1029,7 +1090,9 @@
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "https://registry.npmmirror.com/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-schema-typed": ["json-schema-typed@8.0.2", "https://registry.npmmirror.com/json-schema-typed/-/json-schema-typed-8.0.2.tgz", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
"json-stringify-safe": ["json-stringify-safe@5.0.1", "https://registry.npmmirror.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="],
@ -1091,11 +1154,15 @@
"mdn-data": ["mdn-data@2.12.2", "https://registry.npmmirror.com/mdn-data/-/mdn-data-2.12.2.tgz", {}, "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA=="],
"media-typer": ["media-typer@1.1.0", "https://registry.npmmirror.com/media-typer/-/media-typer-1.1.0.tgz", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"merge-descriptors": ["merge-descriptors@2.0.0", "https://registry.npmmirror.com/merge-descriptors/-/merge-descriptors-2.0.0.tgz", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
"mime": ["mime@2.6.0", "https://registry.npmmirror.com/mime/-/mime-2.6.0.tgz", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="],
"mime-db": ["mime-db@1.52.0", "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-db": ["mime-db@1.54.0", "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"mime-types": ["mime-types@2.1.35", "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"mime-types": ["mime-types@3.0.2", "https://registry.npmmirror.com/mime-types/-/mime-types-3.0.2.tgz", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"mimic-fn": ["mimic-fn@2.1.0", "https://registry.npmmirror.com/mimic-fn/-/mimic-fn-2.1.0.tgz", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
@ -1155,12 +1222,18 @@
"nwsapi": ["nwsapi@2.2.23", "https://registry.npmmirror.com/nwsapi/-/nwsapi-2.2.23.tgz", {}, "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ=="],
"object-assign": ["object-assign@4.1.1", "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-inspect": ["object-inspect@1.13.4", "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"object-keys": ["object-keys@1.1.1", "https://registry.npmmirror.com/object-keys/-/object-keys-1.1.1.tgz", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="],
"ofetch": ["ofetch@2.0.0-alpha.3", "https://registry.npmmirror.com/ofetch/-/ofetch-2.0.0-alpha.3.tgz", {}, "sha512-zpYTCs2byOuft65vI3z43Dd6iSdFbOZZLb9/d21aCpx2rGastVU9dOCv0lu4ykc1Ur1anAYjDi3SUvR0vq50JA=="],
"ohash": ["ohash@2.0.11", "https://registry.npmmirror.com/ohash/-/ohash-2.0.11.tgz", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
"on-finished": ["on-finished@2.4.1", "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
"once": ["once@1.4.0", "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"onetime": ["onetime@5.1.2", "https://registry.npmmirror.com/onetime/-/onetime-5.1.2.tgz", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
@ -1181,12 +1254,16 @@
"parse5-parser-stream": ["parse5-parser-stream@7.1.2", "https://registry.npmmirror.com/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", { "dependencies": { "parse5": "^7.0.0" } }, "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow=="],
"parseurl": ["parseurl@1.3.3", "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-is-absolute": ["path-is-absolute@1.0.1", "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
"path-key": ["path-key@3.1.1", "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-scurry": ["path-scurry@1.11.1", "https://registry.npmmirror.com/path-scurry/-/path-scurry-1.11.1.tgz", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
"path-to-regexp": ["path-to-regexp@8.3.0", "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-8.3.0.tgz", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
"pathe": ["pathe@2.0.3", "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"pathval": ["pathval@2.0.1", "https://registry.npmmirror.com/pathval/-/pathval-2.0.1.tgz", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
@ -1199,6 +1276,8 @@
"picomatch": ["picomatch@4.0.3", "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"pkce-challenge": ["pkce-challenge@5.0.1", "https://registry.npmmirror.com/pkce-challenge/-/pkce-challenge-5.0.1.tgz", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
"plist": ["plist@3.1.0", "https://registry.npmmirror.com/plist/-/plist-3.1.0.tgz", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="],
"postcss": ["postcss@8.5.6", "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
@ -1219,12 +1298,20 @@
"proper-lockfile": ["proper-lockfile@4.1.2", "https://registry.npmmirror.com/proper-lockfile/-/proper-lockfile-4.1.2.tgz", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="],
"proxy-addr": ["proxy-addr@2.0.7", "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"pump": ["pump@3.0.3", "https://registry.npmmirror.com/pump/-/pump-3.0.3.tgz", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="],
"punycode": ["punycode@2.3.1", "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"qs": ["qs@6.15.0", "https://registry.npmmirror.com/qs/-/qs-6.15.0.tgz", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
"quick-lru": ["quick-lru@5.1.1", "https://registry.npmmirror.com/quick-lru/-/quick-lru-5.1.1.tgz", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="],
"range-parser": ["range-parser@1.2.1", "https://registry.npmmirror.com/range-parser/-/range-parser-1.2.1.tgz", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
"raw-body": ["raw-body@3.0.2", "https://registry.npmmirror.com/raw-body/-/raw-body-3.0.2.tgz", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
"rc": ["rc@1.2.8", "https://registry.npmmirror.com/rc/-/rc-1.2.8.tgz", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
"react": ["react@19.2.4", "https://registry.npmmirror.com/react/-/react-19.2.4.tgz", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
@ -1275,6 +1362,8 @@
"rou3": ["rou3@0.7.12", "https://registry.npmmirror.com/rou3/-/rou3-0.7.12.tgz", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="],
"router": ["router@2.2.0", "https://registry.npmmirror.com/router/-/router-2.2.0.tgz", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
"rrweb-cssom": ["rrweb-cssom@0.8.0", "https://registry.npmmirror.com/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="],
"safe-buffer": ["safe-buffer@5.2.1", "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
@ -1293,18 +1382,32 @@
"semver-compare": ["semver-compare@1.0.0", "https://registry.npmmirror.com/semver-compare/-/semver-compare-1.0.0.tgz", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="],
"send": ["send@1.2.1", "https://registry.npmmirror.com/send/-/send-1.2.1.tgz", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
"serialize-error": ["serialize-error@7.0.1", "https://registry.npmmirror.com/serialize-error/-/serialize-error-7.0.1.tgz", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="],
"seroval": ["seroval@1.5.0", "https://registry.npmmirror.com/seroval/-/seroval-1.5.0.tgz", {}, "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw=="],
"seroval-plugins": ["seroval-plugins@1.5.0", "https://registry.npmmirror.com/seroval-plugins/-/seroval-plugins-1.5.0.tgz", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA=="],
"serve-static": ["serve-static@2.2.1", "https://registry.npmmirror.com/serve-static/-/serve-static-2.2.1.tgz", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
"setprototypeof": ["setprototypeof@1.2.0", "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"shebang-command": ["shebang-command@2.0.0", "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"shell-quote": ["shell-quote@1.8.3", "https://registry.npmmirror.com/shell-quote/-/shell-quote-1.8.3.tgz", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
"side-channel": ["side-channel@1.1.0", "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
"side-channel-list": ["side-channel-list@1.0.0", "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
"side-channel-map": ["side-channel-map@1.0.1", "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"siginfo": ["siginfo@2.0.0", "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
"signal-exit": ["signal-exit@3.0.7", "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
@ -1341,6 +1444,8 @@
"stat-mode": ["stat-mode@1.0.0", "https://registry.npmmirror.com/stat-mode/-/stat-mode-1.0.0.tgz", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="],
"statuses": ["statuses@2.0.2", "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"std-env": ["std-env@3.10.0", "https://registry.npmmirror.com/std-env/-/std-env-3.10.0.tgz", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
"string-width": ["string-width@4.2.3", "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
@ -1407,6 +1512,8 @@
"to-regex-range": ["to-regex-range@5.0.1", "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"toidentifier": ["toidentifier@1.0.1", "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"tough-cookie": ["tough-cookie@6.0.0", "https://registry.npmmirror.com/tough-cookie/-/tough-cookie-6.0.0.tgz", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w=="],
"tr46": ["tr46@6.0.0", "https://registry.npmmirror.com/tr46/-/tr46-6.0.0.tgz", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="],
@ -1425,6 +1532,8 @@
"type-fest": ["type-fest@0.13.1", "https://registry.npmmirror.com/type-fest/-/type-fest-0.13.1.tgz", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
"type-is": ["type-is@2.0.1", "https://registry.npmmirror.com/type-is/-/type-is-2.0.1.tgz", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
"typescript": ["typescript@5.9.3", "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"ufo": ["ufo@1.6.3", "https://registry.npmmirror.com/ufo/-/ufo-1.6.3.tgz", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="],
@ -1441,6 +1550,8 @@
"universalify": ["universalify@2.0.1", "https://registry.npmmirror.com/universalify/-/universalify-2.0.1.tgz", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"unpipe": ["unpipe@1.0.0", "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"unplugin": ["unplugin@2.3.11", "https://registry.npmmirror.com/unplugin/-/unplugin-2.3.11.tgz", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],
"unstorage": ["unstorage@2.0.0-alpha.5", "https://registry.npmmirror.com/unstorage/-/unstorage-2.0.0-alpha.5.tgz", { "peerDependencies": { "@azure/app-configuration": "^1.9.0", "@azure/cosmos": "^4.7.0", "@azure/data-tables": "^13.3.1", "@azure/identity": "^4.13.0", "@azure/keyvault-secrets": "^4.10.0", "@azure/storage-blob": "^12.29.1", "@capacitor/preferences": "^6.0.3 || ^7.0.0", "@deno/kv": ">=0.12.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.35.6", "@vercel/blob": ">=0.27.3", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1.0.1", "aws4fetch": "^1.0.20", "chokidar": "^4 || ^5", "db0": ">=0.3.4", "idb-keyval": "^6.2.2", "ioredis": "^5.8.2", "lru-cache": "^11.2.2", "mongodb": "^6 || ^7", "ofetch": "*", "uploadthing": "^7.7.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "chokidar", "db0", "idb-keyval", "ioredis", "lru-cache", "mongodb", "ofetch", "uploadthing"] }, "sha512-Sj8btci21Twnd6M+N+MHhjg3fVn6lAPElPmvFTe0Y/wR0WImErUdA1PzlAaUavHylJ7uDiFwlZDQKm0elG4b7g=="],
@ -1459,6 +1570,8 @@
"util-deprecate": ["util-deprecate@1.0.2", "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"vary": ["vary@1.1.2", "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"verror": ["verror@1.10.1", "https://registry.npmmirror.com/verror/-/verror-1.10.1.tgz", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="],
"vite": ["vite@7.3.1", "https://registry.npmmirror.com/vite/-/vite-7.3.1.tgz", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
@ -1519,10 +1632,14 @@
"zod": ["zod@3.25.76", "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"zod-to-json-schema": ["zod-to-json-schema@3.25.1", "https://registry.npmmirror.com/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
"zustand": ["zustand@5.0.11", "https://registry.npmmirror.com/zustand/-/zustand-5.0.11.tgz", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg=="],
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"@develar/schema-utils/ajv": ["ajv@6.12.6", "https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
"@electron/asar/minimatch": ["minimatch@3.1.2", "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"@electron/fuses/chalk": ["chalk@4.1.2", "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
@ -1575,6 +1692,8 @@
"@tanstack/start-plugin-core/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.40", "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.40.tgz", {}, "sha512-s3GeJKSQOwBlzdUrj4ISjJj5SfSh+aqn0wjOar4Bx95iV1ETI7F6S/5hLcfAxZ9kXDcyrAkxPlqmd1ZITttf+w=="],
"ajv-keywords/ajv": ["ajv@6.12.6", "https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
"anymatch/picomatch": ["picomatch@2.3.1", "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"app-builder-lib/@electron/get": ["@electron/get@3.1.0", "https://registry.npmmirror.com/@electron/get/-/get-3.1.0.tgz", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="],
@ -1583,6 +1702,8 @@
"app-builder-lib/semver": ["semver@7.7.4", "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"body-parser/iconv-lite": ["iconv-lite@0.7.2", "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.2.tgz", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"builder-util/chalk": ["chalk@4.1.2", "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"cacache/glob": ["glob@10.5.0", "https://registry.npmmirror.com/glob/-/glob-10.5.0.tgz", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
@ -1599,6 +1720,8 @@
"dir-compare/minimatch": ["minimatch@3.1.2", "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"dmg-license/ajv": ["ajv@6.12.6", "https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
"dom-serializer/entities": ["entities@4.5.0", "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"electron-builder/chalk": ["chalk@4.1.2", "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
@ -1613,6 +1736,8 @@
"foreground-child/signal-exit": ["signal-exit@4.1.0", "https://registry.npmmirror.com/signal-exit/-/signal-exit-4.1.0.tgz", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"form-data/mime-types": ["mime-types@2.1.35", "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"glob/minimatch": ["minimatch@3.1.2", "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"global-agent/semver": ["semver@7.7.4", "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
@ -1651,6 +1776,8 @@
"prebuild-install/node-abi": ["node-abi@3.87.0", "https://registry.npmmirror.com/node-abi/-/node-abi-3.87.0.tgz", { "dependencies": { "semver": "^7.3.5" } }, "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ=="],
"raw-body/iconv-lite": ["iconv-lite@0.7.2", "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.2.tgz", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"readdirp/picomatch": ["picomatch@2.3.1", "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"recast/source-map": ["source-map@0.6.1", "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
@ -1661,6 +1788,8 @@
"slice-ansi/ansi-styles": ["ansi-styles@4.3.0", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"socks/ip-address": ["ip-address@10.1.0", "https://registry.npmmirror.com/ip-address/-/ip-address-10.1.0.tgz", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
"source-map-support/source-map": ["source-map@0.6.1", "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"strip-literal/js-tokens": ["js-tokens@9.0.1", "https://registry.npmmirror.com/js-tokens/-/js-tokens-9.0.1.tgz", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
@ -1679,6 +1808,8 @@
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"@develar/schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"@electron/asar/minimatch/brace-expansion": ["brace-expansion@1.1.12", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.12.tgz", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"@electron/fuses/chalk/ansi-styles": ["ansi-styles@4.3.0", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
@ -1699,6 +1830,8 @@
"@tanstack/devtools/@tanstack/devtools-client/@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.3.5", "https://registry.npmmirror.com/@tanstack/devtools-event-client/-/devtools-event-client-0.3.5.tgz", {}, "sha512-RL1f5ZlfZMpghrCIdzl6mLOFLTuhqmPNblZgBaeKfdtk5rfbjykurv+VfYydOFXj0vxVIoA2d/zT7xfD7Ph8fw=="],
"ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "https://registry.npmmirror.com/fs-extra/-/fs-extra-8.1.0.tgz", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
"app-builder-lib/@electron/get/semver": ["semver@6.3.1", "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
@ -1711,6 +1844,8 @@
"dir-compare/minimatch/brace-expansion": ["brace-expansion@1.1.12", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.12.tgz", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"dmg-license/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"electron-builder/chalk/ansi-styles": ["ansi-styles@4.3.0", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"electron-publish/chalk/ansi-styles": ["ansi-styles@4.3.0", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
@ -1735,6 +1870,8 @@
"filelist/minimatch/brace-expansion": ["brace-expansion@2.0.2", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"form-data/mime-types/mime-db": ["mime-db@1.52.0", "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.12.tgz", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"hosted-git-info/lru-cache/yallist": ["yallist@4.0.0", "https://registry.npmmirror.com/yallist/-/yallist-4.0.0.tgz", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],

View file

@ -195,8 +195,8 @@ function setupIPC(): void {
ipcMain.handle('dialog:openFile', async () => {
if (!mainWindow) return null
const result = await dialog.showOpenDialog(mainWindow, {
title: 'Open .pen file',
filters: [{ name: 'Pen Files', extensions: ['pen'] }],
title: 'Open .op file',
filters: [{ name: 'OpenPencil Files', extensions: ['op', 'pen'] }],
properties: ['openFile'],
})
if (result.canceled || result.filePaths.length === 0) return null
@ -210,9 +210,9 @@ function setupIPC(): void {
async (_event, payload: { content: string; defaultPath?: string }) => {
if (!mainWindow) return null
const result = await dialog.showSaveDialog(mainWindow, {
title: 'Save .pen file',
title: 'Save .op file',
defaultPath: payload.defaultPath,
filters: [{ name: 'Pen Files', extensions: ['pen'] }],
filters: [{ name: 'OpenPencil Files', extensions: ['op'] }],
})
if (result.canceled || !result.filePath) return null
await writeFile(result.filePath, payload.content, 'utf-8')

View file

@ -6,16 +6,22 @@
"private": true,
"type": "module",
"main": "electron-dist/main.cjs",
"bin": {
"openpencil-mcp": "dist/mcp-server.cjs"
},
"scripts": {
"dev": "bun --bun vite dev --port 3000",
"build": "bun --bun vite build",
"preview": "bun --bun vite preview",
"test": "bun --bun vitest run --passWithNoTests",
"mcp:compile": "esbuild src/mcp/server.ts --bundle --platform=node --target=node20 --outfile=dist/mcp-server.cjs --format=cjs --sourcemap --alias:@=src",
"mcp:dev": "bun run src/mcp/server.ts",
"electron:dev": "bun run scripts/electron-dev.ts",
"electron:compile": "esbuild electron/main.ts electron/preload.ts --bundle --platform=node --target=node20 --outdir=electron-dist --external:electron --format=cjs --out-extension:.js=.cjs --sourcemap",
"electron:build": "BUILD_TARGET=electron bun --bun run build && bun run electron:compile && npx electron-builder --config electron-builder.yml"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.47",
"@anthropic-ai/sdk": "^0.77.0",
"@opencode-ai/sdk": "1.2.6",

View file

@ -0,0 +1,195 @@
import { defineEventHandler, readBody, setResponseHeaders } from 'h3'
import { homedir } from 'node:os'
import { join, resolve } from 'node:path'
import { readFile, writeFile, mkdir } from 'node:fs/promises'
import { existsSync } from 'node:fs'
interface InstallBody {
tool: string
action: 'install' | 'uninstall'
}
interface InstallResult {
success: boolean
error?: string
configPath?: string
}
const MCP_SERVER_NAME = 'openpencil'
/**
* Resolve the absolute path to the compiled MCP server.
* In dev: <project>/dist/mcp-server.cjs
* In production (Electron): <resources>/mcp-server.cjs
*/
function resolveMcpServerPath(): string {
// Try dist/ in the project root first (dev + compiled)
const projectDist = resolve(process.cwd(), 'dist', 'mcp-server.cjs')
if (existsSync(projectDist)) return projectDist
// Fallback: try relative to this file
const serverDist = resolve(__dirname, '..', '..', '..', 'dist', 'mcp-server.cjs')
if (existsSync(serverDist)) return serverDist
return projectDist // Return expected path even if not yet compiled
}
/** Config file locations and formats for each CLI tool. */
const CLI_CONFIGS: Record<
string,
{
configPath: () => string
read: (filePath: string) => Promise<Record<string, any>>
write: (filePath: string, config: Record<string, any>) => Promise<void>
install: (config: Record<string, any>, serverPath: string) => Record<string, any>
uninstall: (config: Record<string, any>) => Record<string, any>
}
> = {
'claude-code': {
configPath: () => join(homedir(), '.claude.json'),
read: readJsonConfig,
write: writeJsonConfig,
install: (config, serverPath) => ({
...config,
mcpServers: {
...(config.mcpServers ?? {}),
[MCP_SERVER_NAME]: { command: 'node', args: [serverPath] },
},
}),
uninstall: (config) => {
const servers = { ...(config.mcpServers ?? {}) }
delete servers[MCP_SERVER_NAME]
return {
...config,
mcpServers: Object.keys(servers).length > 0 ? servers : undefined,
}
},
},
'codex-cli': {
configPath: () => join(homedir(), '.codex', 'config.json'),
read: readJsonConfig,
write: writeJsonConfig,
install: (config, serverPath) => ({
...config,
mcpServers: {
...(config.mcpServers ?? {}),
[MCP_SERVER_NAME]: { command: 'node', args: [serverPath] },
},
}),
uninstall: (config) => {
const servers = { ...(config.mcpServers ?? {}) }
delete servers[MCP_SERVER_NAME]
return { ...config, mcpServers: Object.keys(servers).length > 0 ? servers : undefined }
},
},
'gemini-cli': {
configPath: () => join(homedir(), '.gemini', 'settings.json'),
read: readJsonConfig,
write: writeJsonConfig,
install: (config, serverPath) => ({
...config,
mcpServers: {
...(config.mcpServers ?? {}),
[MCP_SERVER_NAME]: {
command: 'node',
args: [serverPath],
},
},
}),
uninstall: (config) => {
const servers = { ...(config.mcpServers ?? {}) }
delete servers[MCP_SERVER_NAME]
return { ...config, mcpServers: Object.keys(servers).length > 0 ? servers : undefined }
},
},
'opencode-cli': {
configPath: () => join(homedir(), '.opencode', 'config.json'),
read: readJsonConfig,
write: writeJsonConfig,
install: (config, serverPath) => ({
...config,
mcpServers: {
...(config.mcpServers ?? {}),
[MCP_SERVER_NAME]: { command: 'node', args: [serverPath] },
},
}),
uninstall: (config) => {
const servers = { ...(config.mcpServers ?? {}) }
delete servers[MCP_SERVER_NAME]
return { ...config, mcpServers: Object.keys(servers).length > 0 ? servers : undefined }
},
},
'kiro-cli': {
configPath: () => join(homedir(), '.kiro', 'settings.json'),
read: readJsonConfig,
write: writeJsonConfig,
install: (config, serverPath) => ({
...config,
mcpServers: {
...(config.mcpServers ?? {}),
[MCP_SERVER_NAME]: { command: 'node', args: [serverPath] },
},
}),
uninstall: (config) => {
const servers = { ...(config.mcpServers ?? {}) }
delete servers[MCP_SERVER_NAME]
return { ...config, mcpServers: Object.keys(servers).length > 0 ? servers : undefined }
},
},
}
async function readJsonConfig(filePath: string): Promise<Record<string, any>> {
try {
const text = await readFile(filePath, 'utf-8')
return JSON.parse(text)
} catch {
return {}
}
}
async function writeJsonConfig(
filePath: string,
config: Record<string, any>,
): Promise<void> {
const dir = join(filePath, '..')
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true })
}
await writeFile(filePath, JSON.stringify(config, null, 2) + '\n', 'utf-8')
}
/**
* POST /api/ai/mcp-install
* Install or uninstall the openpencil MCP server into a CLI tool's config.
*/
export default defineEventHandler(async (event) => {
const body = await readBody<InstallBody>(event)
setResponseHeaders(event, { 'Content-Type': 'application/json' })
if (!body?.tool || !body?.action) {
return { success: false, error: 'Missing tool or action field' } satisfies InstallResult
}
const cliConfig = CLI_CONFIGS[body.tool]
if (!cliConfig) {
return { success: false, error: `Unknown CLI tool: ${body.tool}` } satisfies InstallResult
}
try {
const configPath = cliConfig.configPath()
const config = await cliConfig.read(configPath)
const serverPath = resolveMcpServerPath()
const updated =
body.action === 'install'
? cliConfig.install(config, serverPath)
: cliConfig.uninstall(config)
await cliConfig.write(configPath, updated)
return { success: true, configPath } satisfies InstallResult
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : String(err),
} satisfies InstallResult
}
})

View file

@ -100,7 +100,7 @@ export default function TopBar() {
downloadDocument(doc, fn)
store.markClean()
} else if (supportsFileSystemAccess()) {
saveDocumentAs(doc, 'untitled.pen').then((result) => {
saveDocumentAs(doc, 'untitled.op').then((result) => {
if (result) {
useDocumentStore.setState({
fileName: result.fileName,

View file

@ -1,6 +1,6 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import type { ComponentType, SVGProps } from 'react'
import { X, Check, Loader2, Unplug } from 'lucide-react'
import { X, Check, Loader2, Unplug, AlertCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
@ -154,12 +154,37 @@ export default function AgentSettingsDialog() {
return () => document.removeEventListener('keydown', handler)
}, [open, setDialogOpen])
const [mcpInstalling, setMcpInstalling] = useState<string | null>(null)
const [mcpError, setMcpError] = useState<string | null>(null)
const handleToggleMCP = useCallback(
(tool: string) => {
toggleMCP(tool)
persist()
async (tool: string) => {
const current = mcpIntegrations.find((m) => m.tool === tool)
if (!current) return
const action = current.enabled ? 'uninstall' : 'install'
setMcpInstalling(tool)
setMcpError(null)
try {
const res = await fetch('/api/ai/mcp-install', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tool, action }),
})
const result = await res.json()
if (result.success) {
toggleMCP(tool)
persist()
} else {
setMcpError(result.error ?? `Failed to ${action}`)
}
} catch {
setMcpError(`Failed to ${action} MCP server`)
} finally {
setMcpInstalling(null)
}
},
[toggleMCP, persist],
[mcpIntegrations, toggleMCP, persist],
)
if (!open) return null
@ -216,21 +241,33 @@ export default function AgentSettingsDialog() {
key={m.tool}
className="flex items-center justify-between py-2 px-1"
>
<span
className={cn(
'text-xs',
m.enabled ? 'text-foreground' : 'text-muted-foreground',
<div className="flex items-center gap-1.5">
<span
className={cn(
'text-xs',
m.enabled ? 'text-foreground' : 'text-muted-foreground',
)}
>
{m.displayName}
</span>
{mcpInstalling === m.tool && (
<Loader2 size={11} className="animate-spin text-muted-foreground" />
)}
>
{m.displayName}
</span>
</div>
<Switch
checked={m.enabled}
disabled={mcpInstalling !== null}
onCheckedChange={() => handleToggleMCP(m.tool)}
/>
</div>
))}
</div>
{mcpError && (
<div className="flex items-center gap-1.5 mt-1.5 px-1">
<AlertCircle size={11} className="text-destructive shrink-0" />
<p className="text-[10px] text-destructive">{mcpError}</p>
</div>
)}
<p className="text-[10px] text-muted-foreground mt-2">
MCP integrations will take effect after restarting the terminal.
</p>

View file

@ -19,7 +19,7 @@ export default function SaveDialog({ open, onClose }: SaveDialogProps) {
// Pre-fill with existing name (without extension)
const fn = useDocumentStore.getState().fileName
if (fn) {
setName(fn.replace(/\.pen$|\.json$/, ''))
setName(fn.replace(/\.op$|\.pen$|\.json$/, ''))
} else {
setName('untitled')
}
@ -43,7 +43,7 @@ export default function SaveDialog({ open, onClose }: SaveDialogProps) {
if (!trimmed) return
// Force-sync all Fabric object positions to the store before serializing
syncCanvasPositionsToStore()
const fileName = trimmed.endsWith('.pen') ? trimmed : `${trimmed}.pen`
const fileName = trimmed.endsWith('.op') ? trimmed : `${trimmed}.op`
const doc = useDocumentStore.getState().document
downloadDocument(doc, fileName)
useDocumentStore.setState({ fileName, isDirty: false })
@ -74,7 +74,7 @@ export default function SaveDialog({ open, onClose }: SaveDialogProps) {
className="flex-1 bg-secondary border border-input rounded px-2 py-1.5 text-sm text-foreground focus:outline-none focus:border-ring"
autoFocus
/>
<span className="text-xs text-muted-foreground">.pen</span>
<span className="text-xs text-muted-foreground">.op</span>
</div>
<div className="flex gap-2">

View file

@ -182,7 +182,7 @@ export function useKeyboardShortcuts() {
downloadDocument(doc, fileName)
store.markClean()
} else if (supportsFileSystemAccess()) {
saveDocumentAs(doc, 'untitled.pen').then((result) => {
saveDocumentAs(doc, 'untitled.op').then((result) => {
if (result) {
useDocumentStore.setState({
fileName: result.fileName,

View file

@ -0,0 +1,75 @@
import { readFile, writeFile, access } from 'node:fs/promises'
import { constants } from 'node:fs'
import type { PenDocument } from '../types/pen'
const cache = new Map<string, { doc: PenDocument; mtime: number }>()
/** Validate that a parsed object looks like a PenDocument. */
function validate(doc: unknown): doc is PenDocument {
if (!doc || typeof doc !== 'object') return false
const d = doc as Record<string, unknown>
return typeof d.version === 'string' && Array.isArray(d.children)
}
/** Read and parse a .op / .pen file, returning a PenDocument. Uses cache. */
export async function openDocument(filePath: string): Promise<PenDocument> {
const cached = cache.get(filePath)
if (cached) return cached.doc
await access(filePath, constants.R_OK)
const text = await readFile(filePath, 'utf-8')
const raw = JSON.parse(text)
if (!validate(raw)) {
throw new Error(`Invalid document format: ${filePath}`)
}
cache.set(filePath, { doc: raw, mtime: Date.now() })
return raw
}
/** Create a new empty document (not saved to disk yet). */
export function createEmptyDocument(): PenDocument {
return {
version: '1.0.0',
children: [],
}
}
/** Write a PenDocument to disk and update cache. */
export async function saveDocument(
filePath: string,
doc: PenDocument,
): Promise<void> {
const json = JSON.stringify(doc, null, 2)
await writeFile(filePath, json, 'utf-8')
cache.set(filePath, { doc, mtime: Date.now() })
}
/** Get document from cache (for tools that operate on the active doc). */
export function getCachedDocument(
filePath: string,
): PenDocument | undefined {
return cache.get(filePath)?.doc
}
/** Update the cached document in-memory (call saveDocument to persist). */
export function setCachedDocument(
filePath: string,
doc: PenDocument,
): void {
cache.set(filePath, { doc, mtime: Date.now() })
}
/** Check if a file exists. */
export async function fileExists(filePath: string): Promise<boolean> {
try {
await access(filePath, constants.F_OK)
return true
} catch {
return false
}
}
/** Invalidate cache for a file. */
export function invalidateCache(filePath: string): void {
cache.delete(filePath)
}

274
src/mcp/server.ts Normal file
View file

@ -0,0 +1,274 @@
#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js'
import { handleOpenDocument } from './tools/open-document'
import { handleBatchGet } from './tools/batch-get'
import { handleBatchDesign } from './tools/batch-design'
import { handleGetVariables, handleSetVariables } from './tools/variables'
import { handleSnapshotLayout } from './tools/snapshot-layout'
import { handleFindEmptySpace } from './tools/find-empty-space'
const server = new Server(
{ name: 'openpencil', version: '0.1.0' },
{ capabilities: { tools: {} } },
)
// --- Tool definitions ---
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'open_document',
description:
'Open an existing .op file or create a new empty document. Returns document metadata.',
inputSchema: {
type: 'object' as const,
properties: {
filePath: {
type: 'string',
description: 'Absolute path to the .op file to open or create',
},
},
required: ['filePath'],
},
},
{
name: 'batch_get',
description:
'Search and read nodes from an .op file. Search by patterns (type, name regex, reusable flag) or read specific node IDs. Control depth with readDepth and searchDepth.',
inputSchema: {
type: 'object' as const,
properties: {
filePath: {
type: 'string',
description: 'Absolute path to the .op file',
},
patterns: {
type: 'array',
description: 'Search patterns to match nodes',
items: {
type: 'object',
properties: {
type: { type: 'string', description: 'Node type (frame, text, rectangle, etc.)' },
name: { type: 'string', description: 'Regex pattern to match node name' },
reusable: { type: 'boolean', description: 'Match reusable components' },
},
},
},
nodeIds: {
type: 'array',
items: { type: 'string' },
description: 'Specific node IDs to read',
},
parentId: {
type: 'string',
description: 'Limit search to children of this parent node',
},
readDepth: {
type: 'number',
description: 'How deep to include children in results (default 1)',
},
searchDepth: {
type: 'number',
description: 'How deep to search for matching nodes (default unlimited)',
},
},
required: ['filePath'],
},
},
{
name: 'batch_design',
description: `Execute design operations on an .op file using a DSL. Each line is one operation:
- Insert: binding=I(parent, { type: "frame", ... })
- Copy: binding=C(sourceId, parent, { ...overrides })
- Update: U(nodeId, { fill: [...] })
- Replace: binding=R(nodeId, { type: "text", ... })
- Move: M(nodeId, newParent, index)
- Delete: D(nodeId)
Bindings can reference earlier results: U(myFrame+"/childId", { ... })`,
inputSchema: {
type: 'object' as const,
properties: {
filePath: {
type: 'string',
description: 'Absolute path to the .op file',
},
operations: {
type: 'string',
description: 'Operations DSL (one operation per line)',
},
},
required: ['filePath', 'operations'],
},
},
{
name: 'get_variables',
description: 'Get all design variables and themes defined in an .op file.',
inputSchema: {
type: 'object' as const,
properties: {
filePath: {
type: 'string',
description: 'Absolute path to the .op file',
},
},
required: ['filePath'],
},
},
{
name: 'set_variables',
description:
'Add or update design variables in an .op file. By default merges with existing variables.',
inputSchema: {
type: 'object' as const,
properties: {
filePath: {
type: 'string',
description: 'Absolute path to the .op file',
},
variables: {
type: 'object',
description: 'Variables to set (name → { type, value })',
},
replace: {
type: 'boolean',
description: 'Replace all variables instead of merging (default false)',
},
},
required: ['filePath', 'variables'],
},
},
{
name: 'snapshot_layout',
description:
'Get the hierarchical bounding box layout tree of an .op file. Useful for understanding spatial arrangement.',
inputSchema: {
type: 'object' as const,
properties: {
filePath: {
type: 'string',
description: 'Absolute path to the .op file',
},
parentId: {
type: 'string',
description: 'Only return layout under this parent node',
},
maxDepth: {
type: 'number',
description: 'Max depth to traverse (default 1)',
},
},
required: ['filePath'],
},
},
{
name: 'find_empty_space',
description:
'Find empty canvas space in a given direction for placing new content.',
inputSchema: {
type: 'object' as const,
properties: {
filePath: {
type: 'string',
description: 'Absolute path to the .op file',
},
width: {
type: 'number',
description: 'Required width of empty space',
},
height: {
type: 'number',
description: 'Required height of empty space',
},
padding: {
type: 'number',
description: 'Minimum padding from other elements (default 50)',
},
direction: {
type: 'string',
enum: ['top', 'right', 'bottom', 'left'],
description: 'Direction to search for empty space',
},
nodeId: {
type: 'string',
description: 'Search relative to this node (default: entire canvas)',
},
},
required: ['filePath', 'width', 'height', 'direction'],
},
},
],
}))
// --- Tool execution ---
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params
try {
switch (name) {
case 'open_document': {
const result = await handleOpenDocument(args as any)
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }
}
case 'batch_get': {
const result = await handleBatchGet(args as any)
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }
}
case 'batch_design': {
const result = await handleBatchDesign(args as any)
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }
}
case 'get_variables': {
const result = await handleGetVariables(args as any)
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }
}
case 'set_variables': {
const result = await handleSetVariables(args as any)
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }
}
case 'snapshot_layout': {
const result = await handleSnapshotLayout(args as any)
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }
}
case 'find_empty_space': {
const result = await handleFindEmptySpace(args as any)
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }
}
default:
return {
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
isError: true,
}
}
} catch (err) {
return {
content: [
{
type: 'text',
text: `Error: ${err instanceof Error ? err.message : String(err)}`,
},
],
isError: true,
}
}
})
// --- Start ---
async function main() {
const transport = new StdioServerTransport()
await server.connect(transport)
}
main().catch((err) => {
console.error('MCP server failed to start:', err)
process.exit(1)
})

View file

@ -0,0 +1,441 @@
import { resolve } from 'node:path'
import { openDocument, saveDocument } from '../document-manager'
import {
findNodeInTree,
insertNodeInTree,
updateNodeInTree,
removeNodeFromTree,
cloneNodeWithNewIds,
} from '../utils/node-operations'
import { generateId } from '../utils/id'
import type { PenDocument, PenNode } from '../../types/pen'
export interface BatchDesignParams {
filePath: string
operations: string
}
interface OpResult {
binding: string
nodeId: string
}
/**
* Parse and execute the batch_design operations DSL.
*
* Supported operations (one per line):
* binding=I(parent, { ...nodeData }) Insert
* binding=C(nodeId, parent, { ...data }) Copy
* U(path, { ...updates }) Update
* binding=R(path, { ...nodeData }) Replace
* M(nodeId, parent, index?) Move
* D(nodeId) Delete
*/
export async function handleBatchDesign(
params: BatchDesignParams,
): Promise<{ results: OpResult[]; nodeCount: number }> {
const filePath = resolve(params.filePath)
let doc = await openDocument(filePath)
doc = structuredClone(doc)
const bindings = new Map<string, string>()
const results: OpResult[] = []
const lines = params.operations
.split('\n')
.map((l) => l.trim())
.filter((l) => l && !l.startsWith('//'))
for (const line of lines) {
try {
executeLine(line, doc, bindings, results)
} catch (err) {
throw new Error(
`Error executing "${line}": ${err instanceof Error ? err.message : String(err)}`,
)
}
}
await saveDocument(filePath, doc)
return {
results,
nodeCount: countNodes(doc.children),
}
}
function executeLine(
line: string,
doc: PenDocument,
bindings: Map<string, string>,
results: OpResult[],
): void {
// Parse: binding=OP(args) or OP(args)
const assignMatch = line.match(/^(\w+)\s*=\s*([ICRM])\((.+)\)$/)
const callMatch = line.match(/^([UDM])\((.+)\)$/)
if (assignMatch) {
const [, binding, op, argsStr] = assignMatch
switch (op) {
case 'I': {
const { parent, data } = parseInsertArgs(argsStr, bindings)
const node = { ...data, id: generateId() } as PenNode
doc.children = insertNodeInTree(doc.children, parent, node)
bindings.set(binding, node.id)
results.push({ binding, nodeId: node.id })
break
}
case 'C': {
const { sourceId, parent, data } = parseCopyArgs(argsStr, bindings)
const source = findNodeInTree(doc.children, sourceId)
if (!source) throw new Error(`Copy source not found: ${sourceId}`)
const cloned = cloneNodeWithNewIds(source, generateId)
// Apply override properties
if (data) {
Object.assign(cloned, data)
// Don't override the cloned id
if (data.id) delete (cloned as any).id
}
// Apply descendant overrides
if (data?.descendants) {
applyDescendantOverrides(cloned, data.descendants)
}
doc.children = insertNodeInTree(doc.children, parent, cloned)
bindings.set(binding, cloned.id)
results.push({ binding, nodeId: cloned.id })
break
}
case 'R': {
const { path, data } = parseReplaceArgs(argsStr, bindings)
const resolvedPath = resolveSlashPath(path, doc, bindings)
const newNode = { ...data, id: generateId() } as PenNode
// Find and replace the node
const oldNode = findNodeByPath(resolvedPath, doc)
if (!oldNode) throw new Error(`Replace target not found: ${path}`)
// Remove old, insert new at same position
const parent = findParentByPath(resolvedPath, doc)
const parentId = parent ? parent.id : null
const siblings = parent
? ('children' in parent ? parent.children ?? [] : [])
: doc.children
const idx = siblings.findIndex((n) => n.id === oldNode.id)
doc.children = removeNodeFromTree(doc.children, oldNode.id)
doc.children = insertNodeInTree(doc.children, parentId, newNode, idx)
bindings.set(binding, newNode.id)
results.push({ binding, nodeId: newNode.id })
break
}
case 'M': {
const { nodeId, parent, index } = parseMoveArgs(argsStr, bindings)
const node = findNodeInTree(doc.children, nodeId)
if (!node) throw new Error(`Move target not found: ${nodeId}`)
doc.children = removeNodeFromTree(doc.children, nodeId)
doc.children = insertNodeInTree(doc.children, parent, node, index)
bindings.set(binding, nodeId)
results.push({ binding, nodeId })
break
}
}
} else if (callMatch) {
const [, op, argsStr] = callMatch
switch (op) {
case 'U': {
const { path, data } = parseUpdateArgs(argsStr, bindings)
const resolvedPath = resolveSlashPath(path, doc, bindings)
const targetNode = findNodeByPath(resolvedPath, doc)
if (!targetNode)
throw new Error(`Update target not found: ${path}`)
// Update the node in-place
doc.children = updateNodeInTree(doc.children, targetNode.id, data)
break
}
case 'D': {
const nodeId = resolveRef(argsStr.trim().replace(/^"|"$/g, ''), bindings)
doc.children = removeNodeFromTree(doc.children, nodeId)
break
}
case 'M': {
const { nodeId, parent, index } = parseMoveArgs(argsStr, bindings)
const node = findNodeInTree(doc.children, nodeId)
if (!node) throw new Error(`Move target not found: ${nodeId}`)
doc.children = removeNodeFromTree(doc.children, nodeId)
doc.children = insertNodeInTree(doc.children, parent, node, index)
break
}
}
} else {
throw new Error(`Cannot parse operation: ${line}`)
}
}
// --- Argument parsers ---
function parseInsertArgs(
argsStr: string,
bindings: Map<string, string>,
): { parent: string | null; data: Record<string, any> } {
const firstComma = findTopLevelComma(argsStr)
if (firstComma === -1) throw new Error('Insert requires parent and node data')
const parentRaw = argsStr.slice(0, firstComma).trim()
const dataStr = argsStr.slice(firstComma + 1).trim()
const parent = parentRaw === 'null' ? null : resolveRef(parentRaw, bindings)
const data = parseJsonArg(dataStr)
return { parent, data }
}
function parseCopyArgs(
argsStr: string,
bindings: Map<string, string>,
): { sourceId: string; parent: string | null; data: Record<string, any> } {
const first = findTopLevelComma(argsStr)
if (first === -1) throw new Error('Copy requires sourceId, parent, and data')
const sourceRaw = argsStr.slice(0, first).trim()
const rest = argsStr.slice(first + 1).trim()
const second = findTopLevelComma(rest)
let parentRaw: string
let dataStr: string
if (second === -1) {
parentRaw = rest
dataStr = '{}'
} else {
parentRaw = rest.slice(0, second).trim()
dataStr = rest.slice(second + 1).trim()
}
return {
sourceId: resolveRef(sourceRaw, bindings),
parent: parentRaw === 'null' ? null : resolveRef(parentRaw, bindings),
data: parseJsonArg(dataStr),
}
}
function parseUpdateArgs(
argsStr: string,
bindings: Map<string, string>,
): { path: string; data: Record<string, any> } {
const firstComma = findTopLevelComma(argsStr)
if (firstComma === -1)
throw new Error('Update requires path and update data')
const pathRaw = argsStr.slice(0, firstComma).trim()
const dataStr = argsStr.slice(firstComma + 1).trim()
const path = resolvePathExpr(pathRaw, bindings)
return { path, data: parseJsonArg(dataStr) }
}
function parseReplaceArgs(
argsStr: string,
bindings: Map<string, string>,
): { path: string; data: Record<string, any> } {
const firstComma = findTopLevelComma(argsStr)
if (firstComma === -1) throw new Error('Replace requires path and node data')
const pathRaw = argsStr.slice(0, firstComma).trim()
const dataStr = argsStr.slice(firstComma + 1).trim()
const path = resolvePathExpr(pathRaw, bindings)
return { path, data: parseJsonArg(dataStr) }
}
function parseMoveArgs(
argsStr: string,
bindings: Map<string, string>,
): { nodeId: string; parent: string | null; index?: number } {
const parts = splitTopLevel(argsStr)
if (parts.length < 2) throw new Error('Move requires nodeId and parent')
return {
nodeId: resolveRef(parts[0].trim(), bindings),
parent:
parts[1].trim() === 'null' || parts[1].trim() === 'undefined'
? null
: resolveRef(parts[1].trim(), bindings),
index: parts[2] ? parseInt(parts[2].trim(), 10) : undefined,
}
}
// --- Helpers ---
function resolveRef(raw: string, bindings: Map<string, string>): string {
const cleaned = raw.replace(/^"|"$/g, '')
return bindings.get(cleaned) ?? cleaned
}
/** Resolve path expressions like `binding+"/child"` or `"id"` */
function resolvePathExpr(
raw: string,
bindings: Map<string, string>,
): string {
if (raw.includes('+')) {
return raw
.split('+')
.map((p) => {
const t = p.trim()
if (t.startsWith('"') || t.startsWith("'")) {
return t.slice(1, -1)
}
return bindings.get(t) ?? t
})
.join('')
}
const cleaned = raw.replace(/^"|"$/g, '')
return bindings.get(cleaned) ?? cleaned
}
/** Resolve slash-separated path (e.g. "instanceId/childId") to find the actual node. */
function resolveSlashPath(
path: string,
_doc: PenDocument,
_bindings: Map<string, string>,
): string {
// The path may be "parentId/childId" — we return as-is for findNodeByPath
return path
}
function findNodeByPath(path: string, doc: PenDocument): PenNode | undefined {
const parts = path.split('/')
if (parts.length === 1) {
return findNodeInTree(doc.children, parts[0])
}
// For paths like "instanceId/childId", resolve through the tree
// First find the instance, then look for child
let current = findNodeInTree(doc.children, parts[0])
for (let i = 1; i < parts.length && current; i++) {
if ('children' in current && current.children) {
current = current.children.find((c) => c.id === parts[i])
} else {
// For ref nodes, the child might be in the referenced component
return undefined
}
}
return current
}
function findParentByPath(
path: string,
doc: PenDocument,
): PenNode | undefined {
const parts = path.split('/')
if (parts.length <= 1) {
// Top-level or simple ID — find parent in tree
return findParentInTree(doc.children, parts[0])
}
// Parent is the second-to-last part
const parentPath = parts.slice(0, -1).join('/')
return findNodeByPath(parentPath, doc)
}
function findParentInTree(
nodes: PenNode[],
id: string,
): PenNode | undefined {
for (const node of nodes) {
if ('children' in node && node.children) {
for (const child of node.children) {
if (child.id === id) return node
}
const found = findParentInTree(node.children, id)
if (found) return found
}
}
return undefined
}
function applyDescendantOverrides(
node: PenNode,
descendants: Record<string, any>,
): void {
if (!('children' in node) || !node.children) return
for (const child of node.children) {
const override = descendants[child.id]
if (override) {
Object.assign(child, override)
}
applyDescendantOverrides(child, descendants)
}
}
/** Parse a JSON-like argument, handling unquoted keys. */
function parseJsonArg(str: string): Record<string, any> {
let normalized = str.trim()
// Convert JavaScript-style object to JSON: unquoted keys → quoted
normalized = normalized.replace(
/(?<=\{|,)\s*(\w+)\s*:/g,
' "$1":',
)
// Handle single-quoted strings → double-quoted
normalized = normalized.replace(/'/g, '"')
try {
return JSON.parse(normalized)
} catch {
throw new Error(`Failed to parse JSON: ${str}`)
}
}
/** Find the index of the first comma not inside braces/brackets/quotes. */
function findTopLevelComma(str: string): number {
let depth = 0
let inString = false
let quote = ''
for (let i = 0; i < str.length; i++) {
const ch = str[i]
if (inString) {
if (ch === '\\') {
i++
continue
}
if (ch === quote) inString = false
continue
}
if (ch === '"' || ch === "'") {
inString = true
quote = ch
continue
}
if (ch === '{' || ch === '[' || ch === '(') depth++
if (ch === '}' || ch === ']' || ch === ')') depth--
if (ch === ',' && depth === 0) return i
}
return -1
}
function splitTopLevel(str: string): string[] {
const result: string[] = []
let start = 0
let depth = 0
let inString = false
let quote = ''
for (let i = 0; i < str.length; i++) {
const ch = str[i]
if (inString) {
if (ch === '\\') {
i++
continue
}
if (ch === quote) inString = false
continue
}
if (ch === '"' || ch === "'") {
inString = true
quote = ch
continue
}
if (ch === '{' || ch === '[' || ch === '(') depth++
if (ch === '}' || ch === ']' || ch === ')') depth--
if (ch === ',' && depth === 0) {
result.push(str.slice(start, i))
start = i + 1
}
}
result.push(str.slice(start))
return result
}
function countNodes(nodes: PenNode[]): number {
let count = 0
for (const node of nodes) {
count++
if ('children' in node && node.children) {
count += countNodes(node.children)
}
}
return count
}

View file

@ -0,0 +1,89 @@
import { resolve } from 'node:path'
import { openDocument } from '../document-manager'
import {
findNodeInTree,
searchNodes,
readNodeWithDepth,
} from '../utils/node-operations'
import type { PenNode } from '../../types/pen'
export interface SearchPattern {
type?: string
name?: string
reusable?: boolean
}
export interface BatchGetParams {
filePath: string
patterns?: SearchPattern[]
nodeIds?: string[]
parentId?: string
readDepth?: number
searchDepth?: number
}
export async function handleBatchGet(
params: BatchGetParams,
): Promise<{ nodes: Record<string, unknown>[] }> {
const filePath = resolve(params.filePath)
const doc = await openDocument(filePath)
const readDepth = params.readDepth ?? 1
const searchDepth = params.searchDepth ?? Infinity
// If no patterns or nodeIds, return top-level children
if (!params.patterns?.length && !params.nodeIds?.length) {
const rootNodes = params.parentId
? (() => {
const parent = findNodeInTree(doc.children, params.parentId)
return parent && 'children' in parent && parent.children
? parent.children
: []
})()
: doc.children
return {
nodes: rootNodes.map((n) => readNodeWithDepth(n, readDepth)),
}
}
const results: PenNode[] = []
const seen = new Set<string>()
// Search by patterns
if (params.patterns?.length) {
const searchRoot = params.parentId
? (() => {
const parent = findNodeInTree(doc.children, params.parentId)
return parent && 'children' in parent && parent.children
? parent.children
: []
})()
: doc.children
for (const pattern of params.patterns) {
const found = searchNodes(searchRoot, pattern, searchDepth)
for (const node of found) {
if (!seen.has(node.id)) {
seen.add(node.id)
results.push(node)
}
}
}
}
// Read by IDs
if (params.nodeIds?.length) {
for (const id of params.nodeIds) {
if (seen.has(id)) continue
const node = findNodeInTree(doc.children, id)
if (node) {
seen.add(id)
results.push(node)
}
}
}
return {
nodes: results.map((n) => readNodeWithDepth(n, readDepth)),
}
}

View file

@ -0,0 +1,64 @@
import { resolve } from 'node:path'
import { openDocument } from '../document-manager'
import { getNodeBounds, findNodeInTree } from '../utils/node-operations'
import type { PenNode } from '../../types/pen'
export interface FindEmptySpaceParams {
filePath: string
width: number
height: number
padding?: number
direction: 'top' | 'right' | 'bottom' | 'left'
nodeId?: string
}
export interface FindEmptySpaceResult {
x: number
y: number
}
export async function handleFindEmptySpace(
params: FindEmptySpaceParams,
): Promise<FindEmptySpaceResult> {
const filePath = resolve(params.filePath)
const doc = await openDocument(filePath)
const padding = params.padding ?? 50
// Compute bounding box of reference content
let nodes: PenNode[]
if (params.nodeId) {
const node = findNodeInTree(doc.children, params.nodeId)
if (!node) throw new Error(`Node not found: ${params.nodeId}`)
nodes = [node]
} else {
nodes = doc.children
}
if (nodes.length === 0) {
return { x: 0, y: 0 }
}
// Compute combined bounding box
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const node of nodes) {
const b = getNodeBounds(node, doc.children)
minX = Math.min(minX, b.x)
minY = Math.min(minY, b.y)
maxX = Math.max(maxX, b.x + b.w)
maxY = Math.max(maxY, b.y + b.h)
}
switch (params.direction) {
case 'right':
return { x: maxX + padding, y: minY }
case 'left':
return { x: minX - padding - params.width, y: minY }
case 'bottom':
return { x: minX, y: maxY + padding }
case 'top':
return { x: minX, y: minY - padding - params.height }
}
}

View file

@ -0,0 +1,57 @@
import { resolve } from 'node:path'
import {
openDocument,
createEmptyDocument,
saveDocument,
fileExists,
} from '../document-manager'
import type { PenDocument } from '../../types/pen'
export interface OpenDocumentParams {
filePath?: string
}
export interface OpenDocumentResult {
filePath: string
document: {
version: string
name?: string
childCount: number
hasVariables: boolean
hasThemes: boolean
}
}
export async function handleOpenDocument(
params: OpenDocumentParams,
): Promise<OpenDocumentResult> {
let filePath: string
let doc: PenDocument
if (params.filePath) {
filePath = resolve(params.filePath)
const exists = await fileExists(filePath)
if (exists) {
doc = await openDocument(filePath)
} else {
// Create new file at specified path
doc = createEmptyDocument()
await saveDocument(filePath, doc)
}
} else {
throw new Error(
'filePath is required. Provide a path to an existing .op file or a new file to create.',
)
}
return {
filePath,
document: {
version: doc.version,
name: doc.name,
childCount: doc.children.length,
hasVariables: !!doc.variables && Object.keys(doc.variables).length > 0,
hasThemes: !!doc.themes && Object.keys(doc.themes).length > 0,
},
}
}

View file

@ -0,0 +1,44 @@
import { resolve } from 'node:path'
import { openDocument } from '../document-manager'
import { computeLayoutTree, type LayoutEntry } from '../utils/node-operations'
export interface SnapshotLayoutParams {
filePath: string
parentId?: string
maxDepth?: number
}
export async function handleSnapshotLayout(
params: SnapshotLayoutParams,
): Promise<{ layout: LayoutEntry[] }> {
const filePath = resolve(params.filePath)
const doc = await openDocument(filePath)
const maxDepth = params.maxDepth ?? 1
let nodes = doc.children
if (params.parentId) {
const findNode = (
list: typeof nodes,
id: string,
): (typeof nodes)[0] | undefined => {
for (const n of list) {
if (n.id === id) return n
if ('children' in n && n.children) {
const found = findNode(n.children, id)
if (found) return found
}
}
return undefined
}
const parent = findNode(doc.children, params.parentId)
if (!parent) {
throw new Error(`Node not found: ${params.parentId}`)
}
nodes =
'children' in parent && parent.children ? parent.children : []
}
const layout = computeLayoutTree(nodes, doc.children, maxDepth)
return { layout }
}

View file

@ -0,0 +1,40 @@
import { resolve } from 'node:path'
import { openDocument, saveDocument } from '../document-manager'
import type { VariableDefinition } from '../../types/variables'
export interface GetVariablesParams {
filePath: string
}
export interface SetVariablesParams {
filePath: string
variables: Record<string, VariableDefinition>
replace?: boolean
}
export async function handleGetVariables(
params: GetVariablesParams,
): Promise<{ variables: Record<string, VariableDefinition>; themes: Record<string, string[]> }> {
const filePath = resolve(params.filePath)
const doc = await openDocument(filePath)
return {
variables: doc.variables ?? {},
themes: doc.themes ?? {},
}
}
export async function handleSetVariables(
params: SetVariablesParams,
): Promise<{ variables: Record<string, VariableDefinition> }> {
const filePath = resolve(params.filePath)
const doc = await openDocument(filePath)
if (params.replace) {
doc.variables = params.variables
} else {
doc.variables = { ...(doc.variables ?? {}), ...params.variables }
}
await saveDocument(filePath, doc)
return { variables: doc.variables }
}

5
src/mcp/utils/id.ts Normal file
View file

@ -0,0 +1,5 @@
import { nanoid } from 'nanoid'
export function generateId(): string {
return nanoid()
}

View file

@ -0,0 +1,250 @@
import type { PenNode, RefNode } from '../../types/pen'
export function findNodeInTree(
nodes: PenNode[],
id: string,
): PenNode | undefined {
for (const node of nodes) {
if (node.id === id) return node
if ('children' in node && node.children) {
const found = findNodeInTree(node.children, id)
if (found) return found
}
}
return undefined
}
export function findParentInTree(
nodes: PenNode[],
id: string,
): PenNode | undefined {
for (const node of nodes) {
if ('children' in node && node.children) {
for (const child of node.children) {
if (child.id === id) return node
}
const found = findParentInTree(node.children, id)
if (found) return found
}
}
return undefined
}
export function removeNodeFromTree(nodes: PenNode[], id: string): PenNode[] {
return nodes
.filter((n) => n.id !== id)
.map((n) => {
if ('children' in n && n.children) {
return { ...n, children: removeNodeFromTree(n.children, id) }
}
return n
})
}
export function updateNodeInTree(
nodes: PenNode[],
id: string,
updates: Partial<PenNode>,
): PenNode[] {
return nodes.map((n) => {
if (n.id === id) {
return { ...n, ...updates } as PenNode
}
if ('children' in n && n.children) {
return {
...n,
children: updateNodeInTree(n.children, id, updates),
} as PenNode
}
return n
})
}
export function insertNodeInTree(
nodes: PenNode[],
parentId: string | null,
node: PenNode,
index?: number,
): PenNode[] {
if (parentId === null) {
const arr = [...nodes]
if (index !== undefined) {
arr.splice(index, 0, node)
} else {
arr.push(node)
}
return arr
}
return nodes.map((n) => {
if (n.id === parentId) {
const children = 'children' in n && n.children ? [...n.children] : []
if (index !== undefined) {
children.splice(index, 0, node)
} else {
children.push(node)
}
return { ...n, children } as PenNode
}
if ('children' in n && n.children) {
return {
...n,
children: insertNodeInTree(n.children, parentId, node, index),
} as PenNode
}
return n
})
}
export function flattenNodes(nodes: PenNode[]): PenNode[] {
const result: PenNode[] = []
for (const node of nodes) {
result.push(node)
if ('children' in node && node.children) {
result.push(...flattenNodes(node.children))
}
}
return result
}
export function cloneNodeWithNewIds(
node: PenNode,
generateId: () => string,
): PenNode {
const cloned = { ...node, id: generateId() } as PenNode
if ('children' in cloned && cloned.children) {
cloned.children = cloned.children.map((c) =>
cloneNodeWithNewIds(c, generateId),
)
}
return cloned
}
/** Get the bounding box of a node, resolving RefNode dimensions from component. */
export function getNodeBounds(
node: PenNode,
allNodes: PenNode[],
): { x: number; y: number; w: number; h: number } {
const x = node.x ?? 0
const y = node.y ?? 0
let w = 'width' in node && typeof node.width === 'number' ? node.width : 0
let h = 'height' in node && typeof node.height === 'number' ? node.height : 0
if (node.type === 'ref' && !w) {
const refComp = findNodeInTree(allNodes, (node as RefNode).ref)
if (refComp) {
w =
'width' in refComp && typeof refComp.width === 'number'
? refComp.width
: 100
h =
'height' in refComp && typeof refComp.height === 'number'
? refComp.height
: 100
}
}
return { x, y, w: w || 100, h: h || 100 }
}
/**
* Search nodes matching a pattern. Supports:
* - type: exact match on node type
* - name: regex match on node name
* - reusable: match reusable flag
*/
export function searchNodes(
nodes: PenNode[],
pattern: { type?: string; name?: string; reusable?: boolean },
maxDepth = Infinity,
currentDepth = 0,
): PenNode[] {
if (currentDepth > maxDepth) return []
const results: PenNode[] = []
for (const node of nodes) {
let matches = true
if (pattern.type && node.type !== pattern.type) matches = false
if (pattern.name) {
const regex = new RegExp(pattern.name, 'i')
if (!regex.test(node.name ?? '')) matches = false
}
if (pattern.reusable !== undefined) {
const isReusable = 'reusable' in node && (node as any).reusable === true
if (pattern.reusable !== isReusable) matches = false
}
if (matches) results.push(node)
if ('children' in node && node.children) {
results.push(
...searchNodes(node.children, pattern, maxDepth, currentDepth + 1),
)
}
}
return results
}
/** Read a node with depth-limited children. */
export function readNodeWithDepth(
node: PenNode,
depth: number,
): Record<string, unknown> {
const result: Record<string, unknown> = { ...node }
if (depth <= 0 && 'children' in node && node.children?.length) {
result.children = '...'
} else if ('children' in node && node.children) {
result.children = node.children.map((c) =>
readNodeWithDepth(c, depth - 1),
)
}
return result
}
/** Compute bounding box layout tree for snapshot_layout. */
export function computeLayoutTree(
nodes: PenNode[],
allNodes: PenNode[],
maxDepth: number,
currentDepth = 0,
parentX = 0,
parentY = 0,
): LayoutEntry[] {
const entries: LayoutEntry[] = []
for (const node of nodes) {
const bounds = getNodeBounds(node, allNodes)
const absX = parentX + bounds.x
const absY = parentY + bounds.y
const entry: LayoutEntry = {
id: node.id,
name: node.name,
type: node.type,
x: absX,
y: absY,
width: bounds.w,
height: bounds.h,
}
if (
'children' in node &&
node.children?.length &&
currentDepth < maxDepth
) {
entry.children = computeLayoutTree(
node.children,
allNodes,
maxDepth,
currentDepth + 1,
absX,
absY,
)
}
entries.push(entry)
}
return entries
}
export interface LayoutEntry {
id: string
name?: string
type: string
x: number
y: number
width: number
height: number
children?: LayoutEntry[]
}

View file

@ -39,23 +39,32 @@ RULES:
- Use "fill_container" to stretch, "fit_content" to shrink-wrap
- Use clipContent: true on cards/containers with cornerRadius + image children to prevent overflow
- Use justifyContent="space_between" to spread items across full width (great for navbars, footers)
OVERFLOW PREVENTION (CRITICAL violations cause visual glitches):
- TEXT WIDTH: text nodes inside layout frames MUST use width="fill_container" + textGrowth="fixed-width". NEVER set a fixed pixel width on text inside a layout it WILL overflow. The layout engine auto-constrains fill_container text to the parent's available content area.
BAD: {"type":"text","width":378,"textGrowth":"fixed-width"} inside a 195px card with 80px padding overflows!
GOOD: {"type":"text","width":"fill_container","textGrowth":"fixed-width"} auto-fits to 115px available space.
- CHILD SIZE: any child with a fixed pixel width must be parent's content area (parent width total horizontal padding). If unsure, use "fill_container".
- CJK TEXT (Chinese/Japanese/Korean): each character renders at ~1.0× fontSize width. For buttons/badges containing CJK text, ensure: container width (charCount × fontSize) + total horizontal padding. Example: "免费下载" (4 chars) at fontSize 15 needs ~60px content + padding button width 104px with padding [8,22].
- BADGES: when badge text is long or CJK, use width="fit_content" on the badge frame so it auto-sizes to its text content.
`
const DESIGN_EXAMPLES = `
EXAMPLES:
Button with icon:
{ "id": "btn-1", "type": "frame", "name": "Button", "x": 100, "y": 100, "width": 180, "height": 44, "cornerRadius": 8, "layout": "horizontal", "gap": 8, "justifyContent": "center", "alignItems": "center", "fill": [{ "type": "solid", "color": "#3B82F6" }], "children": [{ "id": "btn-icon", "type": "path", "name": "ArrowIcon", "d": "M5 12h14M12 5l7 7-7 7", "width": 20, "height": 20, "stroke": { "thickness": 2, "fill": [{ "type": "solid", "color": "#FFFFFF" }] } }, { "id": "btn-text", "type": "text", "name": "Label", "content": "Continue", "fontSize": 16, "fontWeight": 600, "width": 80, "height": 22, "fill": [{ "type": "solid", "color": "#FFFFFF" }] }] }
{ "id": "btn-1", "type": "frame", "name": "Button", "x": 100, "y": 100, "width": 180, "height": 48, "cornerRadius": 8, "layout": "horizontal", "gap": 8, "padding": [12, 24], "justifyContent": "center", "alignItems": "center", "fill": [{ "type": "solid", "color": "#3B82F6" }], "children": [{ "id": "btn-icon", "type": "path", "name": "ArrowRightIcon", "d": "M5 12h14m-7-7 7 7-7 7", "width": 20, "height": 20, "stroke": { "thickness": 2, "fill": [{ "type": "solid", "color": "#FFFFFF" }] } }, { "id": "btn-text", "type": "text", "name": "Label", "content": "Continue", "fontSize": 16, "fontWeight": 600, "fill": [{ "type": "solid", "color": "#FFFFFF" }] }] }
Card with image (clipContent prevents image from poking out of rounded corners):
{ "id": "card-1", "type": "frame", "name": "Card", "x": 50, "y": 50, "width": 320, "height": 340, "cornerRadius": 12, "clipContent": true, "layout": "vertical", "gap": 0, "fill": [{ "type": "solid", "color": "#FFFFFF" }], "effects": [{ "type": "shadow", "offsetX": 0, "offsetY": 4, "blur": 12, "spread": 0, "color": "rgba(0,0,0,0.1)" }], "children": [{ "id": "card-img", "type": "image", "name": "Cover", "src": "https://picsum.photos/320/180", "width": "fill_container", "height": 180 }, { "id": "card-body", "type": "frame", "name": "Body", "width": "fill_container", "height": "fit_content", "layout": "vertical", "padding": 20, "gap": 8, "children": [{ "id": "card-title", "type": "text", "name": "Title", "content": "Card Title", "fontSize": 20, "fontWeight": 700, "lineHeight": 1.2, "textGrowth": "fixed-width", "width": "fill_container", "fill": [{ "type": "solid", "color": "#111827" }] }, { "id": "card-desc", "type": "text", "name": "Description", "content": "Some description text here", "fontSize": 14, "lineHeight": 1.5, "textGrowth": "fixed-width", "width": "fill_container", "fill": [{ "type": "solid", "color": "#6B7280" }] }] }] }
ICONS & IMAGES:
- Icons: Use "path" nodes with SVG d attribute. Use stroke for line icons, fill for solid icons. Size 16-24px for UI icons. IMPORTANT: width and height must match the SVG path's natural aspect ratio symmetric icons like arrows are square, but brand logos (Apple, Meta, etc.) are often taller than wide or vice versa. Never force all icons to 1:1.
- Never use emoji characters as icons (e.g. 🧠📱). Always use "path" icon nodes.
- Icons: Use "path" nodes with SVG d attribute. Size 16-24px for UI icons. Use stroke for line icons (Lucide-style), fill for solid icons.
IMPORTANT: Give icon nodes a descriptive name matching standard icon names (e.g. "SearchIcon", "MenuIcon", "ArrowRightIcon", "CheckIcon", "StarIcon", "DownloadIcon", "PlayIcon", "ShieldIcon", "ZapIcon", "HeartIcon", "UserIcon", "HomeIcon", "MailIcon", "BellIcon", "SettingsIcon", "PlusIcon", "EyeIcon", "LockIcon", "PhoneIcon", "ChevronRightIcon", "ChevronDownIcon", "XIcon").
The system will auto-resolve icon names to verified SVG paths, so the name is more important than the d data.
- Never use emoji characters as icons (e.g. 🧠📱). Always use path nodes for icons.
- For app screenshot/mockup areas, use a phone placeholder frame with solid fill matching the page theme + 1px subtle stroke. cornerRadius ~32. No text inside just a clean phone shape.
- Do NOT use random real-world app screenshots or dense mini-app simulations for showcase sections.
- You know many icon SVG paths from popular Iconify collections use them freely: Lucide, Material Design Icons (mdi), Phosphor, Tabler Icons, Heroicons, Carbon, etc. Always give icon nodes descriptive names (e.g. "SearchIcon", "MenuIcon").
`
const ADAPTIVE_STYLE_POLICY = `
@ -83,6 +92,15 @@ TYPOGRAPHY SCALE (always set lineHeight on text nodes):
- Labels/Numbers: "Inter" or "Roboto Mono" as needed
- Uppercase labels: letterSpacing: 1-2
CJK TYPOGRAPHY (Chinese/Japanese/Korean content):
- When the design content is in Chinese/Japanese/Korean, use CJK-compatible fonts:
- Headings: "Noto Sans SC" (Chinese), "Noto Sans JP" (Japanese), "Noto Sans KR" (Korean)
- Body/UI: "Inter" (has system CJK fallback) or "Noto Sans SC"
- DO NOT use "Space Grotesk" or "Manrope" for CJK text these fonts have NO CJK glyphs and will render inconsistently.
- CJK lineHeight: use 1.3-1.4 for headings (not 1.1), 1.6-1.8 for body. CJK characters are taller and need more line spacing.
- CJK letterSpacing: use 0 for body, 0.5-1 for headings. Do NOT use negative letterSpacing on CJK it causes characters to overlap.
- Detect language from the user's request content: if the prompt or product description is in Chinese/Japanese/Korean, use CJK fonts for ALL text nodes.
SHAPES & EFFECTS:
- Corner Radius: 8-14 for modern product UI
- Use subtle shadows when appropriate; avoid heavy glow by default
@ -135,10 +153,13 @@ LAYOUT ENGINE (flexbox-based):
"center" = center-pack items
"start"/"end" = pack to start/end
- ALL nodes must be descendants of the root frame no floating/orphan elements
- WIDTH CONSISTENCY: siblings in a vertical layout must use the SAME width strategy. If one input/button uses "fill_container", ALL sibling inputs/buttons must also use "fill_container". Mixing fixed-px and fill_container causes misalignment.
- NEVER use "fill_container" on children of a "fit_content" parent circular dependency breaks layout.
- For two-column layouts: root (vertical) content row (horizontal) left column + right column. Each column uses "fill_container" width.
- TEXT IN LAYOUTS: text inside layout frames MUST use textGrowth="fixed-width" + width="fill_container". This makes text wrap within the parent and auto-size height. NEVER use fixed pixel widths for text in layout containers it causes clipping.
- TEXT IN LAYOUTS (MOST COMMON BUG read carefully): text inside layout frames MUST use textGrowth="fixed-width" + width="fill_container". NEVER use a fixed pixel width (e.g. width:378) on text inside a layout the text WILL overflow its parent. "fill_container" auto-constrains to available space.
- SHORT TEXT: buttons, labels, single-line text can use textGrowth="auto" (or omit it) text expands horizontally to fit content.
- NEVER set fixed pixel height on text nodes let textGrowth handle height automatically.
- TEXT HEIGHT: NEVER set explicit pixel height on text nodes (e.g. height:22, height:44). OMIT the height property entirely the layout engine auto-calculates height from textGrowth + content. Setting a small height causes text clipping and overlap with siblings below.
- CJK BUTTONS/BADGES: Chinese/Japanese/Korean characters are wider. For a button with CJK text, ensure container width (charCount × fontSize) + horizontal padding. Example: "免费下载" (4 chars) at 15px min content width ~60px button width 60 + left padding + right padding.
- Use nested frames for complex layouts
DESIGN GUIDELINES:
@ -148,20 +169,26 @@ DESIGN GUIDELINES:
- For web pages, use a consistent centered content container (~1040-1160px) across sections to keep alignment stable
- Max 3-4 levels of nesting
- Text: titles 22-28px bold, body 14-16px, captions 12px
- Buttons: height 44-48px, cornerRadius 8-12
- Inputs: height 44px, light bg, subtle border
- Buttons: height 44-52px, cornerRadius 8-12, padding [12, 24] (vertical, horizontal). With icon+text: layout="horizontal", gap=8, alignItems="center". Width: "fill_container" (stretch), "fit_content" (hug), or fixed px choose per context.
- Icon-only buttons (heart, bookmark, share, etc.): square frame 44x44px, justifyContent="center", alignItems="center", path icon 20-24px inside.
- Badges/tags ("NEW", "SALE", "PRO"): frame with padding [4, 12], cornerRadius 4-6, height="fit_content". Small text (11-13px), no textGrowth. Never clip badge text.
- Button + icon-button row: horizontal, gap=8-12. Primary button width="fill_container"; icon-only button fixed square 44-48px.
- Inputs: height 44px, light bg, subtle border. Use width="fill_container" in form contexts.
- Fixed-width children must NOT exceed their parent's content area (parent width minus padding).
- Consistent color palette
- Default to light neutral styling unless user explicitly asks for dark/neon/terminal
- Avoid repeating the exact same palette across unrelated designs
- Navigation bars: use justifyContent="space_between" with 3 child groups (logo-group | links-group | cta-button), padding=[0,80], alignItems="center". This auto-distributes them perfectly across the full width.
- Use path nodes for icons (SVG d path data). Size icons 16-24px. Preserve the natural aspect ratio of the SVG path do NOT force all icons to square. You can use icons from any popular Iconify collection: Lucide, Material Design Icons, Phosphor, Tabler, Heroicons, Carbon, etc.
- Never use emoji glyphs as icon substitutes. If an icon is needed, create a path node.
- Navigation bars (when designing landing pages/websites): use justifyContent="space_between" with 3 child groups (logo-group | links-group | cta-button), padding=[0,80], alignItems="center". This auto-distributes them perfectly across the full width.
- Icons: use "path" nodes with descriptive names matching standard icon names (e.g. "SearchIcon", "MenuIcon", "ArrowRightIcon"). The system auto-resolves names to verified SVG paths. Size 16-24px.
- Never use emoji glyphs as icon substitutes. If an icon is needed, use a path node with a descriptive icon name.
- Use image nodes for generic photos/illustrations only; for app preview areas prefer phone mockup placeholders
- Phone mockup/screenshot placeholder: exactly ONE "frame" node, width 260-300, height 520-580, cornerRadius 32, solid fill matching theme + 1px subtle stroke. NEVER use ellipse or circle for mockups. NEVER add any children inside (no text, no frames, no images). All mockups must look identical.
- NEVER use ellipse nodes for decorative/placeholder shapes. Use frame or rectangle with cornerRadius instead.
- Avoid adding an extra full-width CTA strip directly under navigation unless the prompt explicitly asks for that section.
- Buttons, nav items, and list items should include icons when appropriate for better UX
- Long subtitles/body copy should use fixed-width text blocks so lines wrap naturally instead of becoming a single very long line.
- CARD ROW ALIGNMENT: when cards are siblings in a horizontal layout, ALL cards MUST use height="fill_container". This makes all cards match the tallest card's height, creating a visually aligned row. Never use different fixed heights on sibling cards.
- TEXT WRAPPING: any text content longer than ~15 characters MUST have textGrowth="fixed-width". Without it, text expands horizontally in a single line and overflows. Only omit textGrowth for very short labels (1-3 words) like button text or nav links.
DESIGN VARIABLES:
- When the user message includes a DOCUMENT VARIABLES section, use "$variableName" references instead of hardcoded values wherever a matching variable exists.
@ -199,8 +226,8 @@ EXAMPLE:
${BLOCK}json
{"_parent":null,"id":"page","type":"frame","name":"Page","x":0,"y":0,"width":375,"height":812,"layout":"vertical","gap":0,"fill":[{"type":"solid","color":"#F8FAFC"}]}
{"_parent":"page","id":"nav","type":"frame","name":"Nav","width":"fill_container","height":56,"layout":"horizontal","padding":16,"alignItems":"center","fill":[{"type":"solid","color":"#FFFFFF"}]}
{"_parent":"nav","id":"logo","type":"text","name":"Logo","content":"App","fontSize":18,"fontWeight":700,"width":"fit_content","height":22,"fill":[{"type":"solid","color":"#0F172A"}]}
{"_parent":"nav","id":"menu-icon","type":"path","name":"MenuIcon","d":"M3 12h18M3 6h18M3 18h18","width":24,"height":24,"stroke":{"thickness":2,"fill":[{"type":"solid","color":"#0F172A"}]}}
{"_parent":"nav","id":"logo","type":"text","name":"Logo","content":"App","fontSize":18,"fontWeight":700,"fill":[{"type":"solid","color":"#0F172A"}]}
{"_parent":"nav","id":"menu-icon","type":"path","name":"MenuIcon","d":"M4 6h16M4 12h16M4 18h16","width":24,"height":24,"stroke":{"thickness":2,"fill":[{"type":"solid","color":"#0F172A"}]}}
{"_parent":"page","id":"hero","type":"frame","name":"Hero","width":"fill_container","height":300,"layout":"vertical","padding":24,"gap":16,"alignItems":"center","justifyContent":"center"}
{"_parent":"hero","id":"title","type":"text","name":"Title","content":"Welcome","fontSize":28,"fontWeight":700,"textGrowth":"fixed-width","width":"fill_container","fill":[{"type":"solid","color":"#0F172A"}]}
${BLOCK}
@ -213,6 +240,8 @@ CRITICAL RULES:
- NEVER set x/y on children inside layout frames the layout engine positions them automatically.
- ALL nodes must be descendants of the root frame no floating/orphan elements.
- Section frames must use width="fill_container" to span full page width.
- WIDTH CONSISTENCY: siblings in a vertical layout must use the SAME width strategy. If one input uses "fill_container", ALL sibling inputs/buttons in that container must also use "fill_container". Never mix fixed-px and fill_container in form layouts.
- NEVER use "fill_container" on children of "fit_content" parent circular dependency breaks layout.
- For two-column content: use a horizontal frame parent with two child frames.
- Use clipContent: true on cards/containers with cornerRadius + image/overflow content. Essential for clean rounded corners.
- Use width/height (or "fill_container") on all children. Unique descriptive IDs. All colors as fill arrays.
@ -220,14 +249,23 @@ CRITICAL RULES:
- After the json block, add a 1-sentence summary.
- Phone mockup: exactly ONE "frame" node, width 260-300, height 520-580, cornerRadius 32, solid fill + 1px stroke. NEVER use ellipse. NEVER add any children inside (no text, no frames, no images). All mockups identical.
- NEVER use ellipse for decorative/placeholder shapes use frame or rectangle with cornerRadius.
- Navigation bars: justifyContent="space_between", 3 groups (logo | links | CTA), padding=[0,80], alignItems="center".
- Never use emoji as icons; use path nodes only.
- TEXT IN LAYOUTS: text inside layout frames MUST use textGrowth="fixed-width" + width="fill_container". NEVER use fixed pixel widths/heights for text let textGrowth auto-size. Short labels/buttons can omit textGrowth.
- Navigation bars (when applicable): justifyContent="space_between", 3 groups (logo | links | CTA), padding=[0,80], alignItems="center".
- Never use emoji as icons; use path nodes with descriptive icon names (system auto-resolves to verified SVG paths).
- TEXT IN LAYOUTS: text inside layout frames MUST use textGrowth="fixed-width" + width="fill_container". NEVER use fixed pixel widths on text. Short labels/buttons can omit textGrowth.
- TEXT HEIGHT: NEVER set explicit pixel height on text nodes (e.g. height:22). OMIT the height property the engine auto-calculates from textGrowth + content. A small explicit height causes text clipping and overlap.
- Cards with images: ALWAYS set clipContent: true + cornerRadius. Use "fill_container" width on image/body/text children inside the card.
- CARD ROW ALIGNMENT: cards in a horizontal row MUST ALL use width="fill_container" + height="fill_container" for even distribution and equal height. Never use different fixed heights on sibling cards it creates an ugly uneven row.
- TEXT WRAPPING: any text content longer than ~15 characters MUST have textGrowth="fixed-width" + width="fill_container". Without textGrowth, long text renders as ONE single line and overflows. Only omit textGrowth for very short labels (1-3 words).
- Keep section rhythm consistent (80-120px vertical padding) and preserve alignment between sections.
OVERFLOW PREVENTION (CRITICAL #1 source of visual bugs):
- ALL text nodes inside layout frames width="fill_container" + textGrowth="fixed-width". No exceptions. NEVER width:378 or width:224 on text inside a layout frame.
- Fixed-width children must be parent content area (parent width horizontal padding). Example: a card width=195 with padding=[24,40,24,40] has 115px available a child with width=378 causes severe overflow.
- CJK (Chinese/Japanese/Korean) text in buttons: each CJK char fontSize wide. "免费下载" (4 chars) at fontSize 15 = ~60px minimum content width. Button must be 60 + horizontal padding.
- Badges with dynamic text: use width="fit_content" so the badge auto-expands to fit its text.
SIZING: Mobile root 375x812. Web root 1200x800 (single screen) or 1200x3000-5000 (landing page).
ICONS: "path" nodes with SVG d. Size 16-24px. Use Lucide/MDI/Heroicons paths.
ICONS: "path" nodes with descriptive names (e.g. "SearchIcon", "MenuIcon"). System auto-resolves to verified SVG paths. Size 16-24px.
IMAGES: for app showcase sections, prefer phone mockup placeholders over real screenshots.
STYLE: Default to light neutral palette unless user explicitly asks for dark/terminal/cyber. Avoid always reusing black+green.

View file

@ -635,9 +635,9 @@ function equalizeHorizontalSiblings(parentId: string): void {
const minH = Math.min(...heights)
if (maxH <= 0 || minH / maxH <= 0.5) return
// Convert to fill_container for even distribution
// Convert to fill_container for even distribution and equal height
for (const child of fixedFrames) {
updateNode(child.id, { width: 'fill_container' } as Partial<PenNode>)
updateNode(child.id, { width: 'fill_container', height: 'fill_container' } as Partial<PenNode>)
}
}
@ -760,11 +760,15 @@ export function animateNodesToCanvas(nodes: PenNode[]): void {
}
function applyGenerationHeuristics(node: PenNode): void {
applyIconPathResolution(node)
applyNoEmojiIconHeuristic(node)
applyImagePlaceholderHeuristic(node)
applyScreenshotFramePlaceholderHeuristic(node)
applyNavbarHeuristic(node)
applyHorizontalAlignCenterHeuristic(node)
applyIconButtonSizing(node)
applyBadgeSizing(node)
applyButtonSpacingHeuristic(node)
applyButtonWidthHeuristic(node)
applyTextWrappingHeuristic(node)
applyClipContentHeuristic(node)
@ -776,11 +780,15 @@ function applyGenerationHeuristics(node: PenNode): void {
applyTextFillContainerInLayout(node)
// Card row equalization: horizontal rows of cards → fill_container
applyCardRowEqualization(node)
// Form/card children: convert fixed-width buttons/inputs to fill_container
applyFormChildFillContainer(node)
for (const child of node.children) {
applyGenerationHeuristics(child)
}
// Remove decorative glow/shadow frames next to phone placeholders
applyRemoveDecorativeGlowSiblings(node)
// After children are processed, fix horizontal overflow (children wider than parent)
applyHorizontalOverflowFix(node)
// After children are processed (text heights fixed), expand frame to fit content
applyFrameHeightExpansion(node)
}
@ -915,29 +923,65 @@ function applyTreeFixesRecursive(
}
// --- Fix 1: Text in layout frames → Fill Width + Auto Height ---
// Skip if parent is fit_content (hug) — fill_container child breaks hug parent layout
const parentIsHug1 = node.width === 'fit_content'
if (node.layout && node.layout !== 'none') {
// Compute parent content width for accurate text height estimation
const nodeW = toSizeNumber(node.width, 0)
const nodePad = parsePaddingValues('padding' in node ? node.padding : undefined)
const nodeContentW = nodeW > 0 ? nodeW - nodePad.left - nodePad.right : 0
for (const child of children) {
if (child.type !== 'text') continue
const needsWidthFix = typeof child.width === 'number'
const needsWidthFix = typeof child.width === 'number' && !parentIsHug1
const needsGrowthFix = !child.textGrowth
if (needsWidthFix || needsGrowthFix) {
const updates: Record<string, unknown> = {}
if (needsWidthFix) updates.width = 'fill_container'
if (needsGrowthFix) updates.textGrowth = 'fixed-width'
// Estimate auto-height based on content
// Estimate auto-height based on content and parent width
const text = typeof child.content === 'string'
? child.content : Array.isArray(child.content)
? child.content.map((s: { text: string }) => s.text).join('') : ''
if (text) {
const fs = child.fontSize ?? 16
const lh = child.lineHeight ?? 1.2
updates.height = estimateAutoHeight(text, fs, lh)
updates.height = estimateAutoHeight(text, fs, lh, nodeContentW || undefined)
}
updateNode(child.id, updates as Partial<PenNode>)
}
}
}
// --- Fix 8: Enforce minimum text height in layout frames ---
// AI often generates text with height:22 on a 48px font → text gets clipped and overlaps siblings.
// For ALL text children in layout frames, ensure height >= fontSize * lineHeight (single line minimum).
// For multi-line text (textGrowth="fixed-width"), re-estimate if current height is too small.
if (node.layout && node.layout !== 'none') {
const nodeW8 = toSizeNumber(node.width, 0)
const nodePad8 = parsePaddingValues('padding' in node ? node.padding : undefined)
const nodeContentW8 = nodeW8 > 0 ? nodeW8 - nodePad8.left - nodePad8.right : 0
for (const child of children) {
if (child.type !== 'text') continue
const fs = child.fontSize ?? 16
const lh = child.lineHeight ?? (fs >= 28 ? 1.2 : 1.5)
const currentH = toSizeNumber(child.height, 0)
const singleLineMin = Math.round(fs * Math.max(lh, 1.2) * 1.15)
if (currentH > 0 && currentH < singleLineMin) {
// Height is too small for even a single line — re-estimate
const text = typeof child.content === 'string'
? child.content : Array.isArray(child.content)
? child.content.map((s: { text: string }) => s.text).join('') : ''
if (text && child.textGrowth === 'fixed-width') {
const estimated = estimateAutoHeight(text, fs, lh, nodeContentW8 || undefined)
updateNode(child.id, { height: Math.max(estimated, singleLineMin) } as Partial<PenNode>)
} else {
updateNode(child.id, { height: singleLineMin } as Partial<PenNode>)
}
}
}
}
// --- Fix 2: Button/badge width expansion for CJK text + icons ---
const w = toSizeNumber(node.width, 0)
const h = toSizeNumber(node.height, 0)
@ -974,8 +1018,8 @@ function applyTreeFixesRecursive(
// --- Fix 7: Card row equalization in horizontal layouts ---
// When a horizontal layout has ≥2 fixed-width frame children of similar height
// (a card row), convert their widths to fill_container for even distribution.
// This prevents cards from being too narrow for their icon+text content.
if (node.layout === 'horizontal' && children.length >= 2) {
// Skip if parent is fit_content (hug) — fill_container children break hug layout.
if (node.layout === 'horizontal' && node.width !== 'fit_content' && children.length >= 2) {
const fixedFrames = children.filter((c: PenNode) =>
c.type === 'frame' && typeof c.width === 'number' && (c.width as number) > 0,
)
@ -988,12 +1032,98 @@ function applyTreeFixesRecursive(
// Similar heights (within 60% ratio) → likely a card row, not sidebar+content
if (maxH > 0 && minH / maxH > 0.5) {
for (const child of fixedFrames) {
updateNode(child.id, { width: 'fill_container' } as Partial<PenNode>)
updateNode(child.id, { width: 'fill_container', height: 'fill_container' } as Partial<PenNode>)
}
}
}
}
// --- Fix 12: Icon-only button sizing ---
{
const nodeW12 = toSizeNumber(node.width, 0)
const nodeH12 = toSizeNumber(node.height, 0)
if (nodeW12 <= 80 && nodeH12 <= 80 && nodeW12 > 0 && nodeH12 > 0) {
const hasText12 = children.some((c: PenNode) => c.type === 'text')
const hasIcon12 = children.some((c: PenNode) =>
c.type === 'path' || c.type === 'rectangle' || c.type === 'ellipse',
)
if (!hasText12 && hasIcon12) {
const updates12: Record<string, unknown> = {}
if (typeof node.width === 'number' && nodeW12 < 40) updates12.width = 40
if (typeof node.height === 'number' && nodeH12 < 40) updates12.height = 40
if (!node.justifyContent) updates12.justifyContent = 'center'
if (!node.alignItems) updates12.alignItems = 'center'
if (Object.keys(updates12).length > 0) {
updateNode(node.id, updates12 as Partial<PenNode>)
}
}
}
}
// --- Fix 13: Badge/tag sizing ---
{
const textKids13 = children.filter(
(c: PenNode) => c.type === 'text' && typeof c.content === 'string',
)
if (textKids13.length === 1) {
const t13 = textKids13[0]
if (t13.type === 'text' && typeof t13.content === 'string') {
const txt13 = t13.content.trim()
const fs13 = t13.fontSize ?? 14
if (txt13.length > 0 && txt13.length <= 20 && fs13 <= 16) {
const h13 = toSizeNumber(node.height, 0)
if (h13 > 0 && h13 <= 48) {
const pad13 = parsePaddingValues('padding' in node ? node.padding : undefined)
if (pad13.top < 4 || pad13.bottom < 4 || pad13.left < 10 || pad13.right < 10) {
const newPad13: [number, number, number, number] = [
Math.max(pad13.top, 4), Math.max(pad13.right, 10),
Math.max(pad13.bottom, 4), Math.max(pad13.left, 10),
]
updateNode(node.id, { padding: newPad13 } as Partial<PenNode>)
}
}
}
}
}
}
// --- Fix 9: Button spacing (padding/gap/height) ---
{
const nodeH = toSizeNumber(node.height, 0)
const isHorizontal = node.layout === 'horizontal'
const isCentered = node.alignItems === 'center' || node.justifyContent === 'center'
const hasTextChild = children.some(
(c: PenNode) => c.type === 'text' && typeof c.content === 'string' && c.content.trim().length > 0,
)
if (nodeH > 0 && nodeH <= 72 && (isHorizontal || isCentered) && hasTextChild) {
const pad = parsePaddingValues('padding' in node ? node.padding : undefined)
const minV = 8
const minH2 = 16
if (pad.top < minV || pad.bottom < minV || pad.left < minH2 || pad.right < minH2) {
const newTop = Math.max(pad.top, minV)
const newBottom = Math.max(pad.bottom, minV)
const newLeft = Math.max(pad.left, minH2)
const newRight = Math.max(pad.right, minH2)
const newPad: [number, number, number, number] = [newTop, newRight, newBottom, newLeft]
updateNode(node.id, { padding: newPad } as Partial<PenNode>)
}
if (children.length >= 2 && isHorizontal) {
const currentGap = toGapNumber('gap' in node ? node.gap : undefined)
if (currentGap < 8) {
updateNode(node.id, { gap: 8 } as Partial<PenNode>)
}
}
const fontSize = children.reduce((max: number, c: PenNode) => {
if (c.type === 'text') return Math.max(max, c.fontSize ?? 16)
return max
}, 0)
const minHeight = Math.max(36, Math.round(fontSize * 2.4))
if (nodeH < minHeight) {
updateNode(node.id, { height: minHeight })
}
}
}
// --- Fix 3: clipContent for frames with cornerRadius + image children ---
if (!('clipContent' in node && node.clipContent)) {
const cr = toCornerRadiusNumber(node.cornerRadius, 0)
@ -1002,11 +1132,139 @@ function applyTreeFixesRecursive(
}
}
// --- Fix 11: Form children alignment & button row layout ---
// Skip if parent is fit_content (hug) — fill_container children break hug layout
if (node.layout === 'vertical' && node.width !== 'fit_content' && children.length >= 2) {
const parentW11 = toSizeNumber(node.width, 0)
const pad11 = parsePaddingValues('padding' in node ? node.padding : undefined)
const contentW11 = parentW11 > 0 ? parentW11 - pad11.left - pad11.right : 0
// Check if any sibling already uses fill_container —
// if so, fixed-width siblings should align by also using fill_container.
const hasFillSibling11 = children.some((c: PenNode) =>
c.type === 'frame' && c.width === 'fill_container',
)
for (const child of children) {
if (child.type !== 'frame') continue
if (typeof child.width !== 'number') continue
const childW = toSizeNumber(child.width, 0)
const childH = toSizeNumber('height' in child ? child.height : undefined, 0)
// Overflow: child wider than parent content area
if (contentW11 > 0 && childW > contentW11) {
updateNode(child.id, { width: 'fill_container' } as Partial<PenNode>)
continue
}
// Consistency: if a sibling uses fill_container, match it
if (hasFillSibling11 && childH > 0 && childH <= 72) {
const hasContent = 'children' in child && Array.isArray(child.children)
&& child.children.some((gc: PenNode) => gc.type === 'text' || gc.type === 'path')
if (hasContent) {
updateNode(child.id, { width: 'fill_container' } as Partial<PenNode>)
continue
}
}
// Horizontal button row → row fills parent, buttons use fit_content
if (child.layout === 'horizontal'
&& 'children' in child && Array.isArray(child.children)
&& child.children.length >= 2) {
const allBtnLike = child.children.every((gc: PenNode) =>
gc.type === 'frame'
&& 'children' in gc && Array.isArray(gc.children)
&& gc.children.some((ggc: PenNode) => ggc.type === 'text' || ggc.type === 'path'),
)
if (allBtnLike) {
updateNode(child.id, { width: 'fill_container' } as Partial<PenNode>)
for (const btn of child.children) {
if (btn.type === 'frame' && typeof btn.width === 'number') {
updateNode(btn.id, { width: 'fit_content' } as Partial<PenNode>)
}
}
const childGap = toGapNumber('gap' in child ? child.gap : undefined)
const updates11: Record<string, unknown> = {}
if (!child.justifyContent || child.justifyContent === 'start') {
updates11.justifyContent = 'center'
}
if (childGap < 8) updates11.gap = 12
if (Object.keys(updates11).length > 0) {
updateNode(child.id, updates11 as Partial<PenNode>)
}
}
}
}
}
// Recurse into child frames
for (const child of children) {
applyTreeFixesRecursive(child, getNodeById, updateNode, removeNode)
}
// --- Fix 10: Horizontal overflow — try gap reduction, fit_content, then expand ---
if (node.layout === 'horizontal' && typeof node.width === 'number' && children.length >= 2) {
const parentW2 = toSizeNumber(node.width, 0)
const pad2 = parsePaddingValues('padding' in node ? node.padding : undefined)
const gap2 = toGapNumber('gap' in node ? node.gap : undefined)
const availW2 = parentW2 - pad2.left - pad2.right
let childrenTotalW = 0
const fixedFrames: PenNode[] = []
for (const child of children) {
const cw = toSizeNumber('width' in child ? (child as { width?: number | string }).width : undefined, 0)
if ('width' in child && typeof (child as { width?: number | string }).width === 'number' && cw > 0) {
childrenTotalW += cw
if (child.type === 'frame') fixedFrames.push(child)
} else {
childrenTotalW += 80
}
}
const gapTotal2 = gap2 * (children.length - 1)
childrenTotalW += gapTotal2
if (childrenTotalW > availW2) {
// Strategy 1: Reduce gap
for (const tryGap of [8, 4]) {
if (gap2 > tryGap) {
const reduced = childrenTotalW - gapTotal2 + tryGap * (children.length - 1)
if (reduced <= availW2) {
updateNode(node.id, { gap: tryGap } as Partial<PenNode>)
childrenTotalW = reduced
break
}
}
}
// Strategy 2: fit_content for button-like rows
if (childrenTotalW > availW2 && fixedFrames.length >= 2) {
const heights2 = fixedFrames.map((c: PenNode) => toSizeNumber('height' in c ? c.height : undefined, 0))
const maxH2 = Math.max(...heights2, 0)
const minH2 = Math.min(...heights2, Infinity)
if (maxH2 > 0 && maxH2 <= 80 && minH2 / maxH2 > 0.5) {
for (const child of fixedFrames) {
updateNode(child.id, { width: 'fit_content' } as Partial<PenNode>)
}
const updates10: Record<string, unknown> = {}
if (!node.justifyContent || node.justifyContent === 'start') {
updates10.justifyContent = 'center'
}
if (Object.keys(updates10).length > 0) {
updateNode(node.id, updates10 as Partial<PenNode>)
}
} else {
// Strategy 3: Expand parent to fit
const neededW2 = Math.round(childrenTotalW + pad2.left + pad2.right)
if (neededW2 > parentW2 && neededW2 <= generationCanvasWidth) {
updateNode(node.id, { width: neededW2 } as Partial<PenNode>)
} else if (neededW2 > generationCanvasWidth * 0.8) {
updateNode(node.id, { width: 'fill_container' } as Partial<PenNode>)
}
}
}
}
}
// --- Fix 4: Frame height expansion (after children are processed) ---
if (typeof node.height === 'number' && node.layout && node.layout !== 'none') {
const intrinsic = estimateNodeIntrinsicHeight(node)
@ -1028,6 +1286,8 @@ function applyTreeFixesRecursive(
function applyCardRowEqualization(parent: PenNode): void {
if (parent.type !== 'frame') return
if (parent.layout !== 'horizontal') return
// Never convert children to fill_container when parent is fit_content (hug)
if (parent.width === 'fit_content') return
if (!Array.isArray(parent.children) || parent.children.length < 2) return
const fixedFrames = parent.children.filter(
@ -1052,21 +1312,40 @@ function applyTextFillContainerInLayout(parent: PenNode): void {
if (!layout || layout === 'none') return
if (!Array.isArray(parent.children)) return
// NEVER convert children to fill_container when parent is fit_content (hug width).
// fill_container child + fit_content parent = circular dependency → layout breaks.
const parentIsHug = parent.width === 'fit_content'
// Compute parent's actual content width for accurate text height estimation
const parentW = toSizeNumber(parent.width, 0)
const pad = parsePaddingValues('padding' in parent ? parent.padding : undefined)
const contentW = parentW > 0 ? parentW - pad.left - pad.right : 0
for (const child of parent.children) {
if (child.type === 'text') {
// ALL text inside layout frames: Fill Width + Auto Height.
if (typeof child.width === 'number') child.width = 'fill_container'
// Convert fixed-pixel text to fill_container, BUT NOT if parent is fit_content
if (typeof child.width === 'number' && !parentIsHug) {
child.width = 'fill_container'
}
if (!child.textGrowth) child.textGrowth = 'fixed-width'
if (!child.lineHeight) {
const fs = child.fontSize ?? 16
child.lineHeight = fs >= 28 ? 1.2 : 1.5
const hasCjk = /[\u4E00-\u9FFF\u3400-\u4DBF]/.test(
typeof child.content === 'string' ? child.content : '',
)
child.lineHeight = hasCjk
? (fs >= 28 ? 1.4 : 1.7)
: (fs >= 28 ? 1.2 : 1.5)
}
// Re-estimate height based on parent's actual content width (not canvas width)
if (contentW > 0 && child.textGrowth === 'fixed-width' && typeof child.content === 'string' && child.content.trim()) {
const fs = child.fontSize ?? 16
const lh = child.lineHeight ?? 1.5
child.height = estimateAutoHeight(child.content.trim(), fs, lh, contentW)
}
}
// Also fix image children in vertical layout — images should fill parent width
if (child.type === 'image' && typeof child.width === 'number' && layout === 'vertical') {
const parentW = toSizeNumber(parent.width, 0)
const pad = parsePaddingValues('padding' in parent ? parent.padding : undefined)
const contentW = parentW - pad.left - pad.right
if (child.type === 'image' && typeof child.width === 'number' && layout === 'vertical' && !parentIsHug) {
if (contentW > 0 && child.width >= contentW * 0.9) {
child.width = 'fill_container'
}
@ -1258,6 +1537,144 @@ function applyHorizontalAlignCenterHeuristic(node: PenNode): void {
node.alignItems = 'center'
}
/**
* Icon-only buttons (heart, bookmark, share, etc.) ensure minimum square sizing.
* AI often makes these too small (e.g. 24x24) making them untappable/invisible.
* Detects: frame with only path/icon children, no text, small size enforce 40x40 min.
*/
function applyIconButtonSizing(node: PenNode): void {
if (node.type !== 'frame') return
if (!Array.isArray(node.children) || node.children.length === 0) return
const w = toSizeNumber(node.width, 0)
const h = toSizeNumber(node.height, 0)
// Must be a small frame (likely button-sized or too small)
if (w > 80 || h > 80) return
// Must have only icon-like children (path, rectangle, ellipse) — NO text
const hasText = node.children.some((c) => c.type === 'text')
if (hasText) return
const hasIcon = node.children.some((c) =>
c.type === 'path' || c.type === 'rectangle' || c.type === 'ellipse',
)
if (!hasIcon) return
// This is an icon-only button — enforce minimum 40x40
const minSize = 40
if (typeof node.width === 'number' && w < minSize) node.width = minSize
if (typeof node.height === 'number' && h < minSize) node.height = minSize
// Ensure centered
if (!node.justifyContent) node.justifyContent = 'center'
if (!node.alignItems) node.alignItems = 'center'
}
/**
* Badge/tag frames (e.g. "NEW", "SALE", "PRO") ensure minimum padding
* and prevent text clipping. Detects: small frame with very short text child.
*/
function applyBadgeSizing(node: PenNode): void {
if (node.type !== 'frame') return
if (!Array.isArray(node.children)) return
// Badge = small frame with 1 short text child
const textChildren = node.children.filter(
(c) => c.type === 'text' && typeof c.content === 'string',
)
if (textChildren.length !== 1) return
const textNode = textChildren[0]
if (textNode.type !== 'text' || typeof textNode.content !== 'string') return
const text = textNode.content.trim()
// Only for short text (badges, tags) — max ~20 chars
if (text.length === 0 || text.length > 20) return
const fontSize = textNode.fontSize ?? 14
// Only for small text (badge-sized)
if (fontSize > 16) return
const h = toSizeNumber(node.height, 0)
// Frame should be small (badge-like), not a full section
if (h > 48) return
// Ensure minimum padding so text isn't clipped
const pad = parsePaddingValues('padding' in node ? node.padding : undefined)
const minV = 4
const minH = 10
if (pad.top < minV || pad.bottom < minV || pad.left < minH || pad.right < minH) {
node.padding = [
Math.max(pad.top, minV),
Math.max(pad.right, minH),
Math.max(pad.bottom, minV),
Math.max(pad.left, minH),
]
}
// Ensure text has proper sizing — don't clip
const estimatedW = estimateSingleLineTextWidth(text, fontSize)
const frameW = toSizeNumber(node.width, 0)
const newPad = parsePaddingValues(node.padding)
const minFrameW = Math.round(estimatedW + newPad.left + newPad.right + 4)
if (typeof node.width === 'number' && frameW > 0 && frameW < minFrameW) {
node.width = minFrameW
}
// Ensure minimum height for badge
const minFrameH = Math.round(fontSize * 1.4 + newPad.top + newPad.bottom)
if (typeof node.height === 'number' && h > 0 && h < minFrameH) {
node.height = minFrameH
}
}
/**
* Ensure button-like frames have minimum internal padding and gap.
* AI often generates buttons with padding: 0 or tiny padding, causing cramped text.
* Also ensures a minimum gap when icon + text coexist in a horizontal button.
*/
function applyButtonSpacingHeuristic(node: PenNode): void {
if (node.type !== 'frame') return
if (!Array.isArray(node.children) || node.children.length === 0) return
const h = toSizeNumber(node.height, 0)
// Only target small frames that look like buttons/badges (height ≤ 72px)
if (h <= 0 || h > 72) return
const isHorizontal = node.layout === 'horizontal'
const isCentered = node.alignItems === 'center' || node.justifyContent === 'center'
if (!isHorizontal && !isCentered) return
const hasText = node.children.some(
(c) => c.type === 'text' && typeof c.content === 'string' && c.content.trim().length > 0,
)
if (!hasText) return
// Ensure minimum padding
const pad = parsePaddingValues('padding' in node ? node.padding : undefined)
const minV = 8
const minH = 16
if (pad.top < minV || pad.bottom < minV || pad.left < minH || pad.right < minH) {
const newTop = Math.max(pad.top, minV)
const newBottom = Math.max(pad.bottom, minV)
const newLeft = Math.max(pad.left, minH)
const newRight = Math.max(pad.right, minH)
if (newTop === newBottom && newLeft === newRight) {
node.padding = [newTop, newLeft]
} else {
node.padding = [newTop, newRight, newBottom, newLeft]
}
}
// Ensure minimum gap when there are multiple children (e.g. icon + text)
if (node.children.length >= 2 && isHorizontal) {
const currentGap = toGapNumber('gap' in node ? node.gap : undefined)
if (currentGap < 8) {
node.gap = 8
}
}
// Ensure minimum height for buttons
const fontSize = node.children.reduce((max, c) => {
if (c.type === 'text') return Math.max(max, c.fontSize ?? 16)
return max
}, 0)
const minHeight = Math.max(36, Math.round(fontSize * 2.4))
if (h < minHeight) {
node.height = minHeight
}
}
/**
* Ensure button/badge-like frames are wide enough for their text content.
* AI often generates fixed widths based on Latin text estimates, but CJK characters
@ -1312,25 +1729,38 @@ function applyButtonWidthHeuristic(node: PenNode): void {
* prevents the under-estimation that causes text clipping in buttons/cards. */
/**
* Estimate auto-height for text with fill_container width.
* Uses generationCanvasWidth as a rough approximation of available width.
* When parentContentWidth is provided, uses it directly for accurate wrapping.
* Otherwise falls back to generationCanvasWidth * 0.45 (conservative for nested layouts).
*/
function estimateAutoHeight(
text: string,
fontSize: number,
lineHeight: number,
parentContentWidth?: number,
): number {
// CJK text needs larger minimum lineHeight — CJK characters are taller
const hasCjk = /[\u4E00-\u9FFF\u3400-\u4DBF\u3000-\u303F]/.test(text)
const minLh = hasCjk
? (fontSize >= 28 ? 1.3 : 1.6)
: (fontSize >= 28 ? 1.15 : 1.4)
const effectiveLh = Math.max(lineHeight, minLh)
const totalTextWidth = estimateSingleLineTextWidth(text, fontSize)
// Approximate available text width: ~60% of canvas for nested layouts
const availW = Math.max(200, generationCanvasWidth * 0.6)
// Use parent width if known, otherwise conservative fallback (45% of canvas
// accounts for two-column layouts, padding, and nesting)
const availW = parentContentWidth
? Math.max(100, parentContentWidth)
: Math.max(200, generationCanvasWidth * 0.45)
const lines = Math.max(1, Math.ceil(totalTextWidth / availW))
return Math.round(lines * fontSize * lineHeight)
// Add 15% safety margin to prevent tight clipping
return Math.round(lines * fontSize * effectiveLh * 1.15)
}
function estimateSingleLineTextWidth(text: string, fontSize: number): number {
let width = 0
for (const char of text) {
if (/[\u4E00-\u9FFF\u3400-\u4DBF\u3000-\u303F\uFF00-\uFFEF\uFE30-\uFE4F]/.test(char)) {
width += fontSize * 1.2 // CJK full-width + font metrics margin
width += fontSize * 1.35 // CJK full-width + font metrics + kerning margin
} else if (char === ' ') {
width += fontSize * 0.3
} else {
@ -1340,6 +1770,166 @@ function estimateSingleLineTextWidth(text: string, fontSize: number): number {
return width
}
/**
* In vertical layout containers (forms, cards, panels):
* 1. Primary action buttons that clearly overflow fill_container
* 2. Horizontal button rows (social login etc.) row gets fill_container,
* individual buttons get fit_content + row uses justifyContent to distribute
* 3. Only touch children that actually overflow respect the AI's sizing choices
*/
function applyFormChildFillContainer(node: PenNode): void {
if (node.type !== 'frame') return
if (node.layout !== 'vertical') return
// Never convert children when parent is fit_content (hug) — breaks layout
if (node.width === 'fit_content') return
if (!Array.isArray(node.children) || node.children.length < 2) return
const parentW = toSizeNumber(node.width, 0)
const pad = parsePaddingValues('padding' in node ? node.padding : undefined)
const contentW = parentW > 0 ? parentW - pad.left - pad.right : 0
// Check if any sibling frame already uses fill_container —
// if so, fixed-width siblings should align by also using fill_container.
const hasFillSibling = node.children.some((c) =>
c.type === 'frame' && c.width === 'fill_container',
)
for (const child of node.children) {
if (child.type !== 'frame') continue
if (!('width' in child)) continue
if (typeof (child as { width?: number | string }).width !== 'number') continue
const childW = toSizeNumber((child as { width?: number | string }).width, 0)
const childH = toSizeNumber('height' in child ? child.height : undefined, 0)
// Overflow: child wider than parent content area
if (contentW > 0 && childW > contentW) {
;(child as unknown as Record<string, unknown>).width = 'fill_container'
continue
}
// Consistency: if a sibling already uses fill_container,
// convert input/button-like children (short height, has content) too
if (hasFillSibling && childH > 0 && childH <= 72) {
const hasContent = 'children' in child && Array.isArray(child.children)
&& child.children.some((gc) => gc.type === 'text' || gc.type === 'path')
if (hasContent) {
;(child as unknown as Record<string, unknown>).width = 'fill_container'
continue
}
}
// Horizontal button row (e.g. social login: Google | Apple | GitHub)
if (child.layout === 'horizontal'
&& 'children' in child && Array.isArray(child.children)
&& child.children.length >= 2) {
const allButtonLike = child.children.every((gc) =>
gc.type === 'frame'
&& 'children' in gc && Array.isArray(gc.children)
&& gc.children.some((ggc) => ggc.type === 'text' || ggc.type === 'path'),
)
if (allButtonLike) {
// Row should fill parent width
;(child as unknown as Record<string, unknown>).width = 'fill_container'
// Individual buttons → fit_content so they hug their content
for (const btn of child.children) {
if (btn.type === 'frame' && typeof btn.width === 'number') {
;(btn as unknown as Record<string, unknown>).width = 'fit_content'
}
}
// Ensure the row has proper distribution
if (!child.justifyContent || child.justifyContent === 'start') {
child.justifyContent = 'center'
}
if (!child.gap || toGapNumber(child.gap) < 8) {
child.gap = 12
}
}
}
}
}
/**
* When children of a horizontal layout overflow the parent's width:
* 1. Try reducing gap first (minimal visual change)
* 2. Try fit_content on children (let them hug content)
* 3. Expand parent if still fixable
* 4. Switch parent to fill_container as last resort
*/
function applyHorizontalOverflowFix(node: PenNode): void {
if (node.type !== 'frame') return
if (node.layout !== 'horizontal') return
if (!Array.isArray(node.children) || node.children.length < 2) return
const parentW = toSizeNumber(node.width, 0)
// Skip if parent uses flex sizing — layout engine handles it
if (typeof node.width !== 'number' || parentW <= 0) return
const pad = parsePaddingValues('padding' in node ? node.padding : undefined)
const gap = toGapNumber('gap' in node ? node.gap : undefined)
const availW = parentW - pad.left - pad.right
// Sum up children's widths (estimate intrinsic width for each)
let childrenTotalW = 0
const fixedFrames: PenNode[] = []
for (const child of node.children) {
const cw = toSizeNumber('width' in child ? (child as { width?: number | string }).width : undefined, 0)
if ('width' in child && typeof (child as { width?: number | string }).width === 'number' && cw > 0) {
childrenTotalW += cw
if (child.type === 'frame') fixedFrames.push(child)
} else {
childrenTotalW += 80
}
}
const gapTotal = gap * (node.children.length - 1)
childrenTotalW += gapTotal
if (childrenTotalW <= availW) return // No overflow
// Strategy 1: Reduce gap to fit (try gap=8, then gap=4)
for (const tryGap of [8, 4]) {
if (gap > tryGap) {
const withReducedGap = childrenTotalW - gapTotal + tryGap * (node.children.length - 1)
if (withReducedGap <= availW) {
node.gap = tryGap
return
}
}
}
// Strategy 2: Convert fixed-width button children to fit_content
// This lets them shrink to their natural content width
if (fixedFrames.length >= 2) {
const heights = fixedFrames.map((c) => toSizeNumber('height' in c ? c.height : undefined, 0))
const maxH = Math.max(...heights, 0)
const minH = Math.min(...heights, Infinity)
// Only for button-like rows (similar height, short frames)
if (maxH > 0 && maxH <= 80 && minH / maxH > 0.5) {
for (const child of fixedFrames) {
;(child as unknown as Record<string, unknown>).width = 'fit_content'
}
// Also ensure proper distribution
if (!node.justifyContent || node.justifyContent === 'start') {
node.justifyContent = 'center'
}
if (gap < 8) node.gap = 8
return
}
}
// Strategy 3: Expand parent width to fit children
const neededW = Math.round(childrenTotalW + pad.left + pad.right)
if (neededW > parentW && neededW <= generationCanvasWidth) {
node.width = neededW
return
}
// Strategy 4: Parent is too narrow for expansion → fill_container
if (neededW > generationCanvasWidth * 0.8) {
node.width = 'fill_container' as unknown as number
}
}
/**
* After children are processed, expand frame height to fit content.
* Prevents card/container content clipping when AI-generated height is too small
@ -1454,6 +2044,111 @@ function getNodeMarker(node: PenNode): string {
return base.toLowerCase()
}
// ---------------------------------------------------------------------------
// Verified icon SVG paths (Lucide-style, 24×24 viewBox, stroke-based)
// These are auto-resolved by node name to avoid LLM hallucinating SVG paths.
// ---------------------------------------------------------------------------
const ICON_PATH_MAP: Record<string, { d: string; style: 'stroke' | 'fill' }> = {
menu: { d: 'M4 6h16M4 12h16M4 18h16', style: 'stroke' },
x: { d: 'M18 6L6 18M6 6l12 12', style: 'stroke' },
close: { d: 'M18 6L6 18M6 6l12 12', style: 'stroke' },
check: { d: 'M20 6L9 17l-5-5', style: 'stroke' },
plus: { d: 'M12 5v14M5 12h14', style: 'stroke' },
minus: { d: 'M5 12h14', style: 'stroke' },
search: { d: 'M11 19a8 8 0 100-16 8 8 0 000 16zM21 21l-4.35-4.35', style: 'stroke' },
arrowright: { d: 'M5 12h14M12 5l7 7-7 7', style: 'stroke' },
arrowleft: { d: 'M19 12H5M12 19l-7-7 7-7', style: 'stroke' },
arrowup: { d: 'M12 19V5M5 12l7-7 7 7', style: 'stroke' },
arrowdown: { d: 'M12 5v14M19 12l-7 7-7-7', style: 'stroke' },
chevronright: { d: 'M9 18l6-6-6-6', style: 'stroke' },
chevronleft: { d: 'M15 18l-6-6 6-6', style: 'stroke' },
chevrondown: { d: 'M6 9l6 6 6-6', style: 'stroke' },
chevronup: { d: 'M18 15l-6-6-6 6', style: 'stroke' },
star: { d: 'M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z', style: 'fill' },
heart: { d: 'M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z', style: 'stroke' },
home: { d: 'M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2V9zM9 22V12h6v10', style: 'stroke' },
user: { d: 'M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2M16 7a4 4 0 11-8 0 4 4 0 018 0z', style: 'stroke' },
settings: { d: 'M12.22 2h-.44a2 2 0 00-2 2v.18a2 2 0 01-1 1.73l-.43.25a2 2 0 01-2 0l-.15-.08a2 2 0 00-2.73.73l-.22.38a2 2 0 00.73 2.73l.15.1a2 2 0 011 1.72v.51a2 2 0 01-1 1.74l-.15.09a2 2 0 00-.73 2.73l.22.38a2 2 0 002.73.73l.15-.08a2 2 0 012 0l.43.25a2 2 0 011 1.73V20a2 2 0 002 2h.44a2 2 0 002-2v-.18a2 2 0 011-1.73l.43-.25a2 2 0 012 0l.15.08a2 2 0 002.73-.73l.22-.39a2 2 0 00-.73-2.73l-.15-.08a2 2 0 01-1-1.74v-.5a2 2 0 011-1.74l.15-.09a2 2 0 00.73-2.73l-.22-.38a2 2 0 00-2.73-.73l-.15.08a2 2 0 01-2 0l-.43-.25a2 2 0 01-1-1.73V4a2 2 0 00-2-2zM15 12a3 3 0 11-6 0 3 3 0 016 0z', style: 'stroke' },
gear: { d: 'M12.22 2h-.44a2 2 0 00-2 2v.18a2 2 0 01-1 1.73l-.43.25a2 2 0 01-2 0l-.15-.08a2 2 0 00-2.73.73l-.22.38a2 2 0 00.73 2.73l.15.1a2 2 0 011 1.72v.51a2 2 0 01-1 1.74l-.15.09a2 2 0 00-.73 2.73l.22.38a2 2 0 002.73.73l.15-.08a2 2 0 012 0l.43.25a2 2 0 011 1.73V20a2 2 0 002 2h.44a2 2 0 002-2v-.18a2 2 0 011-1.73l.43-.25a2 2 0 012 0l.15.08a2 2 0 002.73-.73l.22-.39a2 2 0 00-.73-2.73l-.15-.08a2 2 0 01-1-1.74v-.5a2 2 0 011-1.74l.15-.09a2 2 0 00.73-2.73l-.22-.38a2 2 0 00-2.73-.73l-.15.08a2 2 0 01-2 0l-.43-.25a2 2 0 01-1-1.73V4a2 2 0 00-2-2zM15 12a3 3 0 11-6 0 3 3 0 016 0z', style: 'stroke' },
mail: { d: 'M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2zm16 2l-10 7L2 6', style: 'stroke' },
phone: { d: 'M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07 19.5 19.5 0 01-6-6A19.79 19.79 0 012.12 4.18 2 2 0 014.11 2h3a2 2 0 012 1.72c.127.96.362 1.903.7 2.81a2 2 0 01-.45 2.11L8.09 9.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0122 16.92z', style: 'stroke' },
download: { d: 'M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3', style: 'stroke' },
upload: { d: 'M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12', style: 'stroke' },
play: { d: 'M5 3l14 9-14 9V3z', style: 'fill' },
pause: { d: 'M6 4h4v16H6zM14 4h4v16h-4z', style: 'fill' },
eye: { d: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8zM15 12a3 3 0 11-6 0 3 3 0 016 0z', style: 'stroke' },
lock: { d: 'M19 11H5a2 2 0 00-2 2v7a2 2 0 002 2h14a2 2 0 002-2v-7a2 2 0 00-2-2zM7 11V7a5 5 0 0110 0v4', style: 'stroke' },
shield: { d: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z', style: 'stroke' },
zap: { d: 'M13 2L3 14h9l-1 8 10-12h-9l1-8z', style: 'stroke' },
lightning: { d: 'M13 2L3 14h9l-1 8 10-12h-9l1-8z', style: 'stroke' },
bell: { d: 'M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9M13.73 21a2 2 0 01-3.46 0', style: 'stroke' },
sparkles: { d: 'M12 3l1.5 4.5L18 9l-4.5 1.5L12 15l-1.5-4.5L6 9l4.5-1.5L12 3zM5 19l1 3 1-3 3-1-3-1-1-3-1 3-3 1 3 1z', style: 'stroke' },
ai: { d: 'M12 3l1.5 4.5L18 9l-4.5 1.5L12 15l-1.5-4.5L6 9l4.5-1.5L12 3zM5 19l1 3 1-3 3-1-3-1-1-3-1 3-3 1 3 1z', style: 'stroke' },
globe: { d: 'M12 22a10 10 0 100-20 10 10 0 000 20zM2 12h20M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z', style: 'stroke' },
externallink: { d: 'M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3', style: 'stroke' },
copy: { d: 'M20 9h-9a2 2 0 00-2 2v9a2 2 0 002 2h9a2 2 0 002-2v-9a2 2 0 00-2-2zM5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1', style: 'stroke' },
trash: { d: 'M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2', style: 'stroke' },
edit: { d: 'M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z', style: 'stroke' },
filter: { d: 'M22 3H2l8 9.46V19l4 2v-8.54L22 3z', style: 'stroke' },
bookmark: { d: 'M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z', style: 'stroke' },
clock: { d: 'M12 22a10 10 0 100-20 10 10 0 000 20zM12 6v6l4 2', style: 'stroke' },
calendar: { d: 'M19 4H5a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2V6a2 2 0 00-2-2zM16 2v4M8 2v4M3 10h18', style: 'stroke' },
image: { d: 'M19 3H5a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2V5a2 2 0 00-2-2zM8.5 10a1.5 1.5 0 100-3 1.5 1.5 0 000 3zM21 15l-5-5L5 21', style: 'stroke' },
camera: { d: 'M23 19a2 2 0 01-2 2H3a2 2 0 01-2-2V8a2 2 0 012-2h4l2-3h6l2 3h4a2 2 0 012 2zM15.5 13a3.5 3.5 0 11-7 0 3.5 3.5 0 017 0z', style: 'stroke' },
chart: { d: 'M18 20V10M12 20V4M6 20v-6', style: 'stroke' },
barchart: { d: 'M18 20V10M12 20V4M6 20v-6', style: 'stroke' },
layers: { d: 'M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5', style: 'stroke' },
code: { d: 'M16 18l6-6-6-6M8 6l-6 6 6 6', style: 'stroke' },
terminal: { d: 'M4 17l6-6-6-6M12 19h8', style: 'stroke' },
share: { d: 'M18 8a3 3 0 100-6 3 3 0 000 6zM6 15a3 3 0 100-6 3 3 0 000 6zM18 22a3 3 0 100-6 3 3 0 000 6zM8.59 13.51l6.83 3.98M15.41 6.51l-6.82 3.98', style: 'stroke' },
send: { d: 'M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z', style: 'stroke' },
messageCircle: { d: 'M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z', style: 'stroke' },
info: { d: 'M12 22a10 10 0 100-20 10 10 0 000 20zM12 16v-4M12 8h.01', style: 'stroke' },
alertCircle: { d: 'M12 22a10 10 0 100-20 10 10 0 000 20zM12 8v4M12 16h.01', style: 'stroke' },
helpCircle: { d: 'M12 22a10 10 0 100-20 10 10 0 000 20zM9.09 9a3 3 0 015.83 1c0 2-3 3-3 3M12 17h.01', style: 'stroke' },
apple: { d: 'M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.81-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z', style: 'fill' },
googleplay: { d: 'M3 20.5V3.5c0-.85.54-1.23 1.09-.81L20 12 4.09 21.31c-.55.42-1.09.04-1.09-.81zM14.5 12L4 3.5M14.5 12L4 20.5M14.5 12l6-3.5M14.5 12l6 3.5', style: 'stroke' },
}
/**
* Resolve icon path nodes by their name. When the AI generates a path node
* with a name like "SearchIcon" or "MenuIcon", look up the verified SVG path
* from ICON_PATH_MAP and replace the d attribute.
*/
function applyIconPathResolution(node: PenNode): void {
if (node.type !== 'path') return
const name = (node.name ?? node.id ?? '').toLowerCase()
.replace(/[-_\s]+/g, '') // normalize separators
.replace(/icon$/, '') // strip trailing "icon"
const match = ICON_PATH_MAP[name]
if (!match) return
// Replace with verified path data
node.d = match.d
// Apply correct styling (stroke-based vs fill-based)
if (match.style === 'stroke') {
// Ensure stroke is set for line icons
if (!node.stroke) {
const existingColor = extractPrimaryColor('fill' in node ? node.fill : undefined)
node.stroke = {
thickness: 2,
fill: [{ type: 'solid', color: existingColor ?? '#64748B' }],
}
}
// Line icons should NOT have opaque fill (transparent to show stroke only)
if (node.fill && node.fill.length > 0) {
// Move fill color to stroke if stroke has no color
const fillColor = extractPrimaryColor(node.fill)
if (fillColor && node.stroke) {
node.stroke.fill = [{ type: 'solid', color: fillColor }]
}
node.fill = []
}
}
}
const EMOJI_REGEX = /[\p{Extended_Pictographic}\p{Emoji_Presentation}\uFE0F]/gu
const GENERIC_ICON_PATH = 'M12 3l2.6 5.27 5.82.84-4.2 4.09.99 5.8L12 16.9l-5.21 2.73.99-5.8-4.2-4.09 5.82-.84L12 3z'
@ -1514,13 +2209,13 @@ function applyTextWrappingHeuristic(node: PenNode): void {
// lineHeight is a MULTIPLIER (e.g., 1.45 means 145% of fontSize), NOT absolute pixels.
if (len >= (hasCjk ? 12 : 26) && fontSize <= 24) {
node.textGrowth = 'fixed-width'
if (!node.lineHeight) node.lineHeight = 1.45
if (!node.lineHeight) node.lineHeight = hasCjk ? 1.7 : 1.45
} else if (len >= (hasCjk ? 8 : 16) && fontSize > 24) {
node.textGrowth = 'fixed-width'
if (!node.lineHeight) node.lineHeight = 1.2
if (!node.lineHeight) node.lineHeight = hasCjk ? 1.4 : 1.2
}
// Auto Height: estimate a real height value (like Pencil's panel shows).
const lh = node.lineHeight ?? 1.2
const lh = node.lineHeight ?? (hasCjk ? (fontSize >= 28 ? 1.3 : 1.6) : 1.2)
node.height = estimateAutoHeight(text, fontSize, lh)
return
}
@ -1534,12 +2229,23 @@ function applyTextWrappingHeuristic(node: PenNode): void {
(looksBody && len >= bodyThreshold) || (looksHeading && len >= headingThreshold)
)
// Long text → Fill Width + Auto Height.
// Long text → wrap with auto height.
// Use fill_container only for long body text (paragraphs). For medium-length text
// (labels, subtitles), keep the existing width — the tree-aware heuristic
// applyTextFillContainerInLayout will convert to fill_container later only when
// the parent is NOT fit_content (hug). This prevents breaking hug layouts.
if (willWrap) {
node.textGrowth = 'fixed-width'
if (!node.lineHeight) node.lineHeight = looksBody ? 1.45 : 1.2
node.width = 'fill_container'
node.height = estimateAutoHeight(text, fontSize, node.lineHeight!)
if (!node.lineHeight) node.lineHeight = hasCjk
? (looksBody ? 1.7 : 1.4)
: (looksBody ? 1.45 : 1.2)
// Only force fill_container for clearly long text (>60 chars body or >30 chars heading)
const longBodyThreshold = hasCjk ? 30 : 60
const longHeadingThreshold = hasCjk ? 15 : 30
if ((looksBody && len >= longBodyThreshold) || (looksHeading && len >= longHeadingThreshold)) {
node.width = 'fill_container'
}
node.height = estimateAutoHeight(text, fontSize, node.lineHeight!, widthNum > 0 ? widthNum : undefined)
return
}
@ -1811,13 +2517,26 @@ function parsePaddingValues(
return { top: 0, right: 0, bottom: 0, left: 0 }
}
function estimateNodeIntrinsicHeight(node: PenNode): number {
function estimateNodeIntrinsicHeight(node: PenNode, parentContentWidth?: number): number {
const explicitHeight = toSizeNumber(('height' in node ? node.height : undefined) as number | string | undefined, 0)
const textHeight = node.type === 'text'
? Math.max(20, Math.round((node.fontSize ?? 16) * 1.4))
: 0
// For text nodes: use content-aware height estimation instead of single-line fallback
let textHeight = 0
if (node.type === 'text') {
const fs = node.fontSize ?? 16
const lh = node.lineHeight ?? (fs >= 28 ? 1.2 : 1.5)
if (typeof node.content === 'string' && node.content.trim() && node.textGrowth === 'fixed-width' && parentContentWidth && parentContentWidth > 0) {
// Re-estimate based on actual available width
textHeight = estimateAutoHeight(node.content.trim(), fs, lh, parentContentWidth)
} else {
textHeight = Math.max(20, Math.round(fs * lh))
}
}
if (!('children' in node) || !Array.isArray(node.children) || node.children.length === 0) {
// For text nodes, prefer content-based height over explicit (which may be stale)
if (node.type === 'text' && textHeight > 0) {
return Math.max(explicitHeight, textHeight)
}
return explicitHeight || textHeight || 80
}
@ -1826,10 +2545,14 @@ function estimateNodeIntrinsicHeight(node: PenNode): number {
const layout = 'layout' in node ? node.layout : undefined
const children = node.children
// Compute this node's content width for passing to text children
const nodeW = toSizeNumber(('width' in node ? node.width : undefined) as number | string | undefined, 0)
const childContentW = nodeW > 0 ? nodeW - padding.left - padding.right : 0
if (layout === 'vertical') {
let total = padding.top + padding.bottom
for (const child of children) {
total += estimateNodeIntrinsicHeight(child)
total += estimateNodeIntrinsicHeight(child, childContentW || undefined)
}
if (children.length > 1) {
total += gap * (children.length - 1)
@ -1838,9 +2561,18 @@ function estimateNodeIntrinsicHeight(node: PenNode): number {
}
if (layout === 'horizontal') {
// In horizontal layout, each child gets a fraction of the width
const childCount = children.length
const totalGap = childCount > 1 ? gap * (childCount - 1) : 0
const perChildW = childContentW > 0 && childCount > 0
? (childContentW - totalGap) / childCount
: 0
let maxChild = 0
for (const child of children) {
maxChild = Math.max(maxChild, estimateNodeIntrinsicHeight(child))
// Use child's explicit width if available, else distribute evenly
const childW = toSizeNumber(('width' in child ? child.width : undefined) as number | string | undefined, 0)
const effectiveW = childW > 0 ? childW : (perChildW > 0 ? perChildW : undefined)
maxChild = Math.max(maxChild, estimateNodeIntrinsicHeight(child, effectiveW))
}
const total = padding.top + padding.bottom + maxChild
return Math.max(explicitHeight, total)
@ -1849,7 +2581,7 @@ function estimateNodeIntrinsicHeight(node: PenNode): number {
let boundsBottom = 0
for (const child of children) {
const childY = typeof child.y === 'number' ? child.y : 0
const childBottom = childY + estimateNodeIntrinsicHeight(child)
const childBottom = childY + estimateNodeIntrinsicHeight(child, childContentW || undefined)
boundsBottom = Math.max(boundsBottom, childBottom)
}

View file

@ -100,21 +100,33 @@ export function prepareDesignPrompt(prompt: string): PreparedDesignPrompt {
)
}
conciseParts.push(
'Icon rule: never use emoji glyphs as icons; always use SVG path icon nodes.',
'Icon rule: never use emoji glyphs as icons; always use path nodes with descriptive icon names (e.g. "SearchIcon", "MenuIcon"). System auto-resolves to verified SVG paths.',
)
conciseParts.push(
'Scope guardrails: single marketing page, clear hierarchy, avoid over-detailed micro-content.',
'Scope guardrails: clear hierarchy, avoid over-detailed micro-content.',
)
conciseParts.push(
'Layout guardrails: keep a stable centered content width and avoid inserting extra full-width CTA stripes unless explicitly requested.',
)
conciseParts.push(
'Navbar guardrails: keep logo/links/CTA horizontally aligned; links should be evenly distributed in the center group.',
'Layout guardrails: keep a stable centered content width.',
)
// Only add landing-page-specific guardrails if the prompt looks like a landing page
const isLandingPage = /(?:landing\s*page|website|官网|首页|marketing)/i.test(normalized)
if (isLandingPage) {
conciseParts.push(
'CTA guardrails: avoid inserting extra full-width CTA stripes unless explicitly requested.',
)
conciseParts.push(
'Navbar guardrails: keep logo/links/CTA horizontally aligned; links should be evenly distributed in the center group.',
)
}
conciseParts.push(
'Typography guardrails: long subtitle/body text should use constrained width so lines wrap naturally.',
)
conciseParts.push(
'Overflow prevention: ALL text inside layout frames must use width="fill_container" (never fixed pixel widths). Buttons/badges with CJK text must be wide enough for character count × fontSize + padding.',
)
const concise = conciseParts.join('\n\n')
@ -130,23 +142,32 @@ export function prepareDesignPrompt(prompt: string): PreparedDesignPrompt {
export function buildFallbackPlanFromPrompt(prompt: string): OrchestratorPlan {
const labels = extractFallbackSectionLabels(prompt)
const sectionCount = Math.max(1, labels.length)
// Landing pages are tall scrollable documents — allocate more vertical space
const totalHeight = sectionCount >= 4 ? 4000 : 800
const isMobile = /(?:mobile|手机|phone|app\s*screen|登录|注册|login|signup)/i.test(prompt)
const isAppScreen = /(?:login|signup|register|登录|注册|settings|设置|profile|个人|form|表单|dashboard|modal|dialog)/i.test(prompt)
const width = isMobile ? 375 : 1200
// Mobile app screens: fixed 812px viewport. Desktop landing pages: auto-expand. Desktop app screens: fixed height.
const totalHeight = isMobile
? 812
: isAppScreen
? 800
: sectionCount >= 4 ? 4000 : 800
const heights = allocateSectionHeights(totalHeight, sectionCount)
return {
rootFrame: {
id: 'page',
name: 'Page',
width: 1200,
height: totalHeight,
width,
height: isMobile ? 812 : (isAppScreen ? totalHeight : 0),
layout: 'vertical',
fill: [{ type: 'solid', color: '#F8FAFC' }],
},
subtasks: labels.map((label, index) => ({
id: makeSafeSectionId(label, index),
label,
region: { width: 1200, height: heights[index] ?? 120 },
region: { width, height: heights[index] ?? 120 },
idPrefix: '',
parentFrameId: null,
})),
@ -279,13 +300,22 @@ function extractFallbackSectionLabels(prompt: string): string[] {
if (labels.length > 0) return labels
// Detect design type to provide appropriate fallback labels
const isAppScreen = /(?:login|signup|register|登录|注册|settings|设置|profile|个人|form|表单|dashboard|modal|dialog)/i.test(prompt)
if (isAppScreen) {
return [
'Header',
'Main Content',
'Actions',
]
}
return [
'Navigation',
'Hero',
'Core Highlights',
'Feature Showcase',
'Learning Plan',
'Download CTA',
'CTA',
'Footer',
]
}

View file

@ -3,23 +3,33 @@
* No design details, no prompt rewriting. Just structure.
*/
export const ORCHESTRATOR_PROMPT = `Split a UI request into 6-10 cohesive sections. Each subtask = a meaningful page section (e.g. hero with headline+CTA+image = ONE subtask). Output ONLY JSON, start with {.
export const ORCHESTRATOR_PROMPT = `Split a UI request into cohesive subtasks. Each subtask = a meaningful UI section or component group. Output ONLY JSON, start with {.
DESIGN TYPE DETECTION:
First determine the design type from the user's request:
- "landing page" / "website" / "官网" / "首页" Landing Page (multi-section scrollable page, 6-10 subtasks)
- "login" / "signup" / "register" / "登录" / "注册" App Screen (single screen, 1-4 subtasks)
- "dashboard" / "settings" / "profile" / "设置" / "个人中心" App Screen (single screen, 2-5 subtasks)
- "form" / "表单" / "checkout" / "结算" App Screen (single screen, 1-4 subtasks)
- Other app screens (modal, dialog, onboarding, etc.) App Screen (1-5 subtasks)
FORMAT:
{"rootFrame":{"id":"page","name":"Page","width":1200,"height":0,"layout":"vertical","fill":[{"type":"solid","color":"#0B1120"}]},"styleGuide":{"palette":{"background":"#0B1120","surface":"#141D33","text":"#F1F5F9","secondary":"#94A3B8","accent":"#3B82F6","accent2":"#06B6D4","border":"#1E293B"},"fonts":{"heading":"Space Grotesk","body":"Inter"},"aesthetic":"dark navy with blue gradient accents"},"subtasks":[{"id":"nav","label":"Navigation Bar","region":{"width":1200,"height":72}},{"id":"hero","label":"Hero Section","region":{"width":1200,"height":560}},{"id":"features","label":"Feature Cards","region":{"width":1200,"height":480}}]}
RULES:
- ALWAYS include a Navigation Bar as the FIRST subtask.
- Detect the design type FIRST, then choose the appropriate structure and subtask count.
- Landing pages: include Navigation Bar as the FIRST subtask, followed by Hero, feature sections, CTA, footer, etc. (6-10 subtasks)
- App screens (login, settings, forms, etc.): do NOT include Navigation Bar, Hero, CTA, or footer. Only include the actual UI elements needed (1-5 subtasks).
- Combine related elements: "Hero with title + image + CTA" = ONE subtask, not three.
- Each subtask generates a meaningful section (~10-30 nodes). Only split if it would exceed 40 nodes.
- Choose a visual direction (palette, fonts, aesthetic) that matches the product personality and target audience. Output it in "styleGuide".
- CJK FONT RULE: If the user's request is in Chinese/Japanese/Korean or the product targets CJK audiences, the styleGuide fonts MUST use CJK-compatible fonts: heading="Noto Sans SC" (Chinese) / "Noto Sans JP" (Japanese) / "Noto Sans KR" (Korean), body="Inter". NEVER use "Space Grotesk" or "Manrope" as heading font for CJK content they have no CJK character support.
- Root frame fill must use the styleGuide palette background color.
- Root frame height: Mobile (width=375) set height=812 (fixed viewport). Desktop (width=1200) set height=0 (auto-expands as sections are generated).
- Subtask height hints: nav 64-80px, hero 500-600px, feature sections 400-600px, testimonials 300-400px, CTA 200-300px, footer 200-300px.
- Landing page height hints: nav 64-80px, hero 500-600px, feature sections 400-600px, testimonials 300-400px, CTA 200-300px, footer 200-300px.
- App screen height hints: status bar 44px, header 56-64px, form fields 48-56px each, buttons 48px, spacing 16-24px.
- If a section is about "App截图"/"XX截图"/"screenshot"/"mockup", plan it as a phone mockup placeholder block, not a detailed mini-app reconstruction.
- Navigation sections should preserve good horizontal balance.
- Ensure navigation links are planned as an evenly distributed middle group.
- Desktop landing pages MUST always include a Navigation Bar as the FIRST subtask.
- For landing pages: navigation sections should preserve good horizontal balance, links evenly distributed in the center group.
- Regions tile to fill rootFrame. vertical = top-to-bottom.
- Mobile: 375x812 (both width AND height are fixed). Desktop: 1200x0 (width fixed, height auto-expands).
- NO explanation. NO markdown. JUST the JSON object.`
@ -52,6 +62,8 @@ CRITICAL LAYOUT RULES (violations cause rendering bugs):
- NEVER set x or y on ANY child inside a layout frame. The layout engine positions them automatically.
- ALL nodes must be descendants of the section root. No orphan/floating nodes.
- CHILD SIZE RULE: every child's width must be ≤ parent's content area. Use "fill_container" when in doubt.
- WIDTH CONSISTENCY: siblings in a vertical layout must use the SAME width strategy. If one input/button uses "fill_container", ALL inputs/buttons in that container must also use "fill_container". Mixing fixed-px and fill_container causes misalignment.
- NEVER use "fill_container" on children of a "fit_content" parent this creates a circular dependency and breaks layout.
- CLIP CONTENT: set clipContent: true on cards with cornerRadius + image children. Prevents overflow past rounded corners.
- FLEX LAYOUT: use justifyContent to distribute children:
"space_between" = push first/last to edges, even space between (BEST for navbars: logo | links | CTA).
@ -61,9 +73,36 @@ CRITICAL LAYOUT RULES (violations cause rendering bugs):
- PADDING: number (uniform), [vertical, horizontal] (e.g. [0, 80] for side padding), or [top, right, bottom, left].
- For two-column layouts: horizontal frame with two child frames, each "fill_container" width.
- For centered content: frame with alignItems="center", then content frame with fixed width (e.g. 1080).
- TEXT IN LAYOUTS: text inside layout frames MUST use textGrowth="fixed-width" + width="fill_container". This makes text wrap within the parent and height auto-sizes. NEVER use fixed pixel widths/heights for text.
- SHORT TEXT: buttons/labels can omit textGrowth (defaults to "auto" = expands horizontally).
TEXT RULES (the #1 most common bug source MUST follow):
TEXT WIDTH:
- ALL text nodes inside a layout frame width="fill_container" + textGrowth="fixed-width". NO EXCEPTIONS.
- NEVER output a text node with a fixed pixel width (width:224, width:378, width:784 etc.) inside a layout frame. This causes the text to overflow horizontally and break the design.
- The ONLY time text can have a fixed pixel width is when it is NOT inside a layout frame (layout="none" parent).
- BAD example (causes overflow): parent card width=195, padding=[24,40,24,40] (available=115px), child text width=378 text overflows by 263px!
- GOOD example: same parent card, child text width="fill_container" auto-constrained to 115px, wraps correctly.
TEXT WRAPPING (textGrowth):
- Any text content longer than ~15 characters MUST have textGrowth="fixed-width". Without it, text expands horizontally in a single line and overflows.
- textGrowth="fixed-width" makes the text WRAP within its width and auto-size its height. This is required for descriptions, paragraphs, subtitles, and any multi-word text.
- ONLY omit textGrowth for very short labels (1-3 words) like button text "Submit", nav links, or badge labels.
- BAD: {"type":"text","content":"PolarWords 的 AI 助记系统为每个单词生成专属记忆方案...","fontSize":16,"width":"fill_container"} text renders as ONE long line, overflows!
- GOOD: {"type":"text","content":"PolarWords 的 AI 助记系统为每个单词生成专属记忆方案...","fontSize":16,"width":"fill_container","textGrowth":"fixed-width","lineHeight":1.6} text wraps within parent, height auto-sizes.
TEXT HEIGHT (the #2 most common bug causes overlap):
- NEVER set explicit pixel height on text nodes (e.g. height:22, height:44). OMIT the height property entirely on text.
- The layout engine auto-calculates text height from textGrowth + content. An explicit small height clips the text and causes it to overlap with siblings below.
- BAD: {"type":"text","content":"50,000+","fontSize":36,"height":22} 22px is way too small for 36px font, text overlaps next element!
- GOOD: {"type":"text","content":"50,000+","fontSize":36,"width":"fill_container"} engine auto-sizes height to ~43px, no overlap.
- This applies to ALL text nodes: headings, body, labels, numbers, captions. Never set height on text.
CARD ROW ALIGNMENT:
- When cards are siblings in a horizontal layout, ALL cards MUST use height="fill_container". This makes all cards match the tallest card's height, creating a visually aligned row.
- BAD: 5 cards in horizontal row, each with different fixed heights uneven, ugly row.
- GOOD: 5 cards in horizontal row, each with height="fill_container" all same height, clean alignment.
- Card content (icon + title + description) should use width="fill_container" on text nodes so text wraps within the card.
FORMAT: Each line has "_parent" (null=root, else parent-id). Parent before children.
${BLOCK}json
{"_parent":null,"id":"root","type":"frame","name":"Hero","width":"fill_container","height":"fit_content","layout":"vertical","padding":80,"gap":24,"alignItems":"center","fill":[{"type":"solid","color":"#0B1120"}]}
@ -77,16 +116,38 @@ ${BLOCK}json
${BLOCK}
DESIGN RULES:
- Hero: large headline (40-56px), gradient or bold backgrounds, clear CTA, generous whitespace
- Visual rhythm: alternate section backgrounds for separation
- Typography: Display 40-56px Heading 28-36px Subheading 20-24px Body 16-18px Caption 13-14px. Always set lineHeight: headings 1.1-1.2, body 1.4-1.6, captions 1.3. Use letterSpacing: -0.5 for large headlines, 0.5-2 for uppercase labels.
- Cards: give cards enough height for their content. A card with icon+title+description needs at least 160-200px height. Use "fill_container" height when cards are in a row. ALWAYS set clipContent: true on cards with cornerRadius + image children.
- CTAs: bold accent color, padding 16-20px v / 32-48px h
- Icons: SVG path nodes 16-24px; NEVER use emoji
- CJK FONTS: When content is in Chinese/Japanese/Korean, use CJK-compatible fonts "Noto Sans SC" for headings, "Inter" or "Noto Sans SC" for body. NEVER use "Space Grotesk" or "Manrope" for CJK text (they have no CJK glyphs). CJK lineHeight: 1.3-1.4 for headings, 1.6-1.8 for body. CJK letterSpacing: 0 for body, never negative.
- Cards in horizontal rows: ALL cards MUST use width="fill_container" + height="fill_container" for even distribution and equal height. Never use fixed pixel width/height on cards in a row. A card with icon+title+description needs at least 160-200px content height the row will auto-size. ALWAYS set clipContent: true on cards with cornerRadius + image children.
- Icons: "path" nodes with descriptive names (e.g. "SearchIcon", "MenuIcon", "ArrowRightIcon", "StarIcon", "ShieldIcon", "ZapIcon"). System auto-resolves to verified SVG paths. Size 16-24px. NEVER use emoji.
- PHONE MOCKUP: exactly ONE "frame" node, width 260-300, height 520-580, cornerRadius 32, solid fill + 1px stroke. NEVER use ellipse or circle for mockups. NEVER add any children inside (no text, no frames, no images). Every phone mockup must look identical.
- NEVER use ellipse nodes for decorative shapes. Use frame or rectangle with cornerRadius instead.
- Use STYLE GUIDE colors/fonts from user prompt consistently. Do not introduce random colors.
- Nav bar: use justifyContent="space_between" with 3 child groups (logo-group | links-group | cta-button). padding=[0,80]. This auto-distributes them perfectly.
- TEXT: text inside layout frames MUST use textGrowth="fixed-width" + width="fill_container". NEVER set fixed pixel widths/heights on text textGrowth handles auto-sizing. Short labels/buttons can omit textGrowth.
- TEXT: text inside layout frames MUST use textGrowth="fixed-width" + width="fill_container". NEVER set fixed pixel width on text. NEVER set height on text omit it entirely, the engine auto-sizes. Short labels/buttons can omit textGrowth.
- BUTTONS: height 44-52px, padding [12, 24] minimum. With icon+text: layout="horizontal", gap=8, alignItems="center". Sizing: "fill_container" (stretch), "fit_content" (hug content), or fixed px choose per context.
- CJK BUTTONS (Chinese/Japanese/Korean text): each CJK character renders ~1.0× fontSize wide. For "免费下载" (4 chars) at fontSize 15: content needs ~60px width button needs 60 + horizontal padding (e.g. padding [8,22] = 44px total 104px minimum). ALWAYS calculate: button width charCount × fontSize + totalHorizontalPadding.
- ICON-ONLY BUTTONS (heart, bookmark, share, etc.): square frame, minimum 44x44px, justifyContent="center", alignItems="center". Path icon inside 20-24px.
- BADGES/TAGS (e.g. "NEW", "SALE", "PRO"): frame with padding [4, 12] minimum, cornerRadius 4-6, height="fit_content". For badges with CJK or long text, use width="fit_content" so badge auto-sizes. Text inside must NOT be clipped use short fontSize (11-13px), no textGrowth needed.
- BUTTON + ICON-BUTTON ROW: horizontal frame, gap=8-12. Primary button width="fill_container" to take remaining space; icon-only button fixed square (44-48px).
- LANDING PAGE SECTIONS (only when designing landing pages/websites):
- Hero: large headline (40-56px), gradient or bold backgrounds, clear CTA, generous whitespace
- Visual rhythm: alternate section backgrounds for separation
- CTAs: bold accent color, padding [16, 32] minimum
- Nav bar: use justifyContent="space_between" with 3 child groups (logo-group | links-group | cta-button). padding=[0,80]. This auto-distributes them perfectly.
- APP SCREENS (login, settings, forms, dashboards, etc.):
- Focus on the screen's core functionality, avoid unnecessary decorative sections.
- Form inputs: consistent height (48-56px), clear labels, proper spacing (16-24px gap). Use width="fill_container" for inputs so they align with parent width.
- Primary/submit buttons: use width="fill_container" to match the form width.
- Button rows (social login etc.): wrap in horizontal frame with width="fill_container", gap=12. Each button uses width="fit_content" (hug content) so they stay compact. Use justifyContent="center" or "space_between" to distribute.
- Fixed-width children must NOT exceed their parent's content area (parent width minus padding).
SELF-CHECK before finishing (mentally verify these):
1. Every text node inside a layout frame has width="fill_container" + textGrowth="fixed-width"? (not a fixed pixel width, not missing textGrowth)
2. Every text with content > 15 chars has textGrowth="fixed-width"? (without it, text won't wrap and will overflow)
3. NO text node has an explicit pixel height (height:22, height:44 etc.)? Text height must be OMITTED engine auto-sizes it.
4. Cards in horizontal rows all use width="fill_container" + height="fill_container"? (ensures equal distribution and height)
5. Every button/badge with CJK text has enough width for its characters + padding?
6. No child has a fixed pixel width exceeding its parent's available content area?
7. If content is CJK: using "Noto Sans SC" (not "Space Grotesk") for headings, and lineHeight 1.3 for headings, 1.6 for body?
Start with ${BLOCK}json immediately. No preamble, no <step> tags.`

View file

@ -81,7 +81,7 @@ export async function exportKit(
kitDoc.themes = structuredClone(sourceDoc.themes)
}
const fileName = `${kitName.replace(/[^a-zA-Z0-9-_ ]/g, '').trim() || 'kit'}.pen`
const fileName = `${kitName.replace(/[^a-zA-Z0-9-_ ]/g, '').trim() || 'kit'}.op`
if (supportsFileSystemAccess()) {
const result = await saveDocumentAs(kitDoc, fileName)
@ -111,8 +111,8 @@ async function pickFileSystemAccess(): Promise<PenDocument | null> {
).showOpenFilePicker({
types: [
{
description: 'Pen Design File',
accept: { 'application/json': ['.pen', '.json'] },
description: 'OpenPencil File',
accept: { 'application/json': ['.op', '.pen', '.json'] },
},
],
})
@ -128,7 +128,7 @@ function pickFallback(): Promise<PenDocument | null> {
return new Promise((resolve) => {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.pen,.json'
input.accept = '.op,.pen,.json'
input.onchange = async () => {
const file = input.files?.[0]
if (!file) { resolve(null); return }

View file

@ -34,11 +34,11 @@ export async function saveDocumentAs(
showSaveFilePicker: (opts: unknown) => Promise<FileSystemFileHandle>
}
).showSaveFilePicker({
suggestedName: suggestedName || 'untitled.pen',
suggestedName: suggestedName || 'untitled.op',
types: [
{
description: 'Pen Design File',
accept: { 'application/json': ['.pen'] },
description: 'OpenPencil File',
accept: { 'application/json': ['.op'] },
},
],
})
@ -66,8 +66,8 @@ export async function openDocumentFS(): Promise<{
).showOpenFilePicker({
types: [
{
description: 'Pen Design File',
accept: { 'application/json': ['.pen', '.json'] },
description: 'OpenPencil File',
accept: { 'application/json': ['.op', '.pen', '.json'] },
},
],
})
@ -108,7 +108,7 @@ export function openDocument(): Promise<{
return new Promise((resolve) => {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.pen,.json'
input.accept = '.op,.pen,.json'
input.onchange = async () => {
const file = input.files?.[0]
if (!file) {