diff --git a/.gitignore b/.gitignore
index c241197..9a56ca0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,4 @@ dist/
bun.lock
.vscode
opencode.json
+.env*.local
diff --git a/package.json b/package.json
index 069e222..0ba95dd 100644
--- a/package.json
+++ b/package.json
@@ -27,6 +27,7 @@
"start": "cross-env NODE_ENV=development npm run build && npm run serve",
"dev": "cross-env NODE_ENV=development concurrently 'npm run watch' 'npm run serve'",
"dev:ui": "vite --config vite.config.dev.ts",
+ "test": "vitest run",
"prepublishOnly": "npm run build"
},
"dependencies": {
@@ -53,7 +54,8 @@
"cross-env": "^10.1.0",
"typescript": "^5.9.3",
"vite": "^6.0.0",
- "vite-plugin-singlefile": "^2.3.0"
+ "vite-plugin-singlefile": "^2.3.0",
+ "vitest": "^4.1.2"
},
"optionalDependencies": {
"@oven/bun-darwin-aarch64": "^1.2.21",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ce483bf..c27eab5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -75,6 +75,9 @@ importers:
vite-plugin-singlefile:
specifier: ^2.3.0
version: 2.3.0(rollup@4.57.1)(vite@6.4.1(@types/node@22.19.11)(sass@1.51.0))
+ vitest:
+ specifier: ^4.1.2
+ version: 4.1.2(@types/node@22.19.11)(vite@6.4.1(@types/node@22.19.11)(sass@1.51.0))
optionalDependencies:
'@oven/bun-darwin-aarch64':
specifier: ^1.2.21
@@ -934,6 +937,9 @@ packages:
cpu: [x64]
os: [win32]
+ '@standard-schema/spec@1.1.0':
+ resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
+
'@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@@ -949,6 +955,9 @@ packages:
'@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
+ '@types/chai@5.2.3':
+ resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
+
'@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
@@ -967,6 +976,9 @@ packages:
'@types/debug@4.1.12':
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
+ '@types/deep-eql@4.0.2':
+ resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
+
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -1020,6 +1032,35 @@ packages:
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
+ '@vitest/expect@4.1.2':
+ resolution: {integrity: sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==}
+
+ '@vitest/mocker@4.1.2':
+ resolution: {integrity: sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==}
+ peerDependencies:
+ msw: ^2.4.9
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0
+ peerDependenciesMeta:
+ msw:
+ optional: true
+ vite:
+ optional: true
+
+ '@vitest/pretty-format@4.1.2':
+ resolution: {integrity: sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==}
+
+ '@vitest/runner@4.1.2':
+ resolution: {integrity: sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==}
+
+ '@vitest/snapshot@4.1.2':
+ resolution: {integrity: sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==}
+
+ '@vitest/spy@4.1.2':
+ resolution: {integrity: sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==}
+
+ '@vitest/utils@4.1.2':
+ resolution: {integrity: sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==}
+
accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
@@ -1051,6 +1092,10 @@ packages:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
+ assertion-error@2.0.1:
+ resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
+ engines: {node: '>=12'}
+
baseline-browser-mapping@2.9.19:
resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==}
hasBin: true
@@ -1093,6 +1138,10 @@ packages:
canvas-roundrect-polyfill@0.0.1:
resolution: {integrity: sha512-yWq+R3U3jE+coOeEb3a3GgE2j/0MMiDKM/QpLb6h9ihf5fGY9UXtvK9o4vNqjWXoZz7/3EaSVU3IX53TvFFUOw==}
+ chai@6.2.2:
+ resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
+ engines: {node: '>=18'}
+
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
@@ -1406,6 +1455,9 @@ packages:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
+ es-module-lexer@2.0.0:
+ resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==}
+
es-object-atoms@1.1.1:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'}
@@ -1426,6 +1478,9 @@ packages:
escape-html@1.0.3:
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
+ estree-walker@3.0.3:
+ resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
+
etag@1.8.1:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
@@ -1438,6 +1493,10 @@ packages:
resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==}
engines: {node: '>=18.0.0'}
+ expect-type@1.3.0:
+ resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
+ engines: {node: '>=12.0.0'}
+
express-rate-limit@7.5.1:
resolution: {integrity: sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==}
engines: {node: '>= 16'}
@@ -1673,6 +1732,9 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
+ magic-string@0.30.21:
+ resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -1829,6 +1891,9 @@ packages:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
+ obug@2.1.1:
+ resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
+
on-finished@2.4.1:
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
engines: {node: '>= 0.8'}
@@ -1856,6 +1921,9 @@ packages:
path-to-regexp@8.3.0:
resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==}
+ pathe@2.0.3:
+ resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
+
perfect-freehand@1.2.0:
resolution: {integrity: sha512-h/0ikF1M3phW7CwpZ5MMvKnfpHficWoOEyr//KVNTxV4F6deRK1eYMtHyBKEAKFK0aXIEUK9oBvlF6PNXMDsAw==}
@@ -2055,6 +2123,9 @@ packages:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'}
+ siginfo@2.0.0:
+ resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
+
sliced@1.0.1:
resolution: {integrity: sha512-VZBmZP8WU3sMOZm1bdgTadsQbcscK0UM8oKxKVBs4XAhUo2Xxzm/OFMGBkPusxw9xL3Uy8LrzEqGqJhclsr0yA==}
@@ -2062,10 +2133,16 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
+ stackback@0.0.2:
+ resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
+
statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
+ std-env@4.0.0:
+ resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==}
+
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
@@ -2085,10 +2162,21 @@ packages:
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
engines: {node: '>=10'}
+ tinybench@2.9.0:
+ resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
+
+ tinyexec@1.0.4:
+ resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==}
+ engines: {node: '>=18'}
+
tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
+ tinyrainbow@3.1.0:
+ resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
+ engines: {node: '>=14.0.0'}
+
to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
@@ -2224,6 +2312,41 @@ packages:
yaml:
optional: true
+ vitest@4.1.2:
+ resolution: {integrity: sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==}
+ engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
+ hasBin: true
+ peerDependencies:
+ '@edge-runtime/vm': '*'
+ '@opentelemetry/api': ^1.9.0
+ '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
+ '@vitest/browser-playwright': 4.1.2
+ '@vitest/browser-preview': 4.1.2
+ '@vitest/browser-webdriverio': 4.1.2
+ '@vitest/ui': 4.1.2
+ happy-dom: '*'
+ jsdom: '*'
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0
+ peerDependenciesMeta:
+ '@edge-runtime/vm':
+ optional: true
+ '@opentelemetry/api':
+ optional: true
+ '@types/node':
+ optional: true
+ '@vitest/browser-playwright':
+ optional: true
+ '@vitest/browser-preview':
+ optional: true
+ '@vitest/browser-webdriverio':
+ optional: true
+ '@vitest/ui':
+ optional: true
+ happy-dom:
+ optional: true
+ jsdom:
+ optional: true
+
web-worker@1.5.0:
resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==}
@@ -2235,6 +2358,11 @@ packages:
engines: {node: '>= 8'}
hasBin: true
+ why-is-node-running@2.3.0:
+ resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
+ engines: {node: '>=8'}
+ hasBin: true
+
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@@ -3039,6 +3167,8 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.57.1':
optional: true
+ '@standard-schema/spec@1.1.0': {}
+
'@types/babel__core@7.20.5':
dependencies:
'@babel/parser': 7.29.0
@@ -3065,6 +3195,11 @@ snapshots:
'@types/connect': 3.4.38
'@types/node': 22.19.11
+ '@types/chai@5.2.3':
+ dependencies:
+ '@types/deep-eql': 4.0.2
+ assertion-error: 2.0.1
+
'@types/connect@3.4.38':
dependencies:
'@types/node': 22.19.11
@@ -3085,6 +3220,8 @@ snapshots:
dependencies:
'@types/ms': 2.1.0
+ '@types/deep-eql@4.0.2': {}
+
'@types/estree@1.0.8': {}
'@types/express-serve-static-core@5.1.1':
@@ -3151,6 +3288,47 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@vitest/expect@4.1.2':
+ dependencies:
+ '@standard-schema/spec': 1.1.0
+ '@types/chai': 5.2.3
+ '@vitest/spy': 4.1.2
+ '@vitest/utils': 4.1.2
+ chai: 6.2.2
+ tinyrainbow: 3.1.0
+
+ '@vitest/mocker@4.1.2(vite@6.4.1(@types/node@22.19.11)(sass@1.51.0))':
+ dependencies:
+ '@vitest/spy': 4.1.2
+ estree-walker: 3.0.3
+ magic-string: 0.30.21
+ optionalDependencies:
+ vite: 6.4.1(@types/node@22.19.11)(sass@1.51.0)
+
+ '@vitest/pretty-format@4.1.2':
+ dependencies:
+ tinyrainbow: 3.1.0
+
+ '@vitest/runner@4.1.2':
+ dependencies:
+ '@vitest/utils': 4.1.2
+ pathe: 2.0.3
+
+ '@vitest/snapshot@4.1.2':
+ dependencies:
+ '@vitest/pretty-format': 4.1.2
+ '@vitest/utils': 4.1.2
+ magic-string: 0.30.21
+ pathe: 2.0.3
+
+ '@vitest/spy@4.1.2': {}
+
+ '@vitest/utils@4.1.2':
+ dependencies:
+ '@vitest/pretty-format': 4.1.2
+ convert-source-map: 2.0.0
+ tinyrainbow: 3.1.0
+
accepts@2.0.0:
dependencies:
mime-types: 3.0.2
@@ -3182,6 +3360,8 @@ snapshots:
dependencies:
tslib: 2.8.1
+ assertion-error@2.0.1: {}
+
baseline-browser-mapping@2.9.19: {}
binary-extensions@2.3.0: {}
@@ -3230,6 +3410,8 @@ snapshots:
canvas-roundrect-polyfill@0.0.1: {}
+ chai@6.2.2: {}
+
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
@@ -3543,6 +3725,8 @@ snapshots:
es-errors@1.3.0: {}
+ es-module-lexer@2.0.0: {}
+
es-object-atoms@1.1.1:
dependencies:
es-errors: 1.3.0
@@ -3582,6 +3766,10 @@ snapshots:
escape-html@1.0.3: {}
+ estree-walker@3.0.3:
+ dependencies:
+ '@types/estree': 1.0.8
+
etag@1.8.1: {}
eventsource-parser@3.0.6: {}
@@ -3590,6 +3778,8 @@ snapshots:
dependencies:
eventsource-parser: 3.0.6
+ expect-type@1.3.0: {}
+
express-rate-limit@7.5.1(express@5.2.1):
dependencies:
express: 5.2.1
@@ -3799,6 +3989,10 @@ snapshots:
dependencies:
yallist: 3.1.1
+ magic-string@0.30.21:
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+
math-intrinsics@1.1.0: {}
mcp-handler@1.0.7(@modelcontextprotocol/sdk@1.25.2(hono@4.11.9)(zod@4.3.6)):
@@ -4031,6 +4225,8 @@ snapshots:
object-inspect@1.13.4: {}
+ obug@2.1.1: {}
+
on-finished@2.4.1:
dependencies:
ee-first: 1.1.1
@@ -4051,6 +4247,8 @@ snapshots:
path-to-regexp@8.3.0: {}
+ pathe@2.0.3: {}
+
perfect-freehand@1.2.0: {}
pica@7.1.1:
@@ -4303,12 +4501,18 @@ snapshots:
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
+ siginfo@2.0.0: {}
+
sliced@1.0.1: {}
source-map-js@1.2.1: {}
+ stackback@0.0.2: {}
+
statuses@2.0.2: {}
+ std-env@4.0.0: {}
+
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
@@ -4329,11 +4533,17 @@ snapshots:
dependencies:
has-flag: 4.0.0
+ tinybench@2.9.0: {}
+
+ tinyexec@1.0.4: {}
+
tinyglobby@0.2.15:
dependencies:
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
+ tinyrainbow@3.1.0: {}
+
to-regex-range@5.0.1:
dependencies:
is-number: 7.0.0
@@ -4427,6 +4637,33 @@ snapshots:
fsevents: 2.3.3
sass: 1.51.0
+ vitest@4.1.2(@types/node@22.19.11)(vite@6.4.1(@types/node@22.19.11)(sass@1.51.0)):
+ dependencies:
+ '@vitest/expect': 4.1.2
+ '@vitest/mocker': 4.1.2(vite@6.4.1(@types/node@22.19.11)(sass@1.51.0))
+ '@vitest/pretty-format': 4.1.2
+ '@vitest/runner': 4.1.2
+ '@vitest/snapshot': 4.1.2
+ '@vitest/spy': 4.1.2
+ '@vitest/utils': 4.1.2
+ es-module-lexer: 2.0.0
+ expect-type: 1.3.0
+ magic-string: 0.30.21
+ obug: 2.1.1
+ pathe: 2.0.3
+ picomatch: 4.0.3
+ std-env: 4.0.0
+ tinybench: 2.9.0
+ tinyexec: 1.0.4
+ tinyglobby: 0.2.15
+ tinyrainbow: 3.1.0
+ vite: 6.4.1(@types/node@22.19.11)(sass@1.51.0)
+ why-is-node-running: 2.3.0
+ optionalDependencies:
+ '@types/node': 22.19.11
+ transitivePeerDependencies:
+ - msw
+
web-worker@1.5.0: {}
webworkify@1.5.0: {}
@@ -4435,6 +4672,11 @@ snapshots:
dependencies:
isexe: 2.0.0
+ why-is-node-running@2.3.0:
+ dependencies:
+ siginfo: 2.0.0
+ stackback: 0.0.2
+
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
diff --git a/src/comment-ui.tsx b/src/comment-ui.tsx
new file mode 100644
index 0000000..b9f8901
--- /dev/null
+++ b/src/comment-ui.tsx
@@ -0,0 +1,44 @@
+interface CommentComposerProps {
+ draft: string;
+ error: string | null;
+ sending: boolean;
+ onChange: (value: string) => void;
+ onCancel: () => void;
+ onSend: () => void;
+}
+
+export function CommentComposer({
+ draft,
+ error,
+ sending,
+ onChange,
+ onCancel,
+ onSend,
+}: CommentComposerProps) {
+ return (
+
+ );
+}
diff --git a/src/dev-mock.ts b/src/dev-mock.ts
index 2ee6cc2..d3d77a0 100644
--- a/src/dev-mock.ts
+++ b/src/dev-mock.ts
@@ -77,6 +77,10 @@ export function createMockApp(): MockAppControls {
return { containerDimensions: { height: 600 } };
},
+ getHostCapabilities() {
+ return { message: { text: true }, updateModelContext: { text: true } };
+ },
+
async openLink({ url }: { url: string }) {
console.log(`[mock] openLink: ${url}`);
window.open(url, "_blank");
@@ -85,6 +89,11 @@ export function createMockApp(): MockAppControls {
async updateModelContext(opts: any) {
console.log("[mock] updateModelContext", opts);
},
+
+ async sendMessage(opts: any) {
+ console.log("[mock] sendMessage", opts);
+ return { isError: false };
+ },
} as unknown as App;
return {
diff --git a/src/edit-context.ts b/src/edit-context.ts
deleted file mode 100644
index 680e10f..0000000
--- a/src/edit-context.ts
+++ /dev/null
@@ -1,123 +0,0 @@
-import type { App } from "@modelcontextprotocol/ext-apps";
-
-const DEBOUNCE_MS = 2000;
-let timer: ReturnType | null = null;
-let initialSnapshot: string | null = null;
-let initialElementsById: Map = new Map();
-let storageKey: string | null = null;
-let checkpointId: string | null = null;
-
-/**
- * Set the localStorage key for this widget instance (use viewUUID or tool-call-derived ID).
- */
-export function setStorageKey(key: string) {
- storageKey = `excalidraw:${key}`;
-}
-
-/**
- * Set the checkpoint key for saving state snapshots.
- * Called when ontoolresult delivers the checkpointId from the server.
- */
-export function setCheckpointId(id: string) {
- checkpointId = id;
-}
-
-/**
- * Call once after final render to capture the baseline element state.
- */
-export function captureInitialElements(elements: readonly any[]) {
- initialSnapshot = JSON.stringify(elements.map((el: any) => el.id + ":" + (el.version ?? 0)));
- initialElementsById = new Map(elements.map((el: any) => [el.id, el]));
-}
-
-/** Compute a compact diff between initial and current elements. */
-function computeDiff(current: any[]): string {
- const added: string[] = [];
- const removed: string[] = [];
- const moved: string[] = [];
- const currentIds = new Set();
-
- for (const el of current) {
- currentIds.add(el.id);
- const orig = initialElementsById.get(el.id);
- if (!orig) {
- // New element — include type, position, and text if any
- const desc = `${el.type} "${el.text ?? el.label?.text ?? ""}" at (${Math.round(el.x)},${Math.round(el.y)})`;
- added.push(desc);
- } else if (Math.round(orig.x) !== Math.round(el.x) || Math.round(orig.y) !== Math.round(el.y) ||
- Math.round(orig.width) !== Math.round(el.width) || Math.round(orig.height) !== Math.round(el.height)) {
- moved.push(`${el.id} → (${Math.round(el.x)},${Math.round(el.y)}) ${Math.round(el.width)}x${Math.round(el.height)}`);
- }
- }
-
- for (const id of initialElementsById.keys()) {
- if (!currentIds.has(id)) removed.push(id);
- }
-
- const parts: string[] = [];
- if (added.length) parts.push(`Added: ${added.join("; ")}`);
- if (removed.length) parts.push(`Removed: ${removed.join(", ")}`);
- if (moved.length) parts.push(`Moved/resized: ${moved.join("; ")}`);
- if (!parts.length) return "";
- const cpRef = checkpointId ? ` (checkpoint: ${checkpointId})` : "";
- return `User edited diagram${cpRef}. ${parts.join(". ")}`;
-}
-
-/**
- * Load persisted elements from localStorage (if any).
- */
-export function loadPersistedElements(): any[] | null {
- if (!storageKey) return null;
- try {
- const stored = localStorage.getItem(storageKey);
- if (!stored) return null;
- return JSON.parse(stored);
- } catch {
- return null;
- }
-}
-
-/** Latest edited elements (kept in sync without triggering React re-renders). */
-let latestEditedElements: any[] | null = null;
-
-/**
- * Get the latest user-edited elements (or null if no edits were made).
- * Call this when exiting fullscreen to sync edits back to React state.
- */
-export function getLatestEditedElements(): any[] | null {
- return latestEditedElements;
-}
-
-/**
- * Excalidraw onChange handler. Persists to localStorage and sends updated
- * elements JSON to model context — only when user actually changed something
- * (debounced). Does NOT call setState to avoid infinite re-render loops.
- */
-export function onEditorChange(app: App, elements: readonly any[]) {
- const currentSnapshot = JSON.stringify(elements.map((el: any) => el.id + ":" + (el.version ?? 0)));
- if (currentSnapshot === initialSnapshot) return;
-
- const live = [...elements].filter((el: any) => !el.isDeleted);
- latestEditedElements = live;
-
- if (timer) clearTimeout(timer);
- timer = setTimeout(() => {
- if (storageKey) {
- try {
- localStorage.setItem(storageKey, JSON.stringify(live));
- } catch {}
- }
- if (checkpointId) {
- app.callServerTool({
- name: "save_checkpoint",
- arguments: { id: checkpointId, data: JSON.stringify({ elements: live }) },
- }).catch(() => {});
- }
- const diff = computeDiff(live);
- if (diff) {
- app.updateModelContext({
- content: [{ type: "text", text: diff }],
- }).catch(() => {});
- }
- }, DEBOUNCE_MS);
-}
diff --git a/src/editor-persistence.ts b/src/editor-persistence.ts
new file mode 100644
index 0000000..ffb2835
--- /dev/null
+++ b/src/editor-persistence.ts
@@ -0,0 +1,167 @@
+import type { App } from "@modelcontextprotocol/ext-apps";
+import type { WidgetContextController } from "./widget-context";
+import { flushModelContext } from "./widget-context";
+
+const DEBOUNCE_MS = 2000;
+
+function safeLocalStorageGet(key: string) {
+ try {
+ return localStorage.getItem(key);
+ } catch {
+ return null;
+ }
+}
+
+function safeLocalStorageSet(key: string, value: string) {
+ try {
+ localStorage.setItem(key, value);
+ } catch {}
+}
+
+function buildSnapshot(elements: readonly any[]) {
+ return JSON.stringify(elements.map((el: any) => `${el.id}:${el.version ?? 0}`));
+}
+
+function computeDiff(current: any[], initialElementsById: Map, checkpointId: string | null) {
+ const added: string[] = [];
+ const removed: string[] = [];
+ const moved: string[] = [];
+ const currentIds = new Set();
+
+ for (const el of current) {
+ currentIds.add(el.id);
+ const original = initialElementsById.get(el.id);
+ if (!original) {
+ const desc = `${el.type} "${el.text ?? el.label?.text ?? ""}" at (${Math.round(el.x)},${Math.round(el.y)})`;
+ added.push(desc);
+ continue;
+ }
+
+ if (
+ Math.round(original.x) !== Math.round(el.x)
+ || Math.round(original.y) !== Math.round(el.y)
+ || Math.round(original.width) !== Math.round(el.width)
+ || Math.round(original.height) !== Math.round(el.height)
+ ) {
+ moved.push(
+ `${el.id} → (${Math.round(el.x)},${Math.round(el.y)}) ${Math.round(el.width)}x${Math.round(el.height)}`,
+ );
+ }
+ }
+
+ for (const id of initialElementsById.keys()) {
+ if (!currentIds.has(id)) {
+ removed.push(id);
+ }
+ }
+
+ const parts: string[] = [];
+ if (added.length) {
+ parts.push(`Added: ${added.join("; ")}`);
+ }
+ if (removed.length) {
+ parts.push(`Removed: ${removed.join(", ")}`);
+ }
+ if (moved.length) {
+ parts.push(`Moved/resized: ${moved.join("; ")}`);
+ }
+ if (!parts.length) {
+ return "";
+ }
+
+ const checkpointSuffix = checkpointId ? ` (checkpoint: ${checkpointId})` : "";
+ return `User edited diagram${checkpointSuffix}. ${parts.join(". ")}`;
+}
+
+export interface EditorPersistenceController {
+ setStorageKey(key: string): void;
+ loadPersistedElements(): any[] | null;
+ captureInitialElements(elements: readonly any[]): void;
+ onEditorChange(elements: readonly any[]): void;
+ getLatestEditedElements(): any[] | null;
+ dispose(): void;
+}
+
+export function createEditorPersistence({
+ app,
+ context,
+}: {
+ app: App;
+ context: WidgetContextController;
+}): EditorPersistenceController {
+ let timer: ReturnType | null = null;
+ let initialSnapshot: string | null = null;
+ let latestEditedElements: any[] | null = null;
+ let storageKey: string | null = null;
+ let initialElementsById: Map = new Map();
+
+ return {
+ setStorageKey(key) {
+ storageKey = `excalidraw:${key}`;
+ },
+ loadPersistedElements() {
+ if (!storageKey) {
+ return null;
+ }
+
+ const stored = safeLocalStorageGet(storageKey);
+ if (!stored) {
+ return null;
+ }
+
+ try {
+ return JSON.parse(stored);
+ } catch {
+ return null;
+ }
+ },
+ captureInitialElements(elements) {
+ if (timer) {
+ clearTimeout(timer);
+ }
+ initialSnapshot = buildSnapshot(elements);
+ initialElementsById = new Map(elements.map((el: any) => [el.id, el]));
+ latestEditedElements = null;
+ },
+ onEditorChange(elements) {
+ const currentSnapshot = buildSnapshot(elements);
+ if (currentSnapshot === initialSnapshot) {
+ return;
+ }
+
+ const live = [...elements].filter((el: any) => !el.isDeleted);
+ latestEditedElements = live;
+
+ if (timer) {
+ clearTimeout(timer);
+ }
+
+ timer = setTimeout(() => {
+ if (storageKey) {
+ safeLocalStorageSet(storageKey, JSON.stringify(live));
+ }
+ if (context.getState().checkpointId) {
+ app.callServerTool({
+ name: "save_checkpoint",
+ arguments: {
+ id: context.getState().checkpointId,
+ data: JSON.stringify({ elements: live }),
+ },
+ }).catch(() => {});
+ }
+
+ const diff = computeDiff(live, initialElementsById, context.getState().checkpointId);
+ context.recordEdit(diff || null);
+ flushModelContext(app, context);
+ }, DEBOUNCE_MS);
+ },
+ getLatestEditedElements() {
+ return latestEditedElements;
+ },
+ dispose() {
+ if (timer) {
+ clearTimeout(timer);
+ }
+ },
+ };
+}
diff --git a/src/global.css b/src/global.css
index 25d5756..02ca63b 100644
--- a/src/global.css
+++ b/src/global.css
@@ -91,11 +91,13 @@ body {
/* Mobile: export button moves below the toolbar row (narrow viewports can't
fit it in top-right alongside lock/hand icons). */
-.mobile-share-slot {
+.mobile-action-slot {
position: absolute;
top: calc(var(--sat, 0px) + 64px);
right: calc(var(--sar, 0px) + 12px);
z-index: 5;
+ display: flex;
+ gap: 8px;
}
/* Contain SVG inside fullscreen viewport (not cover) */
@@ -145,6 +147,23 @@ body {
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
}
+.app-button:disabled,
+.app-button[disabled] {
+ cursor: not-allowed;
+ pointer-events: none;
+ opacity: 0.45;
+ color: rgba(0, 0, 0, 0.45);
+ background: rgba(255, 255, 255, 0.42);
+ box-shadow: none;
+}
+
+.app-button:disabled:hover,
+.app-button[disabled]:hover {
+ color: rgba(0, 0, 0, 0.45);
+ background: rgba(255, 255, 255, 0.42);
+ box-shadow: none;
+}
+
.excalidraw-container {
width: 100%;
position: relative;
@@ -312,6 +331,87 @@ body {
--button-hover-color: #fff !important;
}
+.selection-comment-anchor {
+ position: absolute;
+ z-index: 6;
+ transform: translate(-50%, calc(-100% - 12px));
+ display: flex;
+ align-items: flex-end;
+ justify-content: center;
+}
+
+.selection-comment-anchor.mobile {
+ max-width: min(340px, calc(100vw - 24px));
+}
+
+.comment-composer {
+ width: min(340px, calc(100vw - 24px));
+ padding: 12px;
+ border-radius: 12px;
+ background: rgba(255, 255, 255, 0.94);
+ border: 1px solid rgba(0, 0, 0, 0.08);
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.12);
+ backdrop-filter: blur(14px);
+ -webkit-backdrop-filter: blur(14px);
+}
+
+.selection-comment-trigger {
+ border: 1px solid rgba(0, 0, 0, 0.1);
+ border-radius: 999px;
+ padding: 7px 12px;
+ background: rgba(255, 255, 255, 0.96);
+ color: rgba(0, 0, 0, 0.8);
+ font: inherit;
+ font-size: 0.78rem;
+ cursor: pointer;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
+}
+
+.selection-comment-trigger:hover {
+ background: #fff;
+}
+
+.comment-composer-header {
+ margin-bottom: 8px;
+ font-size: 0.78rem;
+ font-weight: 600;
+ color: rgba(0, 0, 0, 0.72);
+}
+
+.comment-composer-input {
+ width: 100%;
+ resize: vertical;
+ min-height: 72px;
+ padding: 10px 12px;
+ border: 1px solid rgba(0, 0, 0, 0.12);
+ border-radius: 10px;
+ background: rgba(255, 255, 255, 0.92);
+ color: #111827;
+ font: inherit;
+}
+
+.comment-composer-input:focus {
+ outline: 2px solid rgba(74, 158, 237, 0.35);
+ outline-offset: 1px;
+}
+
+.comment-composer-error {
+ margin-top: 8px;
+ font-size: 0.75rem;
+ color: #b91c1c;
+}
+
+.comment-composer-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ margin-top: 10px;
+}
+
+.comment-send-button {
+ background: rgba(74, 158, 237, 0.18);
+}
+
.loading {
padding: 1rem;
color: var(--text-muted);
diff --git a/src/mcp-app.tsx b/src/mcp-app.tsx
index 908d7cb..c285972 100644
--- a/src/mcp-app.tsx
+++ b/src/mcp-app.tsx
@@ -1,10 +1,23 @@
import { useApp } from "@modelcontextprotocol/ext-apps/react";
import type { App } from "@modelcontextprotocol/ext-apps";
-import { Excalidraw, exportToSvg, convertToExcalidrawElements, restore, CaptureUpdateAction, FONT_FAMILY, serializeAsJSON, MainMenu } from "@excalidraw/excalidraw";
+import { Excalidraw, exportToSvg, restore, CaptureUpdateAction, serializeAsJSON, MainMenu } from "@excalidraw/excalidraw";
import morphdom from "morphdom";
import { useCallback, useEffect, useRef, useState } from "react";
import { initPencilAudio, playStroke } from "./pencil-audio";
-import { captureInitialElements, onEditorChange, setStorageKey, loadPersistedElements, getLatestEditedElements, setCheckpointId } from "./edit-context";
+import { CommentComposer } from "./comment-ui";
+import { createEditorPersistence } from "./editor-persistence";
+import {
+ convertRawElements,
+ excludeIncompleteLastItem,
+ extractViewportAndElements,
+ fixViewBox4x3,
+ getToolInputElementsSignature,
+ parsePartialElements,
+} from "./scene-data";
+import { anchorsEqual, getSelectionAnchor, type CommentAnchor } from "./selection-anchor";
+import { buildSelectionDetails, buildSelectionSummaryFromCount } from "./selection-utils";
+import { getViewportAppState, type ViewportRect } from "./viewport-utils";
+import { createWidgetContextController, flushModelContext, type WidgetContextController } from "./widget-context";
import "./global.css";
// ============================================================
@@ -16,95 +29,6 @@ function fsLog(msg: string) {
if (_logFn) _logFn(msg);
}
-// ============================================================
-// Shared helpers
-// ============================================================
-
-function parsePartialElements(str: string | undefined): any[] {
- if (!str?.trim().startsWith("[")) return [];
- try { return JSON.parse(str); } catch { /* partial */ }
- const last = str.lastIndexOf("}");
- if (last < 0) return [];
- try { return JSON.parse(str.substring(0, last + 1) + "]"); } catch { /* incomplete */ }
- return [];
-}
-
-function excludeIncompleteLastItem(arr: T[]): T[] {
- if (!arr || arr.length === 0) return [];
- if (arr.length <= 1) return [];
- return arr.slice(0, -1);
-}
-
-interface ViewportRect {
- x: number;
- y: number;
- width: number;
- height: number;
-}
-
-/** Convert raw shorthand elements → Excalidraw format (labels → bound text, font fix).
- * Preserves pseudo-elements like cameraUpdate (not valid Excalidraw types). */
-function convertRawElements(els: any[]): any[] {
- const pseudoTypes = new Set(["cameraUpdate", "delete", "restoreCheckpoint"]);
- const pseudos = els.filter((el: any) => pseudoTypes.has(el.type));
- const real = els.filter((el: any) => !pseudoTypes.has(el.type));
- const withDefaults = real.map((el: any) =>
- el.label ? { ...el, label: { textAlign: "center", verticalAlign: "middle", ...el.label } } : el
- );
- const converted = convertToExcalidrawElements(withDefaults, { regenerateIds: false })
- .map((el: any) => el.type === "text" ? { ...el, fontFamily: (FONT_FAMILY as any).Excalifont ?? 1 } : el);
- return [...converted, ...pseudos];
-}
-
-/** Fix SVG viewBox to 4:3 by expanding the smaller dimension and centering. */
-function fixViewBox4x3(svg: SVGSVGElement): void {
- const vb = svg.getAttribute("viewBox")?.split(" ").map(Number);
- if (!vb || vb.length !== 4) return;
- const [vx, vy, vw, vh] = vb;
- const r = vw / vh;
- if (Math.abs(r - 4 / 3) < 0.01) return;
- if (r > 4 / 3) {
- const h2 = Math.round(vw * 3 / 4);
- svg.setAttribute("viewBox", `${vx} ${vy - Math.round((h2 - vh) / 2)} ${vw} ${h2}`);
- } else {
- const w2 = Math.round(vh * 4 / 3);
- svg.setAttribute("viewBox", `${vx - Math.round((w2 - vw) / 2)} ${vy} ${w2} ${vh}`);
- }
-}
-
-function extractViewportAndElements(elements: any[]): {
- viewport: ViewportRect | null;
- drawElements: any[];
- restoreId: string | null;
- deleteIds: Set;
-} {
- let viewport: ViewportRect | null = null;
- let restoreId: string | null = null;
- const deleteIds = new Set();
- const drawElements: any[] = [];
-
- for (const el of elements) {
- if (el.type === "cameraUpdate") {
- viewport = { x: el.x, y: el.y, width: el.width, height: el.height };
- } else if (el.type === "restoreCheckpoint") {
- restoreId = el.id;
- } else if (el.type === "delete") {
- for (const id of String(el.ids ?? el.id).split(",")) deleteIds.add(id.trim());
- } else {
- drawElements.push(el);
- }
- }
-
- // Hide deleted elements via near-zero opacity instead of removing — preserves SVG
- // group count/order so morphdom matches by position correctly (no cascade re-animations).
- // Using 1 (not 0) because Excalidraw treats opacity:0 as "unset" → defaults to 100.
- const processedDraw = deleteIds.size > 0
- ? drawElements.map((el: any) => (deleteIds.has(el.id) || deleteIds.has(el.containerId)) ? { ...el, opacity: 1 } : el)
- : drawElements;
-
- return { viewport, drawElements: processedDraw, restoreId, deleteIds };
-}
-
const ExpandIcon = () => (