diff --git a/package-lock.json b/package-lock.json index d54d1c6..f428b80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@fastify/cors": "^10.0.2", "@fastify/static": "^8.1.0", "@fastify/websocket": "^11.0.2", + "better-sqlite3": "^12.6.2", "chokidar": "^3.6.0", "fastify": "^5.2.1" }, @@ -47,6 +48,200 @@ "resolved": "packages/shared", "link": true }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.3.tgz", + "integrity": "sha512-Q6mU0Z6bfj6YvnX2k9n0JxiIwrCFN59x/nWmYQnAqP000ruX/yV+5bp/GRcF5T8ncvfwJQ7fgfP74DlpKExILA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.1.tgz", + "integrity": "sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -489,6 +684,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@fastify/accept-negotiator": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz", @@ -713,6 +926,13 @@ "node": ">=18" } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, "node_modules/@lukeed/ms": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", @@ -1083,6 +1303,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/better-sqlite3": { "version": "7.6.13", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", @@ -1093,6 +1320,24 @@ "@types/node": "*" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/earcut": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-3.0.0.tgz", @@ -1116,6 +1361,119 @@ "undici-types": "~6.21.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", + "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "chai": "^6.2.2", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", + "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.0", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", + "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", + "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.0", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", + "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.0", + "@vitest/utils": "4.1.0", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", + "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", + "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.0", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@webgpu/types": { "version": "0.1.69", "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz", @@ -1209,6 +1567,16 @@ "node": ">= 8" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", @@ -1281,6 +1649,16 @@ "node": "20.x || 22.x || 23.x || 24.x || 25.x" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -1361,6 +1739,16 @@ "ieee754": "^1.1.13" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1493,6 +1881,13 @@ "node": ">= 0.6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", @@ -1520,6 +1915,41 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -1605,6 +2035,26 @@ "once": "^1.4.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", @@ -1663,6 +2113,16 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", @@ -1678,6 +2138,16 @@ "node": ">=6" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-decode-uri-component": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", @@ -1949,6 +2419,19 @@ "node": ">=8" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -2062,6 +2545,13 @@ "node": ">=0.12.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2095,6 +2585,47 @@ "integrity": "sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "29.0.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.0.tgz", + "integrity": "sha512-9FshNB6OepopZ08unmmGpsF7/qCjxGPbo3NbgfJAnPeHXnsODE9WWffXZtRFRFe0ntzaAOcSKNJFz8wiyvF1jQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.1", + "@asamuzakjp/dom-selector": "^7.0.2", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.24.3", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/json-schema-ref-resolver": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", @@ -2158,14 +2689,31 @@ "license": "MIT" }, "node_modules/lru-cache": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", - "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/mime": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", @@ -2290,6 +2838,17 @@ "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==", "license": "MIT" }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -2320,6 +2879,19 @@ "integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==", "license": "MIT" }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -2345,6 +2917,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2509,6 +3088,16 @@ "once": "^1.3.1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/quick-format-unescaped": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", @@ -2722,6 +3311,19 @@ "node": ">=10" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/secure-json-parse": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", @@ -2796,6 +3398,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -2881,6 +3490,13 @@ "node": ">= 10.x" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -2890,6 +3506,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stream-shift": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", @@ -2958,6 +3581,13 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tar-fs": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", @@ -3007,6 +3637,23 @@ "node": ">=12" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -3055,6 +3702,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.26", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.26.tgz", + "integrity": "sha512-WiGwQjr0qYdNNG8KpMKlSvpxz652lqa3Rd+/hSaDcY4Uo6SKWZq2LAF+hsAhUewTtYhXlorBKgNF3Kk8hnjGoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.26" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.26", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.26.tgz", + "integrity": "sha512-5WJ2SqFsv4G2Dwi7ZFVRnz6b2H1od39QME1lc2y5Ew3eWiZMAeqOAfWpRP9jHvhUl881406QtZTODvjttJs+ew==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -3085,6 +3762,32 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -3148,6 +3851,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz", + "integrity": "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -3751,6 +4464,149 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vitest": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", + "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.0", + "@vitest/mocker": "4.1.0", + "@vitest/pretty-format": "4.1.0", + "@vitest/runner": "4.1.0", + "@vitest/snapshot": "4.1.0", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "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-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "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.0", + "@vitest/browser-preview": "4.1.0", + "@vitest/browser-webdriverio": "4.1.0", + "@vitest/ui": "4.1.0", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.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 + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3766,6 +4622,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -3811,6 +4684,23 @@ } } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -3858,8 +4748,10 @@ "pixi.js": "^8.6.6" }, "devDependencies": { + "jsdom": "^29.0.0", "typescript": "^5.7.3", - "vite": "^6.1.0" + "vite": "^6.1.0", + "vitest": "^4.1.0" } }, "packages/server": { diff --git a/packages/client/index.html b/packages/client/index.html index 9eaeb2b..dfc6e1a 100644 --- a/packages/client/index.html +++ b/packages/client/index.html @@ -164,6 +164,9 @@ +
diff --git a/packages/client/package.json b/packages/client/package.json index bb3be9c..b433f1b 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -6,14 +6,18 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@agent-move/shared": "*", "pixi.js": "^8.6.6" }, "devDependencies": { + "jsdom": "^29.0.0", + "typescript": "^5.7.3", "vite": "^6.1.0", - "typescript": "^5.7.3" + "vitest": "^4.1.0" } } diff --git a/packages/client/src/main.ts b/packages/client/src/main.ts index a9b1407..92445bf 100644 --- a/packages/client/src/main.ts +++ b/packages/client/src/main.ts @@ -18,6 +18,7 @@ import { Sidebar } from './ui/sidebar.js'; import { ToastManager } from './ui/toast-manager.js'; import { ShortcutsHelp } from './ui/shortcuts-help.js'; import { SessionExport } from './ui/session-export.js'; +import { ScreenshotExport } from './ui/screenshot-export.js'; import { Onboarding } from './ui/onboarding.js'; import { ZONE_MAP, AGENT_PALETTES } from '@agent-move/shared'; @@ -186,6 +187,9 @@ async function main() { // ── Session Export ── const sessionExport = new SessionExport(store); + // ── Screenshot Export ── + const screenshotExport = new ScreenshotExport(pixiApp, world); + // ── Onboarding ── const onboarding = new Onboarding(); @@ -379,6 +383,7 @@ async function main() { break; case 'exit-focus': if (focusModeActive) exitFocusMode(); break; case 'session-export': sessionExport.toggle(); break; + case 'screenshot-export': screenshotExport.toggle(); break; case 'toggle-trails': trails.toggle(); break; case 'toggle-daynight': world.dayNight.toggle(); break; case 'toggle-minimap': minimap.toggle(); break; @@ -416,6 +421,7 @@ async function main() { document.getElementById('zoom-in')!.addEventListener('click', () => world.camera.zoomIn()); document.getElementById('zoom-out')!.addEventListener('click', () => world.camera.zoomOut()); document.getElementById('zoom-reset')!.addEventListener('click', () => world.resetCamera()); + document.getElementById('screenshot-btn')!.addEventListener('click', () => screenshotExport.toggle()); // Audio controls (mute button + volume slider in top bar) const muteBtn = document.getElementById('mute-btn')!; @@ -524,6 +530,7 @@ async function main() { 's': 'toggle-sessions', '[': 'toggle-sidebar', 'S': 'toggle-settings', + 'E': 'screenshot-export', }; document.addEventListener('keydown', (e) => { @@ -554,6 +561,7 @@ async function main() { sessionDetailPanel.dispose(); sessionComparisonPanel.dispose(); minimap.dispose(); + screenshotExport.dispose(); store.dispose(); }); diff --git a/packages/client/src/ui/command-palette.ts b/packages/client/src/ui/command-palette.ts index 0b5659a..6a6f108 100644 --- a/packages/client/src/ui/command-palette.ts +++ b/packages/client/src/ui/command-palette.ts @@ -183,6 +183,14 @@ export class CommandPalette { category: 'feature', action: () => this.onCommand('session-export'), }); + this.actions.push({ + id: 'feature:screenshot', + label: 'Screenshot Export', + description: 'Capture canvas as PNG (Shift+E)', + icon: '📸', + category: 'feature', + action: () => this.onCommand('screenshot-export'), + }); // New features this.actions.push({ diff --git a/packages/client/src/ui/screenshot-export.test.ts b/packages/client/src/ui/screenshot-export.test.ts new file mode 100644 index 0000000..d21deab --- /dev/null +++ b/packages/client/src/ui/screenshot-export.test.ts @@ -0,0 +1,444 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ScreenshotExport } from './screenshot-export.js'; + +// ── Mocks ────────────────────────────────────────────────────────────────────── + +function createMockApp() { + const canvas = document.createElement('canvas'); + // Mock toBlob — calls callback with a real Blob asynchronously + canvas.toBlob = vi.fn((cb: BlobCallback, _type?: string) => { + setTimeout(() => cb(new Blob(['png-data'], { type: 'image/png' })), 0); + }); + + return { + canvas, + render: vi.fn(), + } as any; +} + +function createMockWorld() { + return { + camera: { + getZoom: vi.fn().mockReturnValue(1), + setZoom: vi.fn(), + resetView: vi.fn(), + }, + root: { + position: { x: 0, y: 0, set: vi.fn() }, + }, + worldWidth: 2520, + worldHeight: 2520, + } as any; +} + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +function query(sse: ScreenshotExport, sel: string): HTMLElement { + // Access the private `el` via bracket notation for testing + const el = (sse as any).el as HTMLElement; + return el.querySelector(sel) as HTMLElement; +} + +function allQuery(sse: ScreenshotExport, sel: string): NodeListOf { + const el = (sse as any).el as HTMLElement; + return el.querySelectorAll(sel); +} + +// ── Tests ────────────────────────────────────────────────────────────────────── + +describe('ScreenshotExport', () => { + let sse: ScreenshotExport; + let mockApp: ReturnType; + let mockWorld: ReturnType; + + beforeEach(() => { + mockApp = createMockApp(); + mockWorld = createMockWorld(); + sse = new ScreenshotExport(mockApp, mockWorld); + }); + + afterEach(() => { + sse.dispose(); + }); + + // ── DOM Structure ────────────────────────────────────────────────────────── + + describe('DOM structure', () => { + it('appends root element to document.body', () => { + const el = document.getElementById('screenshot-export'); + expect(el).not.toBeNull(); + }); + + it('has backdrop, modal, header, body, and footer', () => { + expect(query(sse, '.sse-backdrop')).not.toBeNull(); + expect(query(sse, '.sse-modal')).not.toBeNull(); + expect(query(sse, '.sse-header')).not.toBeNull(); + expect(query(sse, '.sse-body')).not.toBeNull(); + expect(query(sse, '.sse-footer')).not.toBeNull(); + }); + + it('has title "Screenshot Export"', () => { + expect(query(sse, '.sse-title').textContent).toBe('Screenshot Export'); + }); + + it('has viewport and full-world mode toggle buttons', () => { + const buttons = allQuery(sse, '.sse-mode-btn'); + expect(buttons).toHaveLength(2); + expect(buttons[0].dataset.mode).toBe('viewport'); + expect(buttons[1].dataset.mode).toBe('full-world'); + }); + + it('has close, copy, and download buttons', () => { + expect(query(sse, '.sse-close')).not.toBeNull(); + expect(query(sse, '.sse-copy-btn')).not.toBeNull(); + expect(query(sse, '.sse-download-btn')).not.toBeNull(); + }); + + it('has preview image and loading indicator', () => { + expect(query(sse, '.sse-preview-img')).not.toBeNull(); + expect(query(sse, '.sse-loading')).not.toBeNull(); + }); + }); + + // ── Open / Close / Toggle ───────────────────────────────────────────────── + + describe('open / close / toggle', () => { + it('starts closed', () => { + const el = (sse as any).el as HTMLElement; + expect(el.classList.contains('open')).toBe(false); + expect((sse as any).isOpen).toBe(false); + }); + + it('open() adds .open class and sets isOpen', () => { + sse.open(); + const el = (sse as any).el as HTMLElement; + expect(el.classList.contains('open')).toBe(true); + expect((sse as any).isOpen).toBe(true); + }); + + it('open() resets to viewport mode', () => { + // Manually set to full-world + (sse as any).currentMode = 'full-world'; + sse.open(); + expect((sse as any).currentMode).toBe('viewport'); + }); + + it('open() triggers a capture (calls canvas.toBlob)', () => { + sse.open(); + expect(mockApp.canvas.toBlob).toHaveBeenCalled(); + }); + + it('close() removes .open class and clears state', () => { + sse.open(); + sse.close(); + const el = (sse as any).el as HTMLElement; + expect(el.classList.contains('open')).toBe(false); + expect((sse as any).isOpen).toBe(false); + expect((sse as any).currentBlob).toBeNull(); + }); + + it('close() clears the preview image src', () => { + sse.open(); + sse.close(); + const img = query(sse, '.sse-preview-img') as HTMLImageElement; + // jsdom resolves empty string to base URL, so check getAttribute + expect(img.getAttribute('src')).toBe(''); + }); + + it('toggle() opens when closed', () => { + sse.toggle(); + expect((sse as any).isOpen).toBe(true); + }); + + it('toggle() closes when open', () => { + sse.open(); + sse.toggle(); + expect((sse as any).isOpen).toBe(false); + }); + }); + + // ── Capture Modes ────────────────────────────────────────────────────────── + + describe('capture modes', () => { + it('viewport capture calls canvas.toBlob with image/png', async () => { + sse.open(); + // Wait for the async toBlob callback + await vi.waitFor(() => { + expect(mockApp.canvas.toBlob).toHaveBeenCalledWith( + expect.any(Function), + 'image/png', + ); + }); + }); + + it('viewport capture stores the blob', async () => { + sse.open(); + await vi.waitFor(() => { + expect((sse as any).currentBlob).toBeInstanceOf(Blob); + }); + }); + + it('full-world capture saves and restores camera state', async () => { + // Mock requestAnimationFrame for full-world capture + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { + cb(0); + return 0; + }); + + mockWorld.camera.getZoom.mockReturnValue(1.5); + mockWorld.root.position.x = 100; + mockWorld.root.position.y = 200; + + sse.open(); + // Switch to full-world mode + const fullBtn = allQuery(sse, '.sse-mode-btn')[1]; + fullBtn.click(); + + await vi.waitFor(() => { + expect(mockWorld.camera.resetView).toHaveBeenCalledWith(2520, 2520); + }); + + await vi.waitFor(() => { + expect(mockWorld.camera.setZoom).toHaveBeenCalledWith(1.5); + expect(mockWorld.root.position.set).toHaveBeenCalledWith(100, 200); + }); + + vi.restoreAllMocks(); + }); + + it('full-world capture forces a render', async () => { + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { + cb(0); + return 0; + }); + + sse.open(); + const fullBtn = allQuery(sse, '.sse-mode-btn')[1]; + fullBtn.click(); + + await vi.waitFor(() => { + expect(mockApp.render).toHaveBeenCalled(); + }); + + vi.restoreAllMocks(); + }); + + it('mode toggle updates active button class', () => { + sse.open(); + const buttons = allQuery(sse, '.sse-mode-btn'); + + // Initially viewport is active + expect(buttons[0].classList.contains('active')).toBe(true); + expect(buttons[1].classList.contains('active')).toBe(false); + + // Click full-world + buttons[1].click(); + expect(buttons[0].classList.contains('active')).toBe(false); + expect(buttons[1].classList.contains('active')).toBe(true); + }); + + it('clicking the already-active mode does not re-capture', () => { + sse.open(); + const callCount = (mockApp.canvas.toBlob as any).mock.calls.length; + + // Click viewport again (already active) + const viewportBtn = allQuery(sse, '.sse-mode-btn')[0]; + viewportBtn.click(); + + // Should not have triggered another capture + expect((mockApp.canvas.toBlob as any).mock.calls.length).toBe(callCount); + }); + }); + + // ── Download ─────────────────────────────────────────────────────────────── + + describe('download', () => { + it('creates a download link with correct filename pattern', async () => { + // Set up a blob + (sse as any).currentBlob = new Blob(['data'], { type: 'image/png' }); + (sse as any).currentMode = 'viewport'; + + const createElementSpy = vi.spyOn(document, 'createElement'); + const mockAnchor = document.createElement('a'); + mockAnchor.click = vi.fn(); + createElementSpy.mockReturnValueOnce(mockAnchor); + + // Trigger download + (sse as any).download(); + + expect(mockAnchor.download).toMatch(/^agent-move-screenshot-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.png$/); + expect(mockAnchor.click).toHaveBeenCalled(); + + createElementSpy.mockRestore(); + }); + + it('appends -full suffix for full-world mode', () => { + (sse as any).currentBlob = new Blob(['data'], { type: 'image/png' }); + (sse as any).currentMode = 'full-world'; + + const createElementSpy = vi.spyOn(document, 'createElement'); + const mockAnchor = document.createElement('a'); + mockAnchor.click = vi.fn(); + createElementSpy.mockReturnValueOnce(mockAnchor); + + (sse as any).download(); + + expect(mockAnchor.download).toMatch(/^agent-move-screenshot-full-/); + + createElementSpy.mockRestore(); + }); + + it('does nothing if no blob is captured', () => { + (sse as any).currentBlob = null; + const createObjectURLSpy = vi.spyOn(URL, 'createObjectURL'); + + (sse as any).download(); + + expect(createObjectURLSpy).not.toHaveBeenCalled(); + createObjectURLSpy.mockRestore(); + }); + + it('shows "Downloaded!" feedback on button', async () => { + (sse as any).currentBlob = new Blob(['data'], { type: 'image/png' }); + + const mockAnchor = document.createElement('a'); + mockAnchor.click = vi.fn(); + vi.spyOn(document, 'createElement').mockReturnValueOnce(mockAnchor); + + (sse as any).download(); + + const btn = query(sse, '.sse-download-btn'); + expect(btn.textContent).toBe('Downloaded!'); + + vi.restoreAllMocks(); + }); + }); + + // ── Copy to Clipboard ────────────────────────────────────────────────────── + + describe('copyToClipboard', () => { + it('calls navigator.clipboard.write with ClipboardItem', async () => { + (sse as any).currentBlob = new Blob(['data'], { type: 'image/png' }); + + const writeMock = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + value: { write: writeMock }, + writable: true, + configurable: true, + }); + + // Mock ClipboardItem as a proper class (jsdom doesn't provide it) + const origClipboardItem = globalThis.ClipboardItem; + class MockClipboardItem { + constructor(public items: Record) {} + } + globalThis.ClipboardItem = MockClipboardItem as any; + + await (sse as any).copyToClipboard(); + + expect(writeMock).toHaveBeenCalled(); + const arg = writeMock.mock.calls[0][0]; + expect(arg).toHaveLength(1); + expect(arg[0]).toBeInstanceOf(MockClipboardItem); + expect(arg[0].items['image/png']).toBeInstanceOf(Blob); + + const btn = query(sse, '.sse-copy-btn'); + expect(btn.textContent).toBe('Copied!'); + + globalThis.ClipboardItem = origClipboardItem; + }); + + it('does nothing if no blob is captured', async () => { + (sse as any).currentBlob = null; + + const writeMock = vi.fn(); + Object.defineProperty(navigator, 'clipboard', { + value: { write: writeMock }, + writable: true, + configurable: true, + }); + + await (sse as any).copyToClipboard(); + + expect(writeMock).not.toHaveBeenCalled(); + }); + + it('falls back to window.open on clipboard failure', async () => { + (sse as any).currentBlob = new Blob(['data'], { type: 'image/png' }); + + Object.defineProperty(navigator, 'clipboard', { + value: { write: vi.fn().mockRejectedValue(new Error('denied')) }, + writable: true, + configurable: true, + }); + + const openSpy = vi.spyOn(window, 'open').mockReturnValue(null); + + await (sse as any).copyToClipboard(); + + expect(openSpy).toHaveBeenCalledWith(expect.any(String), '_blank'); + + const btn = query(sse, '.sse-copy-btn'); + expect(btn.textContent).toBe('Opened in new tab'); + + openSpy.mockRestore(); + }); + }); + + // ── Close Triggers ───────────────────────────────────────────────────────── + + describe('close triggers', () => { + it('clicking backdrop closes the modal', () => { + sse.open(); + query(sse, '.sse-backdrop').click(); + expect((sse as any).isOpen).toBe(false); + }); + + it('clicking close button closes the modal', () => { + sse.open(); + query(sse, '.sse-close').click(); + expect((sse as any).isOpen).toBe(false); + }); + }); + + // ── Dispose ──────────────────────────────────────────────────────────────── + + describe('dispose', () => { + it('removes the element from the DOM', () => { + expect(document.getElementById('screenshot-export')).not.toBeNull(); + sse.dispose(); + expect(document.getElementById('screenshot-export')).toBeNull(); + }); + }); + + // ── Preview image display ────────────────────────────────────────────────── + + describe('preview', () => { + it('shows loading state during capture', () => { + // Before open, loading should not be visible (display style not set initially by DOM) + sse.open(); + // During capture, loading should be flex and img hidden + const loading = query(sse, '.sse-loading'); + const img = query(sse, '.sse-preview-img'); + // At the point of capture start, img is hidden + expect(img.style.display).toBe('none'); + }); + + it('shows preview image after capture completes', async () => { + sse.open(); + await vi.waitFor(() => { + const img = query(sse, '.sse-preview-img') as HTMLImageElement; + expect(img.style.display).toBe('block'); + expect(img.src).not.toBe(''); + }); + }); + + it('hides loading indicator after capture completes', async () => { + sse.open(); + await vi.waitFor(() => { + const loading = query(sse, '.sse-loading'); + expect(loading.style.display).toBe('none'); + }); + }); + }); +}); diff --git a/packages/client/src/ui/screenshot-export.ts b/packages/client/src/ui/screenshot-export.ts new file mode 100644 index 0000000..74d6bf1 --- /dev/null +++ b/packages/client/src/ui/screenshot-export.ts @@ -0,0 +1,210 @@ +import type { Application } from 'pixi.js'; +import type { WorldManager } from '../world/world-manager.js'; + +/** + * Screenshot Export — captures the Pixi canvas as a PNG image. + * Supports viewport (current view) and full-world (all 9 zones) capture modes. + * Shows a preview modal with Download / Copy to Clipboard actions. + */ + +type CaptureMode = 'viewport' | 'full-world'; + +export class ScreenshotExport { + private el: HTMLElement; + private isOpen = false; + private currentBlob: Blob | null = null; + private currentMode: CaptureMode = 'viewport'; + private app: Application; + private world: WorldManager; + + constructor(app: Application, world: WorldManager) { + this.app = app; + this.world = world; + + this.el = document.createElement('div'); + this.el.id = 'screenshot-export'; + + // Build DOM structure safely using static template + // Note: all content here is hardcoded — no user input is interpolated + this.el.innerHTML = [ + '
', + '
', + '
', + ' Screenshot Export', + '
', + ' ', + ' ', + '
', + ' ', + '
', + '
', + '
', + ' Screenshot preview', + '
Capturing...
', + '
', + '
', + ' ', + '
', + ].join('\n'); + + document.body.appendChild(this.el); + + // Event listeners + this.el.querySelector('.sse-backdrop')!.addEventListener('click', () => this.close()); + this.el.querySelector('.sse-close')!.addEventListener('click', () => this.close()); + this.el.querySelector('.sse-copy-btn')!.addEventListener('click', () => this.copyToClipboard()); + this.el.querySelector('.sse-download-btn')!.addEventListener('click', () => this.download()); + + // Mode toggle buttons + this.el.querySelectorAll('.sse-mode-btn').forEach((btn) => { + btn.addEventListener('click', () => { + const mode = (btn as HTMLElement).dataset.mode as CaptureMode; + if (mode !== this.currentMode) { + this.currentMode = mode; + this.updateModeButtons(); + this.capture(); + } + }); + }); + } + + toggle(): void { + if (this.isOpen) this.close(); + else this.open(); + } + + open(): void { + this.isOpen = true; + this.currentMode = 'viewport'; + this.updateModeButtons(); + this.el.classList.add('open'); + this.capture(); + } + + close(): void { + this.isOpen = false; + this.el.classList.remove('open'); + this.currentBlob = null; + // Clear preview + const img = this.el.querySelector('.sse-preview-img') as HTMLImageElement; + img.src = ''; + } + + private updateModeButtons(): void { + this.el.querySelectorAll('.sse-mode-btn').forEach((btn) => { + btn.classList.toggle('active', (btn as HTMLElement).dataset.mode === this.currentMode); + }); + } + + private async capture(): Promise { + const img = this.el.querySelector('.sse-preview-img') as HTMLImageElement; + const loading = this.el.querySelector('.sse-loading') as HTMLElement; + + // Show loading state + img.style.display = 'none'; + loading.style.display = 'flex'; + + try { + if (this.currentMode === 'full-world') { + await this.captureFullWorld(); + } else { + await this.captureViewport(); + } + + // Show preview + if (this.currentBlob) { + const url = URL.createObjectURL(this.currentBlob); + img.onload = () => URL.revokeObjectURL(url); + img.src = url; + img.style.display = 'block'; + } + } catch (err) { + console.error('Screenshot capture failed:', err); + } finally { + loading.style.display = 'none'; + } + } + + private captureViewport(): Promise { + return new Promise((resolve) => { + const canvas = this.app.canvas as HTMLCanvasElement; + canvas.toBlob((blob) => { + this.currentBlob = blob; + resolve(); + }, 'image/png'); + }); + } + + private async captureFullWorld(): Promise { + const camera = this.world.camera; + const root = this.world.root; + + // Save current camera state + const savedZoom = camera.getZoom(); + const savedX = root.position.x; + const savedY = root.position.y; + + // Temporarily fit the entire world into view + camera.resetView(this.world.worldWidth, this.world.worldHeight); + + // Wait a frame for the render to update + await new Promise((resolve) => { + requestAnimationFrame(() => { + // Force a render + this.app.render(); + resolve(); + }); + }); + + // Capture the canvas + await this.captureViewport(); + + // Restore camera state + camera.setZoom(savedZoom); + root.position.set(savedX, savedY); + } + + private async copyToClipboard(): Promise { + if (!this.currentBlob) return; + + const btn = this.el.querySelector('.sse-copy-btn') as HTMLButtonElement; + try { + await navigator.clipboard.write([ + new ClipboardItem({ 'image/png': this.currentBlob }), + ]); + btn.textContent = 'Copied!'; + setTimeout(() => { btn.textContent = 'Copy to Clipboard'; }, 2000); + } catch { + // Fallback: open the image in a new tab + const url = URL.createObjectURL(this.currentBlob); + window.open(url, '_blank'); + setTimeout(() => URL.revokeObjectURL(url), 10000); + btn.textContent = 'Opened in new tab'; + setTimeout(() => { btn.textContent = 'Copy to Clipboard'; }, 2000); + } + } + + private download(): void { + if (!this.currentBlob) return; + + const url = URL.createObjectURL(this.currentBlob); + const a = document.createElement('a'); + a.href = url; + const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-'); + const suffix = this.currentMode === 'full-world' ? '-full' : ''; + a.download = `agent-move-screenshot${suffix}-${timestamp}.png`; + a.click(); + URL.revokeObjectURL(url); + + const btn = this.el.querySelector('.sse-download-btn') as HTMLButtonElement; + btn.textContent = 'Downloaded!'; + setTimeout(() => { btn.textContent = 'Download PNG'; }, 2000); + } + + dispose(): void { + this.el.remove(); + } +} diff --git a/packages/client/src/ui/shortcuts-help.ts b/packages/client/src/ui/shortcuts-help.ts index 4457946..7e2637d 100644 --- a/packages/client/src/ui/shortcuts-help.ts +++ b/packages/client/src/ui/shortcuts-help.ts @@ -32,6 +32,7 @@ const SHORTCUT_GROUPS: ShortcutGroup[] = [ { keys: 'A', description: 'Toggle analytics panel' }, { keys: 'H', description: 'Toggle activity heatmap' }, { keys: 'E', description: 'Export session summary' }, + { keys: 'Shift + E', description: 'Screenshot export' }, { keys: 'M', description: 'Toggle sound mute' }, ], }, diff --git a/packages/client/styles/modals.css b/packages/client/styles/modals.css index 1427ee0..d35bd84 100644 --- a/packages/client/styles/modals.css +++ b/packages/client/styles/modals.css @@ -79,6 +79,32 @@ .se-footer .se-copy-btn { background: var(--accent-dim); color: var(--accent); border-color: rgba(233, 69, 96, 0.3); } .se-footer .se-copy-btn:hover { background: rgba(233, 69, 96, 0.25); } +/* ═══════════════════════════════════════════ + SCREENSHOT EXPORT + ═══════════════════════════════════════════ */ +#screenshot-export { position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 100; display: none; font-family: var(--font-ui); } +#screenshot-export.open { display: block; } +.sse-backdrop { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); backdrop-filter: blur(4px); } +.sse-modal { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 700px; max-width: 90vw; max-height: 85vh; background: rgba(20, 22, 44, 0.98); border: 1px solid var(--border-medium); border-radius: 14px; box-shadow: 0 20px 60px rgba(0,0,0,0.6); display: flex; flex-direction: column; overflow: hidden; } +.sse-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px 12px; border-bottom: 1px solid var(--border-subtle); gap: 12px; } +.sse-title { font-size: var(--text-lg); font-weight: 700; color: var(--text-primary); } +.sse-mode-toggle { display: flex; gap: 0; border: 1px solid var(--border-light); border-radius: var(--radius-md); overflow: hidden; } +.sse-mode-btn { height: 28px; padding: 0 12px; border: none; background: var(--surface-2); color: var(--text-dim); font-family: var(--font-ui); font-size: 11px; font-weight: 500; cursor: pointer; transition: background 0.15s, color 0.15s; } +.sse-mode-btn:not(:last-child) { border-right: 1px solid var(--border-light); } +.sse-mode-btn.active { background: var(--accent-dim); color: var(--accent); } +.sse-mode-btn:hover:not(.active) { background: var(--surface-3); color: var(--text-secondary); } +.sse-close { width: 28px; height: 28px; border: none; background: var(--surface-3); border-radius: var(--radius-sm); color: var(--text-dim); font-size: 18px; cursor: pointer; display: flex; align-items: center; justify-content: center; flex-shrink: 0; } +.sse-close:hover { background: var(--surface-bright); color: var(--text-primary); } +.sse-body { flex: 1; overflow-y: auto; padding: 16px 20px; display: flex; align-items: center; justify-content: center; } +.sse-preview { position: relative; width: 100%; display: flex; align-items: center; justify-content: center; min-height: 200px; background: rgba(0,0,0,0.2); border-radius: var(--radius-md); border: 1px solid var(--surface-2); overflow: hidden; } +.sse-preview-img { max-width: 100%; max-height: 60vh; border-radius: var(--radius-sm); display: none; } +.sse-loading { display: flex; align-items: center; justify-content: center; color: var(--text-dim); font-size: var(--text-sm); padding: 40px; } +.sse-footer { display: flex; gap: 8px; padding: 12px 20px; border-top: 1px solid var(--border-subtle); } +.sse-footer button { height: var(--btn-height-md); border: 1px solid var(--border-light); border-radius: var(--radius-md); background: var(--surface-3); color: var(--text-secondary); font-family: var(--font-ui); font-size: 12px; font-weight: 500; cursor: pointer; padding: 0 16px; transition: background 0.15s, color 0.15s, border-color 0.15s; } +.sse-footer button:hover { background: var(--surface-bright); color: var(--text-primary); } +.sse-footer .sse-copy-btn { background: var(--accent-dim); color: var(--accent); border-color: rgba(233, 69, 96, 0.3); } +.sse-footer .sse-copy-btn:hover { background: rgba(233, 69, 96, 0.25); } + /* ═══════════════════════════════════════════ AGENT CUSTOMIZER ═══════════════════════════════════════════ */ diff --git a/packages/client/vitest.config.ts b/packages/client/vitest.config.ts new file mode 100644 index 0000000..9c90b35 --- /dev/null +++ b/packages/client/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'jsdom', + include: ['src/**/*.test.ts'], + globals: true, + }, +});