mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-06-01 03:14:29 +07:00
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:
parent
5bc192e451
commit
e0d8e4dea8
24 changed files with 2689 additions and 114 deletions
147
bun.lock
147
bun.lock
|
|
@ -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=="],
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
195
server/api/ai/mcp-install.ts
Normal file
195
server/api/ai/mcp-install.ts
Normal 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
|
||||
}
|
||||
})
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
75
src/mcp/document-manager.ts
Normal file
75
src/mcp/document-manager.ts
Normal 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
274
src/mcp/server.ts
Normal 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)
|
||||
})
|
||||
441
src/mcp/tools/batch-design.ts
Normal file
441
src/mcp/tools/batch-design.ts
Normal 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
|
||||
}
|
||||
|
||||
89
src/mcp/tools/batch-get.ts
Normal file
89
src/mcp/tools/batch-get.ts
Normal 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)),
|
||||
}
|
||||
}
|
||||
64
src/mcp/tools/find-empty-space.ts
Normal file
64
src/mcp/tools/find-empty-space.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
57
src/mcp/tools/open-document.ts
Normal file
57
src/mcp/tools/open-document.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
44
src/mcp/tools/snapshot-layout.ts
Normal file
44
src/mcp/tools/snapshot-layout.ts
Normal 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 }
|
||||
}
|
||||
40
src/mcp/tools/variables.ts
Normal file
40
src/mcp/tools/variables.ts
Normal 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
5
src/mcp/utils/id.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { nanoid } from 'nanoid'
|
||||
|
||||
export function generateId(): string {
|
||||
return nanoid()
|
||||
}
|
||||
250
src/mcp/utils/node-operations.ts
Normal file
250
src/mcp/utils/node-operations.ts
Normal 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[]
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.`
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue