Merge remote-tracking branch 'origin/master' into add-vitepress-docs

This commit is contained in:
Anton A S 2026-03-01 12:26:10 +03:00
commit fbb397ad45
23 changed files with 2112 additions and 91 deletions

19
PLAN.md
View file

@ -1368,13 +1368,18 @@ interface CommentPin {
#### Implementation order
1. JSX renderer in `@open-pencil/core` (port from figma-use)
2. Chat panel UI — `ChatPanel.vue`, `useChat()`, message list, tool timeline, `⌘J` toggle
3. API key settings — input UI, Stronghold storage (Tauri) / localStorage (browser)
4. Core tools — render, set_fill, set_layout, set_text, snapshot, get_selection
5. Screenshot + diff tools
6. Comment pin system
7. Full tool set (remaining 118 tools)
1. ✅ JSX renderer in `@open-pencil/core` (port from figma-use)
2. ✅ Chat panel UI — `ChatPanel.vue`, `useChat()`, message list, tool timeline, `⌘J` toggle
3. Code panel — "Code" tab in properties panel, JSX export with syntax highlighting
- `sceneNodeToJsx()` in `packages/core/src/render/export-jsx.ts` (reverse of renderer.ts)
- `CodePanel.vue` with Prism.js highlighting, copy button, reka-ui ScrollArea
- Third tab in PropertiesPanel: Design | Code | AI
- Subtree export: full JSX tree for selection, multi-selection separated by blank lines
4. API key settings — input UI, Stronghold storage (Tauri) / localStorage (browser)
5. Core tools — render, set_fill, set_layout, set_text, snapshot, get_selection
6. Screenshot + diff tools
7. Comment pin system
8. Full tool set (remaining 118 tools)
#### Validation

171
bun.lock
View file

@ -5,8 +5,12 @@
"": {
"name": "open-pencil-app",
"dependencies": {
"@ai-sdk/openai": "^3.0.37",
"@ai-sdk/valibot": "^2.0.17",
"@ai-sdk/vue": "^3.0.105",
"@open-pencil/cli": "workspace:*",
"@open-pencil/core": "workspace:*",
"@openrouter/ai-sdk-provider": "^2.2.3",
"@tailwindcss/vite": "^4.2.1",
"@tanstack/vue-table": "^8.21.3",
"@tauri-apps/api": "^2",
@ -14,13 +18,18 @@
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-opener": "^2",
"@vueuse/core": "^14.2.1",
"ai": "^6.0.105",
"canvaskit-wasm": "^0.40.0",
"culori": "^4.0.2",
"dedent": "^1.7.1",
"fflate": "^0.8.2",
"fzstd": "^0.1.1",
"prismjs": "^1.30.0",
"reka-ui": "^2.8.2",
"tailwindcss": "^4.2.1",
"valibot": "^1.2.0",
"vue": "^3.5.29",
"vue-stream-markdown": "^0.6.0",
"yoga-layout": "^3.2.1",
},
"devDependencies": {
@ -29,12 +38,14 @@
"@tauri-apps/cli": "^2",
"@types/culori": "^4.0.1",
"@types/node": "^25.3.2",
"@types/prismjs": "^1.26.6",
"@typescript/native-preview": "^7.0.0-dev.20260228.1",
"@vitejs/plugin-vue": "^6.0.4",
"esbuild": "^0.27.3",
"jscpd": "^4.0.8",
"oxfmt": "^0.35.0",
"oxlint": "^1.50.0",
"tw-animate-css": "^1.4.0",
"typescript": "~5.8.3",
"unplugin-icons": "^23.0.1",
"unplugin-vue-components": "^31.0.0",
@ -75,6 +86,18 @@
},
},
"packages": {
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.59", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.16", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MbtheWHgEFV/8HL1Z6E3hOAsmP73zZlNFg0F0nJAD0Adnjp4J/plqNK00Y896d+dWTw+r0OXzyov9/2wCFjH0Q=="],
"@ai-sdk/openai": ["@ai-sdk/openai@3.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.16" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bcYjT3/58i/C0DN3AnrjiGsAb0kYivZLWWUtgTjsBurHSht/LTEy+w3dw5XQe3FmZwX7Z/mUQCiA3wB/5Kf7ow=="],
"@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.16", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kBvDqNkt5EwlzF9FujmNhhtl8FYg3e8FO8P5uneKliqfRThWemzBj+wfYr7ZCymAQhTRnwSSz1/SOqhOAwmx9g=="],
"@ai-sdk/valibot": ["@ai-sdk/valibot@2.0.17", "", { "dependencies": { "@ai-sdk/provider-utils": "4.0.16" }, "peerDependencies": { "@valibot/to-json-schema": "^1.3.0", "valibot": "^1.1.0" } }, "sha512-4/kEvGE7JaDGPIMVyrEq1by483kiSpbzhsHoQfi3K5ScbEs1dHXdnWr8P9eGyFBHyQi0uNPTqjnQGa2K4FSWsg=="],
"@ai-sdk/vue": ["@ai-sdk/vue@3.0.105", "", { "dependencies": { "@ai-sdk/provider-utils": "4.0.16", "ai": "6.0.105", "swrv": "^1.0.4" }, "peerDependencies": { "vue": "^3.3.4" } }, "sha512-qdf4G6p8lubBrDKw9u6gG7KJft+J7A/xqk0WRsWw+KAJkG+TgziaK4a+/5amuhOQnvnQL/NFlmQHfYnHzM2KMA=="],
"@algolia/abtesting": ["@algolia/abtesting@1.15.1", "", { "dependencies": { "@algolia/client-common": "5.49.1", "@algolia/requester-browser-xhr": "5.49.1", "@algolia/requester-fetch": "5.49.1", "@algolia/requester-node-http": "5.49.1" } }, "sha512-2yuIC48rUuHGhU1U5qJ9kJHaxYpJ0jpDHJVI5ekOxSMYXlH4+HP+pA31G820lsAznfmu2nzDV7n5RO44zIY1zw=="],
"@algolia/autocomplete-core": ["@algolia/autocomplete-core@1.17.7", "", { "dependencies": { "@algolia/autocomplete-plugin-algolia-insights": "1.17.7", "@algolia/autocomplete-shared": "1.17.7" } }, "sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q=="],
@ -231,6 +254,10 @@
"@open-pencil/core": ["@open-pencil/core@workspace:packages/core"],
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.2.3", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-NovC+BaCfEeJwhToDrs8JeDYXXlJdEyz7lcxkjtyePSE4eoAKik872SyDK0MzXKcz8MRkv7XlNhPI6zz4TQp0g=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.35.0", "", { "os": "android", "cpu": "arm" }, "sha512-BaRKlM3DyG81y/xWTsE6gZiv89F/3pHe2BqX2H4JbiB8HNVlWWtplzgATAE5IDSdwChdeuWLDTQzJ92Lglw3ZA=="],
"@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.35.0", "", { "os": "android", "cpu": "arm64" }, "sha512-/O+EbuAJYs6nde/anv+aID6uHsGQApyE9JtYBo/79KyU8e6RBN3DMbT0ix97y1SOnCglurmL2iZ+hlohjP2PnQ=="],
@ -377,6 +404,8 @@
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@swc/helpers": ["@swc/helpers@0.5.19", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA=="],
"@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="],
@ -453,10 +482,14 @@
"@types/culori": ["@types/culori@4.0.1", "", {}, "sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ=="],
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
"@types/katex": ["@types/katex@0.16.8", "", {}, "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg=="],
"@types/linkify-it": ["@types/linkify-it@5.0.0", "", {}, "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="],
"@types/markdown-it": ["@types/markdown-it@14.1.2", "", { "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" } }, "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog=="],
@ -465,8 +498,12 @@
"@types/mdurl": ["@types/mdurl@2.0.0", "", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="],
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/node": ["@types/node@25.3.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q=="],
"@types/prismjs": ["@types/prismjs@1.26.6", "", {}, "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="],
"@types/sarif": ["@types/sarif@2.1.7", "", {}, "sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
@ -491,6 +528,10 @@
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
"@valibot/to-json-schema": ["@valibot/to-json-schema@1.5.0", "", { "peerDependencies": { "valibot": "^1.2.0" } }, "sha512-GE7DmSr1C2UCWPiV0upRH6mv0cCPsqYGs819fb6srCS1tWhyXrkGGe+zxUiwzn/L1BOfADH4sNjY/YHCuP8phQ=="],
"@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.4", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.2" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0", "vue": "^3.2.25" } }, "sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ=="],
"@vue/compiler-core": ["@vue/compiler-core@3.5.29", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/shared": "3.5.29", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw=="],
@ -531,6 +572,8 @@
"agentfmt": ["agentfmt@0.1.3", "", { "dependencies": { "picocolors": "^1.1.1" } }, "sha512-BVSZr8ovigxjYewuBx42Ep4RJj00HURaQYUQ+id41sgb7r0PMUSEWsnD/+h96xj6t6v2WVyPuGPH0K6RtjftKQ=="],
"ai": ["ai@6.0.105", "", { "dependencies": { "@ai-sdk/gateway": "3.0.59", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.16", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rp+exWtZS3J0DDvZIfetpKCIg7D3cCsvBPoFN3I67IDTs9aoBZDbpecoIkmNLT+U9RBkoEial3OGHRvme23HCw=="],
"algoliasearch": ["algoliasearch@5.49.1", "", { "dependencies": { "@algolia/abtesting": "1.15.1", "@algolia/client-abtesting": "5.49.1", "@algolia/client-analytics": "5.49.1", "@algolia/client-common": "5.49.1", "@algolia/client-insights": "5.49.1", "@algolia/client-personalization": "5.49.1", "@algolia/client-query-suggestions": "5.49.1", "@algolia/client-search": "5.49.1", "@algolia/ingestion": "1.49.1", "@algolia/monitoring": "1.49.1", "@algolia/recommend": "5.49.1", "@algolia/requester-browser-xhr": "5.49.1", "@algolia/requester-fetch": "5.49.1", "@algolia/requester-node-http": "5.49.1" } }, "sha512-X3Pp2aRQhg4xUC6PQtkubn5NpRKuUPQ9FPDQlx36SmpFwwH2N0/tw4c+NXV3nw3PsgeUs+BuWGP0gjz3TvENLQ=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
@ -563,6 +606,8 @@
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
@ -595,6 +640,12 @@
"culori": ["culori@4.0.2", "", {}, "sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
"dedent": ["dedent@1.7.1", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg=="],
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
@ -625,10 +676,14 @@
"esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
"escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
"execa": ["execa@4.1.0", "", { "dependencies": { "cross-spawn": "^7.0.0", "get-stream": "^5.0.0", "human-signals": "^1.1.1", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.0", "onetime": "^5.1.0", "signal-exit": "^3.0.2", "strip-final-newline": "^2.0.0" } }, "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA=="],
"exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
@ -637,6 +692,8 @@
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
"fault": ["fault@2.0.1", "", { "dependencies": { "format": "^0.2.0" } }, "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
@ -645,6 +702,8 @@
"focus-trap": ["focus-trap@7.8.0", "", { "dependencies": { "tabbable": "^6.4.0" } }, "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA=="],
"format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="],
"fs-extra": ["fs-extra@11.3.3", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
@ -653,6 +712,8 @@
"fzstd": ["fzstd@0.1.1", "", {}, "sha512-dkuVSOKKwh3eas5VkJy1AW1vFpet8TA/fGmVA5krThl8YcOVE/8ZIoEA1+U1vEn5ckxxhLirSdY837azmbaNHA=="],
"get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
@ -713,10 +774,14 @@
"jscpd-sarif-reporter": ["jscpd-sarif-reporter@4.0.6", "", { "dependencies": { "colors": "^1.4.0", "fs-extra": "^11.2.0", "node-sarif-builder": "^3.4.0" } }, "sha512-b9Sm3IPZ3+m8Lwa4gZa+4/LhDhlc/ZLEsLXKSOy1DANQ6kx0ueqZT+fUHWEdQ6m0o3+RIVIa7DmvLSojQD05ng=="],
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
"jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
"jstransformer": ["jstransformer@1.0.0", "", { "dependencies": { "is-promise": "^2.0.0", "promise": "^7.0.1" } }, "sha512-C9YK3Rf8q6VAPDCCU9fnqo3mAfOH6vUGnMcP4AQAYIEpWtfGLpwOTmZ+igtdK5y+VvI2n3CyYSzy4Qh34eq24A=="],
"katex": ["katex@0.16.33", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-q3N5u+1sY9Bu7T4nlXoiRBXWfwSefNGoKeOwekV+gw0cAXQlz2Ww6BLcmBxVDeXBMUDQv6fK5bcNaJLxob3ZQA=="],
"lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="],
@ -743,26 +808,112 @@
"local-pkg": ["local-pkg@1.1.2", "", { "dependencies": { "mlly": "^1.7.4", "pkg-types": "^2.3.0", "quansync": "^0.2.11" } }, "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A=="],
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"mark.js": ["mark.js@8.11.1", "", {}, "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ=="],
"markdown-table": ["markdown-table@2.0.0", "", { "dependencies": { "repeat-string": "^1.0.0" } }, "sha512-Ezda85ToJUBhM6WGaG6veasyym+Tbs3cMAw/ZhOPqXiYsr0jgocBV3j3nx+4lk47plLlIqjwuTm/ywVI+zjJ/A=="],
"marked": ["marked@17.0.3", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-jt1v2ObpyOKR8p4XaUJVk3YWRJ5n+i4+rjQopxvV32rSndTJXvIzuUdWWIy/1pFQMkQmvTXawzDNqOH/CUmx6A=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="],
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="],
"mdast-util-frontmatter": ["mdast-util-frontmatter@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "escape-string-regexp": "^5.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-extension-frontmatter": "^2.0.0" } }, "sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA=="],
"mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="],
"mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="],
"mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="],
"mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="],
"mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="],
"mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="],
"mdast-util-math": ["mdast-util-math@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "longest-streak": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.1.0", "unist-util-remove-position": "^5.0.0" } }, "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w=="],
"mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
"mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
"micromark-extension-cjk-friendly": ["micromark-extension-cjk-friendly@2.0.1", "", { "dependencies": { "devlop": "^1.1.0", "micromark-extension-cjk-friendly-util": "3.0.1", "micromark-util-chunked": "^2.0.1", "micromark-util-resolve-all": "^2.0.1", "micromark-util-symbol": "^2.0.1" }, "peerDependencies": { "micromark": "^4.0.0", "micromark-util-types": "^2.0.0" }, "optionalPeers": ["micromark-util-types"] }, "sha512-OkzoYVTL1ChbvQ8Cc1ayTIz7paFQz8iS9oIYmewncweUSwmWR+hkJF9spJ1lxB90XldJl26A1F4IkPOKS3bDXw=="],
"micromark-extension-cjk-friendly-gfm-strikethrough": ["micromark-extension-cjk-friendly-gfm-strikethrough@2.0.1", "", { "dependencies": { "devlop": "^1.1.0", "get-east-asian-width": "^1.4.0", "micromark-extension-cjk-friendly-util": "3.0.1", "micromark-util-character": "^2.1.1", "micromark-util-chunked": "^2.0.1", "micromark-util-resolve-all": "^2.0.1", "micromark-util-symbol": "^2.0.1" }, "peerDependencies": { "micromark": "^4.0.0", "micromark-util-types": "^2.0.0" }, "optionalPeers": ["micromark-util-types"] }, "sha512-wVC0zwjJNqQeX+bb07YTPu/CvSAyCTafyYb7sMhX1r62/Lw5M/df3JyYaANyp8g15c1ypJRFSsookTqA1IDsUg=="],
"micromark-extension-cjk-friendly-util": ["micromark-extension-cjk-friendly-util@3.0.1", "", { "dependencies": { "get-east-asian-width": "^1.4.0", "micromark-util-character": "^2.1.1", "micromark-util-symbol": "^2.0.1" } }, "sha512-GcbXqTTHOsiZHyF753oIddP/J2eH8j9zpyQPhkof6B2JNxfEJabnQqxbCgzJNuNes0Y2jTNJ3LiYPSXr6eJA8w=="],
"micromark-extension-frontmatter": ["micromark-extension-frontmatter@2.0.0", "", { "dependencies": { "fault": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg=="],
"micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="],
"micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="],
"micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="],
"micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="],
"micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="],
"micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="],
"micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="],
"micromark-extension-math": ["micromark-extension-math@3.1.0", "", { "dependencies": { "@types/katex": "^0.16.0", "devlop": "^1.0.0", "katex": "^0.16.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg=="],
"micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
"micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
"micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
"micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
"micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
"micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
"micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
"micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
"micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
"micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
"micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
"micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
"micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
"micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
@ -777,6 +928,8 @@
"mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"node-sarif-builder": ["node-sarif-builder@3.4.0", "", { "dependencies": { "@types/sarif": "^2.1.7", "fs-extra": "^11.1.1" } }, "sha512-tGnJW6OKRii9u/b2WiUViTJS+h7Apxx17qsMUjsUeNDiMMX5ZFf8F8Fcz7PAQ6omvOxHZtvDTmOYKJQwmfpjeg=="],
@ -823,6 +976,8 @@
"preact": ["preact@10.28.4", "", {}, "sha512-uKFfOHWuSNpRFVTnljsCluEFq57OKT+0QdOiQo8XWnQ/pSvg7OpX5eNOejELXJMWy+BwM2nobz0FkvzmnpCNsQ=="],
"prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
"promise": ["promise@7.3.1", "", { "dependencies": { "asap": "~2.0.3" } }, "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg=="],
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
@ -911,6 +1066,8 @@
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"swrv": ["swrv@1.1.0", "", { "peerDependencies": { "vue": ">=3.2.26 < 4" } }, "sha512-pjllRDr2s0iTwiE5Isvip51dZGR7GjLH1gCSVyE8bQnbAx6xackXsFdojau+1O5u98yHF5V73HQGOFxKUXO9gQ=="],
"tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
"tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],
@ -931,6 +1088,8 @@
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="],
@ -941,6 +1100,8 @@
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
"unist-util-remove-position": ["unist-util-remove-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q=="],
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
"unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
@ -957,6 +1118,8 @@
"unplugin-vue-components": ["unplugin-vue-components@31.0.0", "", { "dependencies": { "chokidar": "^5.0.0", "local-pkg": "^1.1.2", "magic-string": "^0.30.21", "mlly": "^1.8.0", "obug": "^2.1.1", "picomatch": "^4.0.3", "tinyglobby": "^0.2.15", "unplugin": "^2.3.11", "unplugin-utils": "^0.3.1" }, "peerDependencies": { "@nuxt/kit": "^3.2.2 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@nuxt/kit"] }, "sha512-4ULwfTZTLuWJ7+S9P7TrcStYLsSRkk6vy2jt/WTfgUEUb0nW9//xxmrfhyHUEVpZ2UKRRwfRb8Yy15PDbVZf+Q=="],
"valibot": ["valibot@1.2.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg=="],
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
@ -971,6 +1134,8 @@
"vue-demi": ["vue-demi@0.14.10", "", { "peerDependencies": { "@vue/composition-api": "^1.0.0-rc.1", "vue": "^3.0.0-0 || ^2.6.0" }, "optionalPeers": ["@vue/composition-api"], "bin": { "vue-demi-fix": "bin/vue-demi-fix.js", "vue-demi-switch": "bin/vue-demi-switch.js" } }, "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg=="],
"vue-stream-markdown": ["vue-stream-markdown@0.6.0", "", { "dependencies": { "@floating-ui/dom": "^1.7.5", "@vueuse/core": "^14.2.1", "marked": "^17.0.3", "mdast-util-from-markdown": "^2.0.3", "mdast-util-frontmatter": "^2.0.1", "mdast-util-gfm": "^3.1.0", "mdast-util-math": "^3.0.0", "mdast-util-to-markdown": "^2.1.2", "micromark-extension-cjk-friendly": "^2.0.1", "micromark-extension-cjk-friendly-gfm-strikethrough": "^2.0.1", "micromark-extension-frontmatter": "^2.0.0", "micromark-extension-gfm": "^3.0.0", "micromark-extension-math": "^3.1.0", "micromark-util-types": "^2.0.2" }, "peerDependencies": { "beautiful-mermaid": ">=0.1.0", "katex": ">=0.16.20", "mermaid": ">=11", "shiki": ">=3.15.0", "vue": ">=3.0.0" }, "optionalPeers": ["beautiful-mermaid", "katex", "mermaid", "shiki"] }, "sha512-RLuI5zt5czh6dkTA0/XkkMv+ApVDQXSEGUs5TgEPc0bEhhcalXItu/IxZZU0/Kf0xJ3zSMawF7PRsxoMLc88Yg=="],
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
@ -981,6 +1146,8 @@
"yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
@ -1001,6 +1168,10 @@
"is-expression/acorn": ["acorn@7.4.1", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A=="],
"katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
"mdast-util-gfm-table/markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],

17
components.d.ts vendored
View file

@ -11,10 +11,17 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ApiKeySetup: typeof import('./src/components/chat/ApiKeySetup.vue')['default']
APIKeySetup: typeof import('./src/components/chat/APIKeySetup.vue')['default']
AppearanceSection: typeof import('./src/components/properties/AppearanceSection.vue')['default']
CanvasContextMenu: typeof import('./src/components/CanvasContextMenu.vue')['default']
ChatInput: typeof import('./src/components/chat/ChatInput.vue')['default']
ChatMessage: typeof import('./src/components/chat/ChatMessage.vue')['default']
ChatPanel: typeof import('./src/components/ChatPanel.vue')['default']
CodePanel: typeof import('./src/components/CodePanel.vue')['default']
ColorInput: typeof import('./src/components/ColorInput.vue')['default']
ColorPicker: typeof import('./src/components/ColorPicker.vue')['default']
DesignPanel: typeof import('./src/components/DesignPanel.vue')['default']
EditorCanvas: typeof import('./src/components/EditorCanvas.vue')['default']
EffectsSection: typeof import('./src/components/properties/EffectsSection.vue')['default']
ExportSection: typeof import('./src/components/properties/ExportSection.vue')['default']
@ -34,10 +41,13 @@ declare module 'vue' {
IconLucideBaseline: typeof import('~icons/lucide/baseline')['default']
IconLucideBlend: typeof import('~icons/lucide/blend')['default']
IconLucideBold: typeof import('~icons/lucide/bold')['default']
IconLucideBot: typeof import('~icons/lucide/bot')['default']
IconLucideCheck: typeof import('~icons/lucide/check')['default']
IconLucideChevronDown: typeof import('~icons/lucide/chevron-down')['default']
IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
IconLucideCircleDot: typeof import('~icons/lucide/circle-dot')['default']
IconLucideCode: typeof import('~icons/lucide/code')['default']
IconLucideCopy: typeof import('~icons/lucide/copy')['default']
IconLucideEye: typeof import('~icons/lucide/eye')['default']
IconLucideEyeOff: typeof import('~icons/lucide/eye-off')['default']
IconLucideFile: typeof import('~icons/lucide/file')['default']
@ -47,15 +57,22 @@ declare module 'vue' {
IconLucideHash: typeof import('~icons/lucide/hash')['default']
IconLucideImage: typeof import('~icons/lucide/image')['default']
IconLucideItalic: typeof import('~icons/lucide/italic')['default']
IconLucideKeyRound: typeof import('~icons/lucide/key-round')['default']
IconLucideLink: typeof import('~icons/lucide/link')['default']
IconLucideLoaderCircle: typeof import('~icons/lucide/loader-circle')['default']
IconLucideMessageCircle: typeof import('~icons/lucide/message-circle')['default']
IconLucidePlus: typeof import('~icons/lucide/plus')['default']
IconLucideRadius: typeof import('~icons/lucide/radius')['default']
IconLucideRotateCcw: typeof import('~icons/lucide/rotate-ccw')['default']
IconLucideRotateCw: typeof import('~icons/lucide/rotate-cw')['default']
IconLucideSearch: typeof import('~icons/lucide/search')['default']
IconLucideSend: typeof import('~icons/lucide/send')['default']
IconLucideSettings2: typeof import('~icons/lucide/settings2')['default']
IconLucideSparkles: typeof import('~icons/lucide/sparkles')['default']
IconLucideSquare: typeof import('~icons/lucide/square')['default']
IconLucideStrikethrough: typeof import('~icons/lucide/strikethrough')['default']
IconLucideToggleLeft: typeof import('~icons/lucide/toggle-left')['default']
IconLucideTriangleAlert: typeof import('~icons/lucide/triangle-alert')['default']
IconLucideType: typeof import('~icons/lucide/type')['default']
IconLucideUnderline: typeof import('~icons/lucide/underline')['default']
IconLucideUnlink: typeof import('~icons/lucide/unlink')['default']

View file

@ -28,8 +28,12 @@
"docs:preview": "vitepress preview docs"
},
"dependencies": {
"@ai-sdk/openai": "^3.0.37",
"@ai-sdk/valibot": "^2.0.17",
"@ai-sdk/vue": "^3.0.105",
"@open-pencil/cli": "workspace:*",
"@open-pencil/core": "workspace:*",
"@openrouter/ai-sdk-provider": "^2.2.3",
"@tailwindcss/vite": "^4.2.1",
"@tanstack/vue-table": "^8.21.3",
"@tauri-apps/api": "^2",
@ -37,13 +41,18 @@
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-opener": "^2",
"@vueuse/core": "^14.2.1",
"ai": "^6.0.105",
"canvaskit-wasm": "^0.40.0",
"culori": "^4.0.2",
"dedent": "^1.7.1",
"fflate": "^0.8.2",
"fzstd": "^0.1.1",
"prismjs": "^1.30.0",
"reka-ui": "^2.8.2",
"tailwindcss": "^4.2.1",
"valibot": "^1.2.0",
"vue": "^3.5.29",
"vue-stream-markdown": "^0.6.0",
"yoga-layout": "^3.2.1"
},
"devDependencies": {
@ -52,12 +61,14 @@
"@tauri-apps/cli": "^2",
"@types/culori": "^4.0.1",
"@types/node": "^25.3.2",
"@types/prismjs": "^1.26.6",
"@typescript/native-preview": "^7.0.0-dev.20260228.1",
"@vitejs/plugin-vue": "^6.0.4",
"esbuild": "^0.27.3",
"jscpd": "^4.0.8",
"oxfmt": "^0.35.0",
"oxlint": "^1.50.0",
"tw-animate-css": "^1.4.0",
"typescript": "~5.8.3",
"unplugin-icons": "^23.0.1",
"unplugin-vue-components": "^31.0.0",

View file

@ -72,3 +72,33 @@ export const RULER_MAJOR_TOLERANCE = 0.01
export const TEXT_SELECTION_COLOR = { r: 0.26, g: 0.52, b: 0.96, a: 0.3 }
export const TEXT_CARET_COLOR = { r: 0, g: 0, b: 0, a: 1 }
export const TEXT_CARET_WIDTH = 1
export interface ModelOption {
id: string
name: string
provider: string
tag?: string
}
export const AI_MODELS: ModelOption[] = [
// Best for design: vision + frontend + tool calling (WebDev Arena #1, DesignBench, SWE-bench 79.6%)
{ id: 'anthropic/claude-sonnet-4.6', name: 'Claude Sonnet 4.6', provider: 'Anthropic', tag: 'Best for design' },
{ id: 'anthropic/claude-opus-4.6', name: 'Claude Opus 4.6', provider: 'Anthropic', tag: 'Smartest' },
// 76.8% SWE-bench, vision + UI-to-code specialist
{ id: 'moonshotai/kimi-k2.5', name: 'Kimi K2.5', provider: 'Moonshot', tag: 'Vision + code' },
// 1M context, multimodal (text+image+audio+video), 78% SWE-bench
{ id: 'google/gemini-3.1-pro-preview', name: 'Gemini 3.1 Pro', provider: 'Google', tag: '1M context' },
// 80% SWE-bench, 400K context, agentic coding
{ id: 'openai/gpt-5.3-codex', name: 'GPT-5.3 Codex', provider: 'OpenAI' },
// Fast & cheap
{ id: 'google/gemini-3-flash-preview', name: 'Gemini 3 Flash', provider: 'Google', tag: 'Fast' },
{ id: 'deepseek/deepseek-v3.2', name: 'DeepSeek V3.2', provider: 'DeepSeek', tag: 'Cheap' },
{ id: 'qwen/qwen3.5-flash-02-23', name: 'Qwen 3.5 Flash', provider: 'Qwen', tag: 'Cheap' },
// Free (with tool calling)
{ id: 'qwen/qwen3-coder:free', name: 'Qwen3 Coder', provider: 'Qwen', tag: 'Free' },
{ id: 'openai/gpt-oss-120b:free', name: 'GPT-OSS 120B', provider: 'OpenAI', tag: 'Free' },
]
export const DEFAULT_AI_MODEL = AI_MODELS[0].id

View file

@ -112,7 +112,9 @@ export {
type BaseProps,
type TextProps,
type StyleProps,
type RenderResult
type RenderResult,
sceneNodeToJsx,
selectionToJsx
} from './render'
export {
parseFigmaClipboard,

View file

@ -0,0 +1,280 @@
import { colorToHex } from '../color'
import type { SceneGraph, SceneNode, Fill, Stroke, Effect, NodeType, Color } from '../scene-graph'
const NODE_TYPE_TO_TAG: Partial<Record<NodeType, string>> = {
FRAME: 'Frame',
RECTANGLE: 'Rectangle',
ROUNDED_RECTANGLE: 'Rectangle',
ELLIPSE: 'Ellipse',
TEXT: 'Text',
LINE: 'Line',
STAR: 'Star',
POLYGON: 'Polygon',
VECTOR: 'Vector',
GROUP: 'Group',
SECTION: 'Section',
COMPONENT: 'Component',
COMPONENT_SET: 'Frame',
INSTANCE: 'Frame'
}
function formatColor(color: Color, opacity = 1): string {
const hex = colorToHex(color)
if (opacity < 1) return hex + Math.round(opacity * 255).toString(16).padStart(2, '0')
return hex
}
function solidFillColor(fills: Fill[]): string | null {
const visible = fills.filter((f) => f.visible && f.type === 'SOLID')
if (visible.length !== 1) return null
return formatColor(visible[0].color, visible[0].opacity)
}
function solidStroke(strokes: Stroke[]): { color: string; weight: number } | null {
const visible = strokes.filter((s) => s.visible)
if (visible.length !== 1) return null
return {
color: formatColor(visible[0].color, visible[0].opacity),
weight: visible[0].weight
}
}
function formatShadow(e: Effect): string | null {
if (e.type !== 'DROP_SHADOW' && e.type !== 'INNER_SHADOW') return null
return `${e.offset.x} ${e.offset.y} ${e.radius} ${formatColor(e.color, e.color.a)}`
}
function formatProp(key: string, value: unknown): string {
if (typeof value === 'string') return `${key}="${value}"`
if (typeof value === 'number') return `${key}={${value}}`
if (typeof value === 'boolean') return value ? key : `${key}={false}`
return `${key}={${JSON.stringify(value)}}`
}
function collectProps(node: SceneNode, graph: SceneGraph): [string, unknown][] {
const props: [string, unknown][] = []
if (node.name && node.name !== node.type) {
props.push(['name', node.name])
}
if (node.layoutMode !== 'NONE') {
props.push(['flex', node.layoutMode === 'HORIZONTAL' ? 'row' : 'col'])
}
const isAutoLayout = node.layoutMode !== 'NONE'
const parent = node.parentId ? graph.getNode(node.parentId) : null
const parentIsAutoLayout = parent ? parent.layoutMode !== 'NONE' : false
if (isAutoLayout) {
const primaryAxis = node.layoutMode === 'HORIZONTAL' ? 'width' : 'height'
const crossAxis = node.layoutMode === 'HORIZONTAL' ? 'height' : 'width'
if (node.primaryAxisSizing === 'HUG') {
/* hug is default with flex, omit */
} else if (node.primaryAxisSizing === 'FILL') {
props.push([primaryAxis === 'width' ? 'w' : 'h', 'fill'])
} else {
props.push([primaryAxis === 'width' ? 'w' : 'h', node[primaryAxis]])
}
if (node.counterAxisSizing === 'HUG') {
/* hug is default, omit */
} else if (node.counterAxisSizing === 'FILL') {
props.push([crossAxis === 'width' ? 'w' : 'h', 'fill'])
} else {
props.push([crossAxis === 'width' ? 'w' : 'h', node[crossAxis]])
}
} else {
if (node.width > 0) props.push(['w', node.width])
if (node.height > 0) props.push(['h', node.height])
}
if (parentIsAutoLayout && node.layoutGrow > 0) {
props.push(['grow', node.layoutGrow])
}
if (isAutoLayout && node.itemSpacing > 0) {
props.push(['gap', node.itemSpacing])
}
if (isAutoLayout && node.layoutWrap === 'WRAP') {
props.push(['wrap', true])
if (node.counterAxisSpacing > 0) {
props.push(['rowGap', node.counterAxisSpacing])
}
}
if (isAutoLayout) {
if (node.primaryAxisAlign === 'CENTER') props.push(['justify', 'center'])
else if (node.primaryAxisAlign === 'MAX') props.push(['justify', 'end'])
else if (node.primaryAxisAlign === 'SPACE_BETWEEN') props.push(['justify', 'between'])
if (node.counterAxisAlign === 'CENTER') props.push(['items', 'center'])
else if (node.counterAxisAlign === 'MAX') props.push(['items', 'end'])
else if (node.counterAxisAlign === 'STRETCH') props.push(['items', 'stretch'])
}
if (isAutoLayout) {
const { paddingTop: pt, paddingRight: pr, paddingBottom: pb, paddingLeft: pl } = node
if (pt > 0 || pr > 0 || pb > 0 || pl > 0) {
if (pt === pr && pr === pb && pb === pl) {
props.push(['p', pt])
} else if (pt === pb && pl === pr) {
props.push(['py', pt])
props.push(['px', pl])
} else {
if (pt > 0) props.push(['pt', pt])
if (pr > 0) props.push(['pr', pr])
if (pb > 0) props.push(['pb', pb])
if (pl > 0) props.push(['pl', pl])
}
}
}
const bg = solidFillColor(node.fills)
if (bg) props.push(['bg', bg])
const stroke = solidStroke(node.strokes)
if (stroke) {
props.push(['stroke', stroke.color])
if (stroke.weight !== 1) props.push(['strokeWidth', stroke.weight])
}
if (node.cornerRadius > 0) {
if (node.independentCorners) {
const { topLeftRadius: tl, topRightRadius: tr, bottomRightRadius: br, bottomLeftRadius: bl } = node
if (tl === tr && tr === br && br === bl) {
props.push(['rounded', tl])
} else {
if (tl > 0) props.push(['roundedTL', tl])
if (tr > 0) props.push(['roundedTR', tr])
if (br > 0) props.push(['roundedBR', br])
if (bl > 0) props.push(['roundedBL', bl])
}
} else {
props.push(['rounded', node.cornerRadius])
}
}
if (node.cornerSmoothing > 0) props.push(['cornerSmoothing', node.cornerSmoothing])
if (node.opacity < 1) props.push(['opacity', Math.round(node.opacity * 100) / 100])
if (node.rotation !== 0) props.push(['rotate', Math.round(node.rotation * 100) / 100])
if (node.blendMode !== 'PASS_THROUGH' && node.blendMode !== 'NORMAL') {
props.push(['blendMode', node.blendMode.toLowerCase()])
}
if (node.clipsContent) props.push(['overflow', 'hidden'])
for (const effect of node.effects) {
if (!effect.visible) continue
if (effect.type === 'DROP_SHADOW' || effect.type === 'INNER_SHADOW') {
const shadow = formatShadow(effect)
if (shadow) props.push(['shadow', shadow])
} else if (effect.type === 'LAYER_BLUR' || effect.type === 'BACKGROUND_BLUR') {
props.push(['blur', effect.radius])
}
}
if (node.type === 'TEXT') {
if (node.fontSize !== 14) props.push(['size', node.fontSize])
if (node.fontFamily && node.fontFamily !== 'Inter') props.push(['font', node.fontFamily])
if (node.fontWeight !== 400) {
if (node.fontWeight === 700) props.push(['weight', 'bold'])
else if (node.fontWeight === 500) props.push(['weight', 'medium'])
else props.push(['weight', node.fontWeight])
}
if (node.textAlignHorizontal !== 'LEFT') {
props.push(['textAlign', node.textAlignHorizontal.toLowerCase()])
}
const textColor = solidFillColor(node.fills)
if (textColor) {
const bgIdx = props.findIndex(([k]) => k === 'bg')
if (bgIdx !== -1) props.splice(bgIdx, 1)
props.push(['color', textColor])
}
}
if (node.type === 'STAR') {
if (node.pointCount !== 5) props.push(['points', node.pointCount])
if (node.starInnerRadius !== 0.382) props.push(['innerRadius', node.starInnerRadius])
}
if (node.type === 'POLYGON' && node.pointCount !== 3) {
props.push(['points', node.pointCount])
}
return props
}
function nodeToJsx(
node: SceneNode,
graph: SceneGraph,
indent: number
): string {
const tag = NODE_TYPE_TO_TAG[node.type]
if (!tag) return ''
const prefix = ' '.repeat(indent)
const props = collectProps(node, graph)
const propsStr = props.map(([k, v]) => formatProp(k, v)).join(' ')
const opening = propsStr ? `<${tag} ${propsStr}` : `<${tag}`
const children = graph.getChildren(node.id)
const isText = node.type === 'TEXT'
if (isText) {
const text = node.text
if (!text) return `${prefix}${opening} />`
const escaped = text.replace(/[{}<>&]/g, (c) =>
c === '{' ? '&#123;' : c === '}' ? '&#125;' : c === '<' ? '&lt;' : c === '>' ? '&gt;' : '&amp;'
)
if (!escaped.includes('\n')) {
return `${prefix}${opening}>${escaped}</${tag}>`
}
const lines = escaped.split('\n')
const parts = [
`${prefix}${opening}>`,
...lines.map((l) => `${prefix} ${l}`),
`${prefix}</${tag}>`
]
return parts.join('\n')
}
if (children.length === 0) {
return `${prefix}${opening} />`
}
const childJsx = children
.filter((c) => c.visible)
.map((c) => nodeToJsx(c, graph, indent + 1))
.filter(Boolean)
if (childJsx.length === 0) {
return `${prefix}${opening} />`
}
return [
`${prefix}${opening}>`,
...childJsx,
`${prefix}</${tag}>`
].join('\n')
}
export function sceneNodeToJsx(
nodeId: string,
graph: SceneGraph
): string {
const node = graph.getNode(nodeId)
if (!node) return ''
return nodeToJsx(node, graph, 0)
}
export function selectionToJsx(
nodeIds: string[],
graph: SceneGraph
): string {
return nodeIds
.map((id) => sceneNodeToJsx(id, graph))
.filter(Boolean)
.join('\n\n')
}

View file

@ -22,3 +22,5 @@ export { type TreeNode, type BaseProps, type TextProps, type StyleProps, isTreeN
export { renderTree, type RenderResult } from './renderer'
export { renderJsx, renderTreeNode, buildComponent } from './render-jsx'
export { sceneNodeToJsx, selectionToJsx } from './export-jsx'

310
src/ai/tools.ts Normal file
View file

@ -0,0 +1,310 @@
import { valibotSchema } from '@ai-sdk/valibot'
import { tool } from 'ai'
import * as v from 'valibot'
import type { EditorStore } from '@/stores/editor'
const nodeTypeSchema = v.picklist([
'FRAME',
'RECTANGLE',
'ELLIPSE',
'TEXT',
'LINE',
'STAR',
'POLYGON',
'SECTION'
])
const rgbaSchema = v.object({
r: v.pipe(v.number(), v.minValue(0), v.maxValue(1)),
g: v.pipe(v.number(), v.minValue(0), v.maxValue(1)),
b: v.pipe(v.number(), v.minValue(0), v.maxValue(1)),
a: v.optional(v.pipe(v.number(), v.minValue(0), v.maxValue(1)), 1)
})
const hexSchema = v.pipe(
v.string(),
v.regex(/^#?[0-9a-fA-F]{6,8}$/),
v.description('Hex color like #ff0000 or #ff000080')
)
const colorInputSchema = v.union([rgbaSchema, hexSchema])
type ColorInput = v.InferOutput<typeof colorInputSchema>
function resolveColor(input: ColorInput) {
if (typeof input === 'string') {
const h = input.replace('#', '')
return {
r: parseInt(h.slice(0, 2), 16) / 255,
g: parseInt(h.slice(2, 4), 16) / 255,
b: parseInt(h.slice(4, 6), 16) / 255,
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1
}
}
return { ...input, a: input.a ?? 1 }
}
export function createAITools(store: EditorStore) {
return {
create_shape: tool({
description:
'Create a shape on the canvas. Use FRAME for containers/cards, RECTANGLE for solid blocks, ELLIPSE for circles, TEXT for labels, SECTION for page sections.',
inputSchema: valibotSchema(
v.object({
type: nodeTypeSchema,
x: v.pipe(v.number(), v.description('X position in canvas coordinates')),
y: v.pipe(v.number(), v.description('Y position in canvas coordinates')),
width: v.pipe(v.number(), v.minValue(1), v.description('Width in pixels')),
height: v.pipe(v.number(), v.minValue(1), v.description('Height in pixels')),
name: v.optional(v.pipe(v.string(), v.description('Node name shown in layers panel'))),
parent_id: v.optional(v.pipe(v.string(), v.description('Parent node ID to nest inside')))
})
),
execute: async ({ type, x, y, width, height, name, parent_id }) => {
const id = store.createShape(type, x, y, width, height, parent_id)
if (name) store.renameNode(id, name)
store.select([id])
return { id, type, x, y, width, height, name: name ?? type.toLowerCase() }
}
}),
set_fill: tool({
description: 'Set the fill color of a node. Accepts hex (#ff0000) or RGBA object.',
inputSchema: valibotSchema(
v.object({
id: v.pipe(v.string(), v.description('Node ID')),
color: colorInputSchema
})
),
execute: async ({ id, color }) => {
const c = resolveColor(color)
store.updateNodeWithUndo(
id,
{ fills: [{ type: 'SOLID', color: c, opacity: 1, visible: true }] },
'Set fill'
)
return { id, color: c }
}
}),
set_stroke: tool({
description: 'Set the stroke (border) of a node.',
inputSchema: valibotSchema(
v.object({
id: v.pipe(v.string(), v.description('Node ID')),
color: colorInputSchema,
weight: v.optional(v.pipe(v.number(), v.minValue(0.1)), 1),
align: v.optional(v.picklist(['INSIDE', 'CENTER', 'OUTSIDE']), 'INSIDE')
})
),
execute: async ({ id, color, weight, align }) => {
const c = resolveColor(color)
store.updateNodeWithUndo(
id,
{ strokes: [{ color: c, weight, opacity: 1, visible: true, align }] },
'Set stroke'
)
return { id, color: c, weight }
}
}),
update_node: tool({
description:
'Update properties of an existing node: position, size, opacity, corner radius, visibility, text content, font.',
inputSchema: valibotSchema(
v.object({
id: v.pipe(v.string(), v.description('Node ID')),
x: v.optional(v.number()),
y: v.optional(v.number()),
width: v.optional(v.pipe(v.number(), v.minValue(1))),
height: v.optional(v.pipe(v.number(), v.minValue(1))),
opacity: v.optional(v.pipe(v.number(), v.minValue(0), v.maxValue(1))),
corner_radius: v.optional(v.pipe(v.number(), v.minValue(0))),
visible: v.optional(v.boolean()),
text: v.optional(v.pipe(v.string(), v.description('Text content (for TEXT nodes)'))),
font_size: v.optional(v.pipe(v.number(), v.minValue(1))),
font_weight: v.optional(v.number()),
name: v.optional(v.string())
})
),
execute: async ({ id, corner_radius, font_size, font_weight, name, ...rest }) => {
const changes: Record<string, unknown> = { ...rest }
if (corner_radius !== undefined) changes.cornerRadius = corner_radius
if (font_size !== undefined) changes.fontSize = font_size
if (font_weight !== undefined) changes.fontWeight = font_weight
store.updateNodeWithUndo(id, changes, 'Update node')
if (name !== undefined) store.renameNode(id, name)
return { id, updated: Object.keys(changes) }
}
}),
set_layout: tool({
description:
'Set auto-layout (flexbox) on a frame. Direction, alignment, spacing, and padding.',
inputSchema: valibotSchema(
v.object({
id: v.pipe(v.string(), v.description('Frame node ID')),
direction: v.picklist(['HORIZONTAL', 'VERTICAL']),
spacing: v.optional(
v.pipe(v.number(), v.minValue(0), v.description('Gap between items')),
0
),
padding: v.optional(
v.pipe(v.number(), v.minValue(0), v.description('Equal padding on all sides'))
),
padding_horizontal: v.optional(v.pipe(v.number(), v.minValue(0))),
padding_vertical: v.optional(v.pipe(v.number(), v.minValue(0))),
align: v.optional(
v.pipe(
v.picklist(['MIN', 'CENTER', 'MAX', 'SPACE_BETWEEN']),
v.description('Primary axis alignment')
),
'MIN'
),
counter_align: v.optional(
v.pipe(
v.picklist(['MIN', 'CENTER', 'MAX', 'STRETCH']),
v.description('Cross axis alignment')
),
'MIN'
)
})
),
execute: async ({
id,
direction,
spacing,
padding,
padding_horizontal,
padding_vertical,
align,
counter_align
}) => {
store.setLayoutMode(id, direction)
const ph = padding_horizontal ?? padding ?? 0
const pv = padding_vertical ?? padding ?? 0
store.updateNodeWithUndo(
id,
{
itemSpacing: spacing,
primaryAxisAlign: align,
counterAxisAlign: counter_align,
paddingLeft: ph,
paddingRight: ph,
paddingTop: pv,
paddingBottom: pv
},
'Set layout'
)
return { id, direction, spacing }
}
}),
delete_node: tool({
description: 'Delete a node by ID.',
inputSchema: valibotSchema(
v.object({
id: v.pipe(v.string(), v.description('Node ID to delete'))
})
),
execute: async ({ id }) => {
store.select([id])
store.deleteSelected()
return { deleted: id }
}
}),
select_nodes: tool({
description: 'Select one or more nodes by ID.',
inputSchema: valibotSchema(
v.object({
ids: v.pipe(v.array(v.string()), v.minLength(1), v.description('Node IDs to select'))
})
),
execute: async ({ ids }) => {
store.select(ids)
return { selected: ids }
}
}),
get_page_tree: tool({
description:
'Get the node tree of the current page. Returns all nodes with their hierarchy, types, positions, and sizes.',
inputSchema: valibotSchema(v.object({})),
execute: async () => {
const graph = store.graph
const pageId = store.state.currentPageId
const page = graph.getNode(pageId)
if (!page) return { error: 'No current page' }
function nodeToJSON(id: string): object | null {
const node = graph.getNode(id)
if (!node) return null
const children = graph
.getChildren(id)
.map((c) => nodeToJSON(c.id))
.filter(Boolean)
return {
id: node.id,
type: node.type,
name: node.name,
x: node.x,
y: node.y,
width: node.width,
height: node.height,
...(children.length > 0 ? { children } : {}),
...(node.fills.length > 0 ? { fills: node.fills } : {}),
...(node.text ? { text: node.text } : {})
}
}
return {
page: page.name,
children: graph
.getChildren(pageId)
.map((c) => nodeToJSON(c.id))
.filter(Boolean)
}
}
}),
get_selection: tool({
description: 'Get details about currently selected nodes.',
inputSchema: valibotSchema(v.object({})),
execute: async () => {
const nodes = store.selectedNodes.value
if (nodes.length === 0) return { selection: [] }
return {
selection: nodes.map((n) => ({
id: n.id,
type: n.type,
name: n.name,
x: n.x,
y: n.y,
width: n.width,
height: n.height,
fills: n.fills,
text: n.text || undefined
}))
}
}
}),
rename_node: tool({
description: 'Rename a node in the layers panel.',
inputSchema: valibotSchema(
v.object({
id: v.string(),
name: v.string()
})
),
execute: async ({ id, name }) => {
store.renameNode(id, name)
return { id, name }
}
})
}
}
export type AITools = ReturnType<typeof createAITools>

View file

@ -1,4 +1,5 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@theme {
--color-panel: #2a2a2a;

View file

@ -0,0 +1,96 @@
<script setup lang="ts">
import { ScrollAreaRoot, ScrollAreaScrollbar, ScrollAreaThumb, ScrollAreaViewport } from 'reka-ui'
import { computed, markRaw, nextTick, ref, watch } from 'vue'
import APIKeySetup from '@/components/chat/APIKeySetup.vue'
import ChatInput from '@/components/chat/ChatInput.vue'
import ChatMessage from '@/components/chat/ChatMessage.vue'
import { useAIChat } from '@/composables/use-chat'
import type { Chat } from '@ai-sdk/vue'
import type { UIMessage } from 'ai'
const { isConfigured, ensureChat } = useAIChat()
const chat = ref<Chat<UIMessage> | null>(null)
const messagesEnd = ref<HTMLDivElement>()
const messages = computed(() => chat.value?.messages ?? [])
const status = computed(() => chat.value?.status ?? 'ready')
function scrollToBottom() {
nextTick(() => {
messagesEnd.value?.scrollIntoView({ behavior: 'smooth', block: 'end' })
})
}
watch(messages, scrollToBottom, { deep: true })
function handleSubmit(text: string) {
if (!chat.value) {
const c = ensureChat()
if (c) chat.value = markRaw(c)
}
chat.value?.sendMessage({ text }).catch(() => {})
}
function handleStop() {
chat.value?.stop()
}
</script>
<template>
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
<APIKeySetup v-if="!isConfigured" />
<template v-else>
<ScrollAreaRoot class="min-h-0 flex-1">
<ScrollAreaViewport class="h-full px-3 py-3 [&>div]:h-full">
<!-- Empty state -->
<div
v-if="messages.length === 0"
class="flex h-full flex-col items-center justify-center gap-3 text-muted"
>
<icon-lucide-message-circle class="size-8 opacity-50" />
<p class="text-xs">Describe what you want to create or change.</p>
</div>
<!-- Messages -->
<div v-else class="flex flex-col gap-3">
<ChatMessage v-for="msg in messages" :key="msg.id" :message="msg" />
<!-- Typing indicator -->
<div v-if="status === 'submitted'" class="flex gap-2">
<div
class="flex size-6 shrink-0 items-center justify-center rounded-full bg-muted/20 text-[10px] font-bold text-muted"
>
AI
</div>
<div class="flex items-center gap-1 py-2">
<span
class="size-1.5 animate-bounce rounded-full bg-muted"
style="animation-delay: 0ms"
/>
<span
class="size-1.5 animate-bounce rounded-full bg-muted"
style="animation-delay: 150ms"
/>
<span
class="size-1.5 animate-bounce rounded-full bg-muted"
style="animation-delay: 300ms"
/>
</div>
</div>
<div ref="messagesEnd" />
</div>
</ScrollAreaViewport>
<ScrollAreaScrollbar orientation="vertical" class="flex w-1.5 touch-none select-none p-px">
<ScrollAreaThumb class="relative flex-1 rounded-full bg-muted/30" />
</ScrollAreaScrollbar>
</ScrollAreaRoot>
<ChatInput :status="status" @submit="handleSubmit" @stop="handleStop" />
</template>
</div>
</template>

View file

@ -0,0 +1,106 @@
<script setup lang="ts">
import Prism from 'prismjs'
import 'prismjs/components/prism-jsx'
import { ScrollAreaRoot, ScrollAreaScrollbar, ScrollAreaThumb, ScrollAreaViewport } from 'reka-ui'
import { computed, ref, watch } from 'vue'
import { selectionToJsx } from '@open-pencil/core'
import { useEditorStore } from '@/stores/editor'
const store = useEditorStore()
const copied = ref(false)
const jsxCode = computed(() => {
void store.state.sceneVersion
const ids = [...store.state.selectedIds]
if (ids.length === 0) return ''
return selectionToJsx(ids, store.graph)
})
const highlightedLines = computed(() => {
if (!jsxCode.value) return []
const grammar = Prism.languages.jsx ?? Prism.languages.javascript
return jsxCode.value
.split('\n')
.map((line) => Prism.highlight(line, grammar, 'jsx'))
})
let copyTimeout: ReturnType<typeof setTimeout> | undefined
function copyCode() {
navigator.clipboard.writeText(jsxCode.value)
copied.value = true
clearTimeout(copyTimeout)
copyTimeout = setTimeout(() => (copied.value = false), 2000)
}
watch(jsxCode, () => {
copied.value = false
})
</script>
<template>
<div v-if="!jsxCode" class="flex flex-1 items-center justify-center px-4 text-center">
<span class="text-xs text-muted">Select a layer to see its JSX code</span>
</div>
<div v-else class="flex min-h-0 flex-1 flex-col">
<div class="flex shrink-0 items-center justify-between border-b border-border px-3 py-1.5">
<span class="text-[11px] text-muted">JSX</span>
<button
class="flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-muted hover:bg-hover hover:text-surface"
@click="copyCode"
>
<icon-lucide-check v-if="copied" class="size-3 text-green-400" />
<icon-lucide-copy v-else class="size-3" />
{{ copied ? 'Copied' : 'Copy' }}
</button>
</div>
<ScrollAreaRoot class="min-h-0 flex-1">
<ScrollAreaViewport class="size-full">
<div class="p-3">
<div
v-for="(html, i) in highlightedLines"
:key="i"
class="flex text-xs leading-5"
>
<span
class="mr-3 shrink-0 select-none text-right text-muted/40"
style="min-width: 1.5em"
>{{ i + 1 }}</span>
<pre class="m-0 min-w-0 flex-1 whitespace-pre-wrap break-words"><code v-html="html" /></pre>
</div>
</div>
</ScrollAreaViewport>
<ScrollAreaScrollbar orientation="vertical" class="flex w-1.5 touch-none select-none p-px">
<ScrollAreaThumb class="relative flex-1 rounded-full bg-white/10" />
</ScrollAreaScrollbar>
</ScrollAreaRoot>
</div>
</template>
<style>
.token.tag {
color: #7dd3fc;
}
.token.attr-name {
color: #c4b5fd;
}
.token.attr-value,
.token.string {
color: #86efac;
}
.token.number {
color: #fca5a5;
}
.token.punctuation {
color: #888;
}
.token.boolean {
color: #fca5a5;
}
.token.keyword {
color: #c4b5fd;
}
</style>

View file

@ -0,0 +1,85 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useEditorStore } from '@/stores/editor'
import VariablesDialog from './VariablesDialog.vue'
import AppearanceSection from './properties/AppearanceSection.vue'
import EffectsSection from './properties/EffectsSection.vue'
import ExportSection from './properties/ExportSection.vue'
import FillSection from './properties/FillSection.vue'
import LayoutSection from './properties/LayoutSection.vue'
import PageSection from './properties/PageSection.vue'
import PositionSection from './properties/PositionSection.vue'
import StrokeSection from './properties/StrokeSection.vue'
import TypographySection from './properties/TypographySection.vue'
import VariablesSection from './properties/VariablesSection.vue'
const store = useEditorStore()
const variablesOpen = ref(false)
const node = computed(() => store.selectedNode.value)
const multiCount = computed(() => store.selectedNodes.value.length)
const isComponentType = computed(() => {
const t = node.value?.type
return t === 'COMPONENT' || t === 'COMPONENT_SET' || t === 'INSTANCE'
})
</script>
<template>
<!-- Multi-select summary -->
<div v-if="multiCount > 1" class="flex-1 overflow-y-auto pb-4">
<div class="flex items-center gap-1.5 border-b border-border px-3 py-2">
<span class="text-[11px] text-muted">Mixed</span>
<span class="text-xs font-semibold">{{ multiCount }} layers</span>
</div>
<AppearanceSection />
</div>
<!-- Single selection -->
<div v-else-if="node" class="flex-1 overflow-y-auto pb-4">
<!-- Node header -->
<div class="flex items-center gap-1.5 border-b border-border px-3 py-2">
<span class="text-[11px]" :class="isComponentType ? 'text-[#9747ff]' : 'text-muted'">{{
node.type
}}</span>
<span class="text-xs font-semibold">{{ node.name }}</span>
</div>
<!-- Component actions -->
<div
v-if="node.type === 'INSTANCE'"
class="flex flex-col gap-1 border-b border-border px-3 py-2"
>
<button
class="rounded bg-[#9747ff]/10 px-2 py-1 text-left text-[11px] text-[#9747ff] hover:bg-[#9747ff]/20"
@click="store.goToMainComponent()"
>
Go to Main Component
</button>
<button
class="rounded px-2 py-1 text-left text-[11px] text-muted hover:bg-hover"
@click="store.detachInstance()"
>
Detach Instance
</button>
</div>
<PositionSection />
<LayoutSection />
<AppearanceSection />
<TypographySection v-if="node.type === 'TEXT'" />
<FillSection />
<StrokeSection />
<EffectsSection />
<ExportSection />
</div>
<div v-else class="flex-1 overflow-y-auto pb-4">
<PageSection />
<VariablesSection @open-dialog="variablesOpen = true" />
</div>
<VariablesDialog v-model:open="variablesOpen" />
</template>

View file

@ -1,29 +1,15 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
import { useAIChat } from '@/composables/use-chat'
import { useEditorStore } from '@/stores/editor'
import VariablesDialog from './VariablesDialog.vue'
import AppearanceSection from './properties/AppearanceSection.vue'
import EffectsSection from './properties/EffectsSection.vue'
import ExportSection from './properties/ExportSection.vue'
import FillSection from './properties/FillSection.vue'
import LayoutSection from './properties/LayoutSection.vue'
import PageSection from './properties/PageSection.vue'
import PositionSection from './properties/PositionSection.vue'
import StrokeSection from './properties/StrokeSection.vue'
import TypographySection from './properties/TypographySection.vue'
import VariablesSection from './properties/VariablesSection.vue'
import ChatPanel from './ChatPanel.vue'
import CodePanel from './CodePanel.vue'
import DesignPanel from './DesignPanel.vue'
const store = useEditorStore()
const variablesOpen = ref(false)
const node = computed(() => store.selectedNode.value)
const multiCount = computed(() => store.selectedNodes.value.length)
const isComponentType = computed(() => {
const t = node.value?.type
return t === 'COMPONENT' || t === 'COMPONENT_SET' || t === 'INSTANCE'
})
const { activeTab } = useAIChat(store)
</script>
<template>
@ -31,71 +17,62 @@ const isComponentType = computed(() => {
class="flex min-w-0 flex-1 flex-col overflow-hidden border-l border-border bg-panel"
style="contain: paint layout style"
>
<!-- Tabs -->
<div class="flex h-10 shrink-0 items-center gap-1 border-b border-border px-2">
<button class="rounded px-2.5 py-1 text-xs font-semibold text-surface">Design</button>
<button class="rounded px-2.5 py-1 text-xs text-muted">Prototype</button>
<span
class="ml-auto cursor-pointer rounded px-1.5 py-0.5 text-[11px] text-muted hover:bg-hover"
>
{{ Math.round(store.state.zoom * 100) }}%
</span>
</div>
<!-- Multi-select summary -->
<div v-if="multiCount > 1" class="flex-1 overflow-y-auto pb-4">
<div class="flex items-center gap-1.5 border-b border-border px-3 py-2">
<span class="text-[11px] text-muted">Mixed</span>
<span class="text-xs font-semibold">{{ multiCount }} layers</span>
</div>
<AppearanceSection />
</div>
<!-- Single selection -->
<div v-else-if="node" class="flex-1 overflow-y-auto pb-4">
<!-- Node header -->
<div class="flex items-center gap-1.5 border-b border-border px-3 py-2">
<span class="text-[11px]" :class="isComponentType ? 'text-[#9747ff]' : 'text-muted'">{{
node.type
}}</span>
<span class="text-xs font-semibold">{{ node.name }}</span>
</div>
<!-- Component actions -->
<div
v-if="node.type === 'INSTANCE'"
class="flex flex-col gap-1 border-b border-border px-3 py-2"
>
<button
class="rounded bg-[#9747ff]/10 px-2 py-1 text-left text-[11px] text-[#9747ff] hover:bg-[#9747ff]/20"
@click="store.goToMainComponent()"
<TabsRoot v-model="activeTab" class="flex min-h-0 flex-1 flex-col">
<TabsList class="flex h-10 shrink-0 items-center gap-1 border-b border-border px-2">
<TabsTrigger
value="design"
class="rounded px-2.5 py-1 text-xs text-muted hover:text-surface data-[state=active]:font-semibold data-[state=active]:text-surface"
>
Go to Main Component
</button>
<button
class="rounded px-2 py-1 text-left text-[11px] text-muted hover:bg-hover"
@click="store.detachInstance()"
Design
</TabsTrigger>
<TabsTrigger
value="code"
class="flex items-center gap-1 rounded px-2.5 py-1 text-xs text-muted hover:text-surface data-[state=active]:font-semibold data-[state=active]:text-surface"
>
Detach Instance
</button>
</div>
<icon-lucide-code class="size-3" />
Code
</TabsTrigger>
<TabsTrigger
value="ai"
class="flex items-center gap-1 rounded px-2.5 py-1 text-xs text-muted hover:text-surface data-[state=active]:font-semibold data-[state=active]:text-surface"
>
<icon-lucide-sparkles class="size-3" />
AI
</TabsTrigger>
<span
v-if="activeTab === 'design'"
class="ml-auto cursor-pointer rounded px-1.5 py-0.5 text-[11px] text-muted hover:bg-hover"
>
{{ Math.round(store.state.zoom * 100) }}%
</span>
</TabsList>
<PositionSection />
<LayoutSection />
<AppearanceSection />
<TypographySection v-if="node.type === 'TEXT'" />
<FillSection />
<StrokeSection />
<EffectsSection />
<TabsContent
value="design"
class="flex min-h-0 flex-1 flex-col"
:force-mount="true"
:hidden="activeTab !== 'design'"
>
<DesignPanel />
</TabsContent>
<ExportSection />
</div>
<TabsContent
value="code"
class="flex min-h-0 flex-1 flex-col"
:force-mount="true"
:hidden="activeTab !== 'code'"
>
<CodePanel />
</TabsContent>
<div v-else class="flex-1 overflow-y-auto pb-4">
<PageSection />
<VariablesSection @open-dialog="variablesOpen = true" />
</div>
<VariablesDialog v-model:open="variablesOpen" />
<TabsContent
value="ai"
class="flex min-h-0 flex-1 flex-col"
:force-mount="true"
:hidden="activeTab !== 'ai'"
>
<ChatPanel />
</TabsContent>
</TabsRoot>
</aside>
</template>

View file

@ -0,0 +1,43 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useAIChat } from '@/composables/use-chat'
const { apiKey } = useAIChat()
const input = ref('')
function save() {
apiKey.value = input.value.trim()
input.value = ''
}
</script>
<template>
<div class="flex flex-1 flex-col items-center justify-center gap-4 px-4">
<icon-lucide-key-round class="size-8 text-muted" />
<p class="text-center text-xs text-muted">Enter your OpenRouter API key to start chatting.</p>
<form class="flex w-full gap-1.5" @submit.prevent="save">
<input
v-model="input"
type="password"
placeholder="sk-or-…"
class="min-w-0 flex-1 rounded border border-border bg-input px-2 py-1 text-xs text-surface outline-none focus:border-accent"
/>
<button
type="submit"
class="shrink-0 rounded bg-accent px-2.5 py-1 text-xs font-medium text-white hover:bg-accent/90"
:disabled="!input.trim()"
>
Save
</button>
</form>
<a
href="https://openrouter.ai/keys"
target="_blank"
class="text-[10px] text-muted underline hover:text-surface"
>
Get an API key
</a>
</div>
</template>

View file

@ -0,0 +1,140 @@
<script setup lang="ts">
import {
SelectContent,
SelectItem,
SelectItemText,
SelectPortal,
SelectRoot,
SelectTrigger,
SelectViewport,
TooltipContent,
TooltipPortal,
TooltipProvider,
TooltipRoot,
TooltipTrigger
} from 'reka-ui'
import { computed, ref } from 'vue'
import { MODELS, useAIChat } from '@/composables/use-chat'
const { modelId } = useAIChat()
const props = defineProps<{
status: 'ready' | 'submitted' | 'streaming' | 'error'
}>()
const emit = defineEmits<{
submit: [text: string]
stop: []
}>()
const input = ref('')
const isStreaming = computed(() => props.status === 'streaming' || props.status === 'submitted')
const selectedModelName = computed(
() => MODELS.find((m) => m.id === modelId.value)?.name ?? modelId.value
)
function handleSubmit(e: Event) {
e.preventDefault()
const text = input.value.trim()
if (!text) return
emit('submit', text)
input.value = ''
}
</script>
<template>
<TooltipProvider>
<div class="shrink-0 border-t border-border px-3 py-2">
<!-- Model selector -->
<div class="mb-1.5 flex items-center">
<SelectRoot v-model="modelId">
<SelectTrigger
class="flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] text-muted hover:bg-hover hover:text-surface"
>
<icon-lucide-bot class="size-3" />
{{ selectedModelName }}
<icon-lucide-chevron-down class="size-2.5" />
</SelectTrigger>
<SelectPortal>
<SelectContent
position="popper"
side="top"
:side-offset="4"
class="z-50 max-h-60 overflow-y-auto rounded-lg border border-border bg-panel p-1 shadow-lg"
>
<SelectViewport>
<SelectItem
v-for="model in MODELS"
:key="model.id"
:value="model.id"
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-[11px] text-surface outline-none data-[highlighted]:bg-hover"
>
<SelectItemText class="flex-1">{{ model.name }}</SelectItemText>
<span
v-if="model.tag"
class="rounded bg-accent/10 px-1 py-px text-[9px] text-accent"
>
{{ model.tag }}
</span>
</SelectItem>
</SelectViewport>
</SelectContent>
</SelectPortal>
</SelectRoot>
</div>
<!-- Input form -->
<form class="flex gap-1.5" @submit="handleSubmit">
<input
v-model="input"
type="text"
placeholder="Describe a change…"
class="min-w-0 flex-1 rounded border border-border bg-input px-2.5 py-1.5 text-xs text-surface outline-none placeholder:text-muted focus:border-accent"
:disabled="status === 'submitted'"
/>
<TooltipRoot v-if="isStreaming">
<TooltipTrigger as-child>
<button
type="button"
class="shrink-0 rounded border border-border px-2 py-1.5 text-xs text-muted hover:bg-hover"
@click="emit('stop')"
>
<icon-lucide-square class="size-3" />
</button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent
side="top"
:side-offset="4"
class="rounded bg-surface px-2 py-1 text-[10px] text-canvas"
>
Stop generating
</TooltipContent>
</TooltipPortal>
</TooltipRoot>
<TooltipRoot v-else>
<TooltipTrigger as-child>
<button
type="submit"
class="shrink-0 rounded bg-accent px-2.5 py-1.5 text-xs font-medium text-white hover:bg-accent/90"
:disabled="!input.trim()"
>
<icon-lucide-send class="size-3" />
</button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent
side="top"
:side-offset="4"
class="rounded bg-surface px-2 py-1 text-[10px] text-canvas"
>
Send message
</TooltipContent>
</TooltipPortal>
</TooltipRoot>
</form>
</div>
</TooltipProvider>
</template>

View file

@ -0,0 +1,133 @@
<script setup lang="ts">
import { CollapsibleContent, CollapsibleRoot, CollapsibleTrigger } from 'reka-ui'
import { Markdown } from 'vue-stream-markdown'
import 'vue-stream-markdown/index.css'
import type { UIMessage } from 'ai'
const { message } = defineProps<{ message: UIMessage }>()
function getTextContent(msg: UIMessage): string {
return msg.parts
.filter((p): p is { type: 'text'; text: string } => p.type === 'text')
.map((p) => p.text)
.join('')
}
interface ToolPart {
type: string
toolCallId: string
state: string
input?: unknown
output?: unknown
errorText?: string
}
function isToolPart(part: unknown): part is ToolPart {
return (
typeof part === 'object' &&
part !== null &&
'type' in part &&
typeof (part as ToolPart).type === 'string' &&
(part as ToolPart).type.startsWith('tool-')
)
}
function getToolParts(msg: UIMessage): ToolPart[] {
return msg.parts.filter(isToolPart)
}
function toolName(part: ToolPart): string {
return part.type.replace(/^tool-/, '')
}
function toolDisplayName(part: ToolPart): string {
return toolName(part)
.replace(/_/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase())
}
function toolState(part: ToolPart): 'pending' | 'done' | 'error' {
if (part.state === 'error') return 'error'
if (part.state === 'output-available') return 'done'
return 'pending'
}
</script>
<template>
<div :class="message.role === 'user' ? 'flex justify-end' : ''">
<div class="min-w-0 space-y-1.5" :class="message.role === 'user' ? 'max-w-[85%]' : ''">
<!-- Tool timeline -->
<div
v-if="message.role === 'assistant' && getToolParts(message).length > 0"
class="space-y-0.5 rounded-lg border border-border bg-canvas p-2"
>
<CollapsibleRoot v-for="tool in getToolParts(message)" :key="tool.toolCallId">
<CollapsibleTrigger
class="flex w-full items-center gap-2 rounded px-1 py-0.5 hover:bg-hover"
>
<div
class="flex size-4 items-center justify-center rounded-full"
:class="{
'bg-accent/20 text-accent': toolState(tool) === 'pending',
'bg-green-500/20 text-green-400': toolState(tool) === 'done',
'bg-red-500/20 text-red-400': toolState(tool) === 'error'
}"
>
<icon-lucide-loader-circle
v-if="toolState(tool) === 'pending'"
class="size-3 animate-spin"
/>
<icon-lucide-check v-else-if="toolState(tool) === 'done'" class="size-3" />
<icon-lucide-triangle-alert v-else class="size-3" />
</div>
<span class="text-[11px] text-surface">
{{ toolDisplayName(tool) }}
</span>
<span class="text-[10px] text-muted">
{{
toolState(tool) === 'pending'
? 'Running…'
: toolState(tool) === 'done'
? 'Done'
: 'Error'
}}
</span>
<icon-lucide-chevron-down
v-if="toolState(tool) !== 'pending'"
class="ml-auto size-3 text-muted transition-transform [[data-state=open]>&]:rotate-180"
/>
</CollapsibleTrigger>
<CollapsibleContent
v-if="toolState(tool) !== 'pending'"
class="overflow-hidden text-[10px] data-[state=closed]:collapsible-up data-[state=open]:collapsible-down"
>
<pre class="mt-1 overflow-x-auto rounded bg-input p-2 text-muted">{{
tool.state === 'error' && tool.errorText
? tool.errorText
: JSON.stringify(tool.output, null, 2)
}}</pre>
</CollapsibleContent>
</CollapsibleRoot>
</div>
<!-- Text bubble -->
<div
v-if="getTextContent(message)"
class="rounded-xl px-3 py-2 text-xs leading-relaxed"
:class="
message.role === 'user'
? 'whitespace-pre-wrap rounded-br-md bg-accent text-white'
: 'rounded-tl-md bg-hover text-surface'
"
>
<Markdown
v-if="message.role === 'assistant'"
:content="getTextContent(message)"
class="chat-markdown"
/>
<template v-else>{{ getTextContent(message) }}</template>
</div>
</div>
</div>
</template>

111
src/composables/use-chat.ts Normal file
View file

@ -0,0 +1,111 @@
import { Chat } from '@ai-sdk/vue'
import { DEFAULT_AI_MODEL } from '@open-pencil/core'
import { createOpenRouter } from '@openrouter/ai-sdk-provider'
import { DirectChatTransport, ToolLoopAgent } from 'ai'
import dedent from 'dedent'
import { computed, ref, watch } from 'vue'
import { createAITools } from '@/ai/tools'
import type { EditorStore } from '@/stores/editor'
import type { UIMessage } from 'ai'
export { AI_MODELS as MODELS } from '@open-pencil/core'
export type { ModelOption } from '@open-pencil/core'
const API_KEY_STORAGE = 'open-pencil:openrouter-api-key'
const MODEL_STORAGE = 'open-pencil:model'
const SYSTEM_PROMPT = dedent`
You are a design assistant inside OpenPencil, a Figma-like design editor.
Help users create and modify designs. Be concise and direct.
When describing changes, use specific design terminology.
Available node types: FRAME (containers/cards), RECTANGLE, ELLIPSE, TEXT, LINE, STAR, POLYGON, SECTION.
Colors can be hex strings (#ff0000) or RGBA objects with values 01.
Coordinates use canvas space (0, 0) is the top-left of the page.
Always use tools to make changes. After creating nodes, briefly describe what you did.
When the user asks to create a layout, use create_shape with FRAME, then set_layout for auto-layout.
`
const apiKey = ref(localStorage.getItem(API_KEY_STORAGE) ?? '')
const modelId = ref(localStorage.getItem(MODEL_STORAGE) ?? DEFAULT_AI_MODEL)
const activeTab = ref<'design' | 'ai'>('design')
let editorStore: EditorStore | null = null
watch(apiKey, (key) => {
if (key) {
localStorage.setItem(API_KEY_STORAGE, key)
} else {
localStorage.removeItem(API_KEY_STORAGE)
}
})
watch(modelId, (id) => {
localStorage.setItem(MODEL_STORAGE, id)
})
const isConfigured = computed(() => apiKey.value.length > 0)
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test-only mock transports don't implement full generics
let overrideTransport: (() => any) | null = null
let chat: Chat<UIMessage> | null = null
function createTransport() {
if (overrideTransport) return overrideTransport()
const openrouter = createOpenRouter({
apiKey: apiKey.value,
headers: {
'X-OpenRouter-Title': 'OpenPencil',
'HTTP-Referer': 'https://github.com/dannote/open-pencil'
}
})
const tools = editorStore ? createAITools(editorStore) : {}
const agent = new ToolLoopAgent({
model: openrouter(modelId.value),
instructions: SYSTEM_PROMPT,
tools
})
return new DirectChatTransport({ agent })
}
function ensureChat(): Chat<UIMessage> | null {
if (!apiKey.value) return null
if (!chat) {
chat = new Chat<UIMessage>({
transport: createTransport()
})
}
return chat
}
function resetChat() {
chat = null
}
if (typeof window !== 'undefined') {
window.__OPEN_PENCIL_SET_TRANSPORT__ = (factory) => {
overrideTransport = factory
}
}
export function useAIChat(store?: EditorStore) {
if (store) {
editorStore = store
}
return {
apiKey,
modelId,
activeTab,
isConfigured,
ensureChat,
resetChat
}
}

View file

@ -1,5 +1,6 @@
import { useEventListener } from '@vueuse/core'
import { useAIChat } from '@/composables/use-chat'
import { TOOL_SHORTCUTS } from '@/stores/editor'
import { openFileDialog } from './use-menu'
@ -11,6 +12,7 @@ function isEditing(e: Event) {
}
export function useKeyboard(store: EditorStore) {
const { activeTab } = useAIChat()
useEventListener(window, 'copy', (e: ClipboardEvent) => {
if (isEditing(e)) return
e.preventDefault()
@ -79,6 +81,11 @@ export function useKeyboard(store: EditorStore) {
}
if (e.metaKey || e.ctrlKey) {
if (e.code === 'KeyJ') {
e.preventDefault()
activeTab.value = activeTab.value === 'ai' ? 'design' : 'ai'
return
}
if (e.key === 'z' && !e.shiftKey) {
e.preventDefault()
store.undoAction()

View file

@ -77,5 +77,3 @@ export async function loadFont(family: string, style = 'Regular'): Promise<Array
return loadFontCore(family, style)
}

2
src/global.d.ts vendored
View file

@ -20,4 +20,6 @@ interface Window {
blob(): Promise<Blob>
}[]
>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
__OPEN_PENCIL_SET_TRANSPORT__?(factory: () => any): void
}

View file

@ -0,0 +1,201 @@
import { expect, test, type Page } from '@playwright/test'
import { CanvasHelper } from '../helpers/canvas'
const USE_REAL_LLM = process.env.TEST_REAL_LLM === '1'
const OPENROUTER_KEY = process.env.OPENROUTER_API_KEY ?? ''
let page: Page
let canvas: CanvasHelper
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ browser }) => {
page = await browser.newPage()
await page.goto('/')
canvas = new CanvasHelper(page)
await canvas.waitForInit()
if (!USE_REAL_LLM) {
await injectMockTransport(page)
}
})
test.afterAll(async () => {
await page.close()
})
async function injectMockTransport(page: Page) {
await page.evaluate(() => {
const setTransport = window.__OPEN_PENCIL_SET_TRANSPORT__
if (!setTransport) throw new Error('Transport override not available')
let msgCounter = 0
setTransport(() => ({
async sendMessages({
messages,
}: {
messages: Array<{ role: string; parts: Array<{ type: string; text?: string }> }>
}) {
const lastUser = [...messages].reverse().find((m) => m.role === 'user')
const text = lastUser?.parts?.find((p) => p.type === 'text')?.text ?? ''
const msgId = `mock-msg-${++msgCounter}`
const wantsTool = text.toLowerCase().includes('frame') || text.toLowerCase().includes('rectangle')
return new ReadableStream({
start(controller) {
controller.enqueue({ type: 'start', messageId: msgId })
if (wantsTool) {
const toolCallId = `call-${msgId}`
controller.enqueue({
type: 'tool-input-start',
toolCallId,
toolName: 'create_shape',
})
controller.enqueue({
type: 'tool-input-delta',
toolCallId,
inputTextDelta: '{"type":"FRAME","x":100,"y":100,"width":200,"height":150,"name":"Card"}',
})
controller.enqueue({
type: 'tool-input-available',
toolCallId,
toolName: 'create_shape',
input: { type: 'FRAME', x: 100, y: 100, width: 200, height: 150, name: 'Card' },
})
controller.enqueue({
type: 'tool-output-available',
toolCallId,
toolName: 'create_shape',
output: { id: '0:99', type: 'FRAME', x: 100, y: 100, width: 200, height: 150, name: 'Card' },
})
}
const words = wantsTool
? ['Created', 'a', 'frame', 'called', '"Card".']
: `I'll help you with: "${text}". Here's a mock response.`.split(' ')
controller.enqueue({ type: 'text-start', id: 'text-1' })
for (const word of words) {
controller.enqueue({ type: 'text-delta', id: 'text-1', delta: word + ' ' })
}
controller.enqueue({ type: 'text-end', id: 'text-1' })
controller.enqueue({ type: 'finish', finishReason: 'stop' })
controller.close()
},
})
},
async reconnectToStream() {
return null
},
}))
})
}
function chatTab() {
return page.getByRole('tab', { name: 'AI' })
}
function designTab() {
return page.getByRole('tab', { name: 'Design' })
}
function chatInput() {
return page.locator('input[placeholder="Describe a change…"]')
}
function apiKeyInput() {
return page.locator('input[placeholder="sk-or-…"]')
}
test('⌘J switches to AI tab', async () => {
await designTab().waitFor()
await page.keyboard.press('Meta+j')
await expect(chatTab()).toHaveAttribute('data-state', 'active')
})
test('⌘J switches back to Design tab', async () => {
await page.keyboard.press('Meta+j')
await expect(designTab()).toHaveAttribute('data-state', 'active')
})
test('clicking AI tab shows API key setup when no key set', async () => {
await chatTab().click()
await expect(apiKeyInput()).toBeVisible()
await expect(page.getByText('Enter your OpenRouter API key')).toBeVisible()
})
test('saving API key shows chat interface', async () => {
const key = USE_REAL_LLM ? OPENROUTER_KEY : 'sk-or-test-key-12345'
await apiKeyInput().fill(key)
await page.locator('button:has-text("Save")').click()
await expect(chatInput()).toBeVisible()
await expect(page.getByText('Describe what you want to create or change.')).toBeVisible()
})
test('empty input has disabled send button', async () => {
const sendButton = page.locator('button[type="submit"]')
await expect(sendButton).toBeDisabled()
})
test('typing enables send button', async () => {
await chatInput().fill('Make a red rectangle')
const sendButton = page.locator('button[type="submit"]')
await expect(sendButton).toBeEnabled()
})
test('Enter submits message and clears input', async () => {
await chatInput().fill('Hello there')
await chatInput().press('Enter')
await expect(page.getByText('Hello there', { exact: true })).toBeVisible({ timeout: 5000 })
await expect(chatInput()).toHaveValue('')
})
test('assistant responds', async () => {
if (USE_REAL_LLM) {
await expect(
page.locator('.chat-markdown, [class*="rounded-tl-md"]').first(),
).toBeVisible({ timeout: 30000 })
} else {
await expect(page.getByText('mock response', { exact: false })).toBeVisible({ timeout: 5000 })
}
})
test('model selector is visible and clickable', async () => {
const trigger = page.getByRole('combobox')
await expect(trigger).toBeVisible()
await trigger.click()
await expect(page.getByRole('option', { name: /Claude Sonnet 4\.6/ })).toBeVisible()
await expect(page.getByText('Best for design')).toBeVisible()
await expect(page.getByText('Free').first()).toBeVisible()
await page.keyboard.press('Escape')
})
test('tool calls render in assistant message', async () => {
await chatInput().fill('Create a frame')
await chatInput().press('Enter')
if (USE_REAL_LLM) {
await expect(
page.locator('.chat-markdown, [class*="rounded-tl-md"]').first(),
).toBeVisible({ timeout: 30000 })
} else {
await expect(page.getByText('Create Shape')).toBeVisible({ timeout: 5000 })
await expect(page.getByText('Done')).toBeVisible()
await expect(page.getByText('Created a frame', { exact: false })).toBeVisible()
}
})
test('switching tabs preserves chat', async () => {
await designTab().click()
await expect(designTab()).toHaveAttribute('data-state', 'active')
await chatTab().click()
await expect(page.getByText('Hello there', { exact: true })).toBeVisible()
})

View file

@ -0,0 +1,293 @@
import { describe, expect, test } from 'bun:test'
import { SceneGraph, sceneNodeToJsx, selectionToJsx } from '@open-pencil/core'
function makeGraph() {
const graph = new SceneGraph()
graph.createNode('CANVAS', graph.rootId, { name: 'Page 1' })
return graph
}
function pageId(graph: SceneGraph) {
return graph.getPages()[0].id
}
describe('sceneNodeToJsx', () => {
test('basic rectangle', () => {
const graph = makeGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), {
name: 'Box',
width: 100,
height: 50
})
const jsx = sceneNodeToJsx(node.id, graph)
expect(jsx).toBe('<Rectangle name="Box" w={100} h={50} />')
})
test('rectangle with fill and rounded corners', () => {
const graph = makeGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), {
name: 'Card',
width: 320,
height: 200,
fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }],
cornerRadius: 16
})
const jsx = sceneNodeToJsx(node.id, graph)
expect(jsx).toContain('w={320}')
expect(jsx).toContain('h={200}')
expect(jsx).toContain('bg="#FFFFFF"')
expect(jsx).toContain('rounded={16}')
expect(jsx).toContain('<Rectangle')
expect(jsx).toContain('/>')
})
test('text node', () => {
const graph = makeGraph()
const node = graph.createNode('TEXT', pageId(graph), {
name: 'Title',
width: 200,
height: 24,
text: 'Hello World',
fontSize: 18,
fontWeight: 700,
fills: [
{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }
]
})
const jsx = sceneNodeToJsx(node.id, graph)
expect(jsx).toContain('<Text')
expect(jsx).toContain('size={18}')
expect(jsx).toContain('weight="bold"')
expect(jsx).toContain('color="#000000"')
expect(jsx).toContain('>Hello World</Text>')
})
test('frame with auto-layout', () => {
const graph = makeGraph()
const frame = graph.createNode('FRAME', pageId(graph), {
name: 'Row',
width: 400,
height: 100,
layoutMode: 'HORIZONTAL',
itemSpacing: 16,
paddingTop: 12,
paddingRight: 12,
paddingBottom: 12,
paddingLeft: 12,
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'HUG'
})
const jsx = sceneNodeToJsx(frame.id, graph)
expect(jsx).toContain('flex="row"')
expect(jsx).toContain('gap={16}')
expect(jsx).toContain('p={12}')
expect(jsx).toContain('w={400}')
})
test('frame with children', () => {
const graph = makeGraph()
const frame = graph.createNode('FRAME', pageId(graph), {
name: 'Card',
width: 320,
height: 200,
layoutMode: 'VERTICAL',
itemSpacing: 8,
primaryAxisSizing: 'HUG',
counterAxisSizing: 'FIXED'
})
graph.createNode('RECTANGLE', frame.id, {
name: 'Image',
width: 320,
height: 120
})
graph.createNode('TEXT', frame.id, {
name: 'Title',
width: 200,
height: 20,
text: 'Card Title',
fontSize: 16,
fontWeight: 700,
fills: [
{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }
]
})
const jsx = sceneNodeToJsx(frame.id, graph)
expect(jsx).toContain('<Frame')
expect(jsx).toContain('flex="col"')
expect(jsx).toContain('gap={8}')
expect(jsx).toContain(' <Rectangle')
expect(jsx).toContain(' <Text')
expect(jsx).toContain('>Card Title</Text>')
expect(jsx).toContain('</Frame>')
})
test('asymmetric padding', () => {
const graph = makeGraph()
const frame = graph.createNode('FRAME', pageId(graph), {
name: 'Box',
width: 100,
height: 100,
layoutMode: 'HORIZONTAL',
paddingTop: 8,
paddingRight: 16,
paddingBottom: 8,
paddingLeft: 16,
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED'
})
const jsx = sceneNodeToJsx(frame.id, graph)
expect(jsx).toContain('py={8}')
expect(jsx).toContain('px={16}')
expect(jsx).not.toContain('pt=')
expect(jsx).not.toContain('pb=')
})
test('independent corners', () => {
const graph = makeGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), {
name: 'Pill',
width: 100,
height: 40,
cornerRadius: 20,
independentCorners: true,
topLeftRadius: 20,
topRightRadius: 0,
bottomRightRadius: 0,
bottomLeftRadius: 20
})
const jsx = sceneNodeToJsx(node.id, graph)
expect(jsx).toContain('roundedTL={20}')
expect(jsx).toContain('roundedBL={20}')
expect(jsx).not.toContain('roundedTR=')
expect(jsx).not.toContain('roundedBR=')
})
test('opacity and rotation', () => {
const graph = makeGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), {
width: 50,
height: 50,
opacity: 0.5,
rotation: 45
})
const jsx = sceneNodeToJsx(node.id, graph)
expect(jsx).toContain('opacity={0.5}')
expect(jsx).toContain('rotate={45}')
})
test('stroke', () => {
const graph = makeGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), {
width: 100,
height: 100,
strokes: [
{ color: { r: 1, g: 0, b: 0, a: 1 }, weight: 2, opacity: 1, visible: true, align: 'INSIDE' as const }
]
})
const jsx = sceneNodeToJsx(node.id, graph)
expect(jsx).toContain('stroke="#FF0000"')
expect(jsx).toContain('strokeWidth={2}')
})
test('hidden children are excluded', () => {
const graph = makeGraph()
const frame = graph.createNode('FRAME', pageId(graph), {
name: 'Parent',
width: 100,
height: 100
})
graph.createNode('RECTANGLE', frame.id, {
name: 'Visible',
width: 50,
height: 50
})
graph.createNode('RECTANGLE', frame.id, {
name: 'Hidden',
width: 50,
height: 50,
visible: false
})
const jsx = sceneNodeToJsx(frame.id, graph)
expect(jsx).toContain('Visible')
expect(jsx).not.toContain('Hidden')
})
test('empty text node renders self-closing', () => {
const graph = makeGraph()
const node = graph.createNode('TEXT', pageId(graph), {
name: 'Empty',
width: 100,
height: 20,
text: ''
})
const jsx = sceneNodeToJsx(node.id, graph)
expect(jsx).toContain('/>')
expect(jsx).not.toContain('</Text>')
})
test('omits default values', () => {
const graph = makeGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), {
width: 100,
height: 100,
opacity: 1,
rotation: 0,
cornerRadius: 0
})
const jsx = sceneNodeToJsx(node.id, graph)
expect(jsx).not.toContain('opacity')
expect(jsx).not.toContain('rotate')
expect(jsx).not.toContain('rounded')
})
test('effects: shadow and blur', () => {
const graph = makeGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), {
width: 100,
height: 100,
effects: [
{
type: 'DROP_SHADOW',
color: { r: 0, g: 0, b: 0, a: 0.25 },
offset: { x: 0, y: 4 },
radius: 8,
spread: 0,
visible: true
},
{
type: 'LAYER_BLUR',
color: { r: 0, g: 0, b: 0, a: 0 },
offset: { x: 0, y: 0 },
radius: 4,
spread: 0,
visible: true
}
]
})
const jsx = sceneNodeToJsx(node.id, graph)
expect(jsx).toContain('shadow="0 4 8')
expect(jsx).toContain('blur={4}')
})
})
describe('selectionToJsx', () => {
test('multiple nodes separated by blank lines', () => {
const graph = makeGraph()
const a = graph.createNode('RECTANGLE', pageId(graph), {
name: 'A',
width: 10,
height: 10
})
const b = graph.createNode('ELLIPSE', pageId(graph), {
name: 'B',
width: 20,
height: 20
})
const jsx = selectionToJsx([a.id, b.id], graph)
expect(jsx).toContain('<Rectangle')
expect(jsx).toContain('<Ellipse')
expect(jsx).toContain('\n\n')
})
})