diff --git a/web/.gitignore b/web/.gitignore
new file mode 100644
index 00000000..efd9fc48
--- /dev/null
+++ b/web/.gitignore
@@ -0,0 +1,5 @@
+node_modules
+dist
+*.local
+.DS_Store
+*.tsbuildinfo
diff --git a/web/.oxlintrc.json b/web/.oxlintrc.json
new file mode 100644
index 00000000..7798f297
--- /dev/null
+++ b/web/.oxlintrc.json
@@ -0,0 +1,9 @@
+{
+ "$schema": "./node_modules/oxlint/configuration_schema.json",
+ "plugins": ["react", "typescript", "oxc", "jsx-a11y"],
+ "rules": {
+ "react/rules-of-hooks": "error",
+ "react/only-export-components": "off",
+ "jsx-a11y/prefer-tag-over-role": "off"
+ }
+}
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 00000000..d8993812
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Sample Platform
+
+
+
+
+
+
diff --git a/web/package-lock.json b/web/package-lock.json
new file mode 100644
index 00000000..dedf64b2
--- /dev/null
+++ b/web/package-lock.json
@@ -0,0 +1,2954 @@
+{
+ "name": "web",
+ "version": "0.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "web",
+ "version": "0.0.0",
+ "dependencies": {
+ "@fontsource-variable/inter": "^5.2.8",
+ "@fontsource/jetbrains-mono": "^5.2.8",
+ "@radix-ui/react-dialog": "^1.1.18",
+ "@radix-ui/react-dropdown-menu": "^2.1.19",
+ "@radix-ui/react-popover": "^1.1.18",
+ "@radix-ui/react-select": "^2.3.2",
+ "@radix-ui/react-switch": "^1.3.2",
+ "@radix-ui/react-tooltip": "^1.2.11",
+ "@tailwindcss/vite": "^4.3.2",
+ "@tanstack/react-query": "^5.101.2",
+ "@tanstack/react-router": "^1.170.17",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "lucide-react": "^1.23.0",
+ "motion": "^12.42.2",
+ "react": "^19.2.7",
+ "react-dom": "^19.2.7",
+ "tailwind-merge": "^3.6.0",
+ "tailwindcss": "^4.3.2"
+ },
+ "devDependencies": {
+ "@types/node": "^24.13.2",
+ "@types/react": "^19.2.17",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^6.0.3",
+ "oxlint": "^1.71.0",
+ "typescript": "~6.0.2",
+ "vite": "^8.1.1"
+ }
+ },
+ "node_modules/@emnapi/core": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
+ "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.2",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
+ "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
+ "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@floating-ui/core": {
+ "version": "1.7.5",
+ "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
+ "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/utils": "^0.2.11"
+ }
+ },
+ "node_modules/@floating-ui/dom": {
+ "version": "1.7.6",
+ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
+ "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/core": "^1.7.5",
+ "@floating-ui/utils": "^0.2.11"
+ }
+ },
+ "node_modules/@floating-ui/react-dom": {
+ "version": "2.1.8",
+ "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz",
+ "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/dom": "^1.7.6"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@floating-ui/utils": {
+ "version": "0.2.11",
+ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
+ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
+ "license": "MIT"
+ },
+ "node_modules/@fontsource-variable/inter": {
+ "version": "5.2.8",
+ "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz",
+ "integrity": "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==",
+ "license": "OFL-1.1",
+ "funding": {
+ "url": "https://github.com/sponsors/ayuhito"
+ }
+ },
+ "node_modules/@fontsource/jetbrains-mono": {
+ "version": "5.2.8",
+ "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
+ "integrity": "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==",
+ "license": "OFL-1.1",
+ "funding": {
+ "url": "https://github.com/sponsors/ayuhito"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "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==",
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
+ "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.3"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.138.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz",
+ "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@oxlint/binding-android-arm-eabi": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.72.0.tgz",
+ "integrity": "sha512-zhCmvn+1Mj3UchAc/90i99S0t7jJUsHmFVSPg4UWrjO8b8eaSGwscgO6QAUtvHBstkjQwBttQNswEnAF1mIQdA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-android-arm64": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.72.0.tgz",
+ "integrity": "sha512-mtH+aY/ozv1eZoCUC2owjFAtyNBKHpJHygKeEu9zXXnQGW1Q2/qOpvx+I+Lf23+TvTz66F4iiXUbl2cGvoLPCQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-darwin-arm64": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.72.0.tgz",
+ "integrity": "sha512-EvnajNPDtfknB3ZieeOOyDTwJn9QXDiwfnF4ZDQqART6RG6hjY4WigQcZdGoK2dkB3e1vrmEzN9aYbQCUkh/gQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-darwin-x64": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.72.0.tgz",
+ "integrity": "sha512-ZkCdEa/G80A7vEHfeCDz/+L3m33DE73v32mDKhgOIgz8Uwf0DFcK7+uu6qC+7LEhmz5fpOe1osWKyjSNMydFIQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-freebsd-x64": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.72.0.tgz",
+ "integrity": "sha512-NroXv2vh+sxVY1uya/rM5pjhx1hm8BzlYpx9q67QP0Xhw5MH2bf5GJylpvLEC+781p1Xli/317EoV9AlGwViag==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-arm-gnueabihf": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.72.0.tgz",
+ "integrity": "sha512-0NDywYgfj279Ou/BcQuCYSj7NJwBfmWn5qc5uGO/Ny7fUWmXyIpvawqX/8acQlWG6IXelJsJhj+JAy6sjsKj0A==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-arm-musleabihf": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.72.0.tgz",
+ "integrity": "sha512-4vpXB06h65Ezsy4hRyrGjGrfa1SkVPii09yaajiYhmVpgsFiLD+KNxIx/BNAY+XiO+i1yqp9HHdwqM8VTqa5XQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-arm64-gnu": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.72.0.tgz",
+ "integrity": "sha512-immaN4g2ZGFiOkKrvRX9LvzZdd2GkQM5wR+UyzYyUuyhUTXGQ4HKUJH18xp4G8OfhCVaVAJfKZxwE1r8+4hhaQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-arm64-musl": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.72.0.tgz",
+ "integrity": "sha512-JGHS9Mnr7iWyyLDxgCv1MhzVpAckgptg00F2gnxt/GD7lQ2SW1BRcxHqhSTaSdDpjWRrBkBxMMh4+Hn3aVtExg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-ppc64-gnu": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.72.0.tgz",
+ "integrity": "sha512-AOYgBZqxNshrg83P9v0RYv+m8s10Cqkj4/PxXFDhcS3k7FqsIG5+CxErshZCIN7G8iy4Y+VGfAsuEdar8AcbBg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-riscv64-gnu": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.72.0.tgz",
+ "integrity": "sha512-QMybPS5ij3/vrKG67mqzHwW++91sYxK/PPUVi6SBtNCEzW4niS52fVBdXbQ6nou0wWbUPEpx8Sl/ZjtgE3clXA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-riscv64-musl": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.72.0.tgz",
+ "integrity": "sha512-gOc3W7JV0PXRpIL7stUlLe3Wa9Gp0Kdlup87IT3gHDvPKck2xNgMIl/Gs2lldYY2lyXZDC4rWi3hmoLUobkgbQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-s390x-gnu": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.72.0.tgz",
+ "integrity": "sha512-rpGxph+FjjHcYI5q6uxB3Az+tnfmEnDbSA8+PK9ZE/VzyUAkvBOMeuY7ZQMhu5mpZH7YQDsTdW6Cx4kV/msc6w==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-x64-gnu": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.72.0.tgz",
+ "integrity": "sha512-WND+uhf/Ko13SLqQMWQUgsZuLvYYEvL0ZKgg0tgGYfLqxG7l8Ju123fHDMJyYSDl5E3bUbpFUuii/OvMreFQzw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-x64-musl": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.72.0.tgz",
+ "integrity": "sha512-SrpbrUL70nG9vh6zP4/oKHWgLuHquwsr7MW9XOn0olBVgh10Uqr8qscKhQoBGEn6olK/IUpn5GSKcdQ5AjUhGA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-openharmony-arm64": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.72.0.tgz",
+ "integrity": "sha512-qkrsEn6NmgFKr7U/QnezQMb+q/vzAy0Dd9Y95gQGQTyjzDLN+HRZMuM5u70iyH4nBLCfKBzhjMsYCehKay2jyg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-win32-arm64-msvc": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.72.0.tgz",
+ "integrity": "sha512-LWR6ZlFZph+KPjXv8opgZsXRDCdrdQe8VL8Cg9zxCoBS73h6znzZpydVgmdnwj8mB9AuSM5jxEgDJDpQkjboeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-win32-ia32-msvc": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.72.0.tgz",
+ "integrity": "sha512-yt6HEh7IsHvtjRWtmeZRX134eaXKHq5Gnqlf1xBJdJl1JtdoRUEJw3nAxpZoUDS860cX/foKbztO441anVBtVQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-win32-x64-msvc": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.72.0.tgz",
+ "integrity": "sha512-b2eKFD2hX7tIwmo/cyH6TDq8vzWRZ2qNHrzoGntUTmq0h3zQh/uX3eTSHCwI8OB/ADQfJCRelLItK8BsxuucDA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@radix-ui/number": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.2.tgz",
+ "integrity": "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==",
+ "license": "MIT"
+ },
+ "node_modules/@radix-ui/primitive": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz",
+ "integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==",
+ "license": "MIT"
+ },
+ "node_modules/@radix-ui/react-arrow": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.11.tgz",
+ "integrity": "sha512-Kdil9BB1rIFC/khmf4hC35bn8701AJcizTU7G7cUbEbk5XqqbjDuHW60uUfKqO5WojjZcbAW51Q7P0hRmMLw8A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.7"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-collection": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.11.tgz",
+ "integrity": "sha512-djW9+zeg137KQdlPtmE8xnaD+K2rcXXMWFrSg0hsmYZ6HRbdTA7tDHFgpaW9+huWVEu0RCabL+985T4TA0BE7g==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.3",
+ "@radix-ui/react-context": "1.1.4",
+ "@radix-ui/react-primitive": "2.1.7",
+ "@radix-ui/react-slot": "1.3.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-compose-refs": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz",
+ "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-context": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz",
+ "integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog": {
+ "version": "1.1.18",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.18.tgz",
+ "integrity": "sha512-apa28mldjMgORmE6g/w3sCcA0Y9UAVeeDVoozN4i7kOw12mLl9RBchfzK3Nn6qxOWjrZhK1Lfy7f07kyzxtnBw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.4",
+ "@radix-ui/react-compose-refs": "1.1.3",
+ "@radix-ui/react-context": "1.1.4",
+ "@radix-ui/react-dismissable-layer": "1.1.14",
+ "@radix-ui/react-focus-guards": "1.1.4",
+ "@radix-ui/react-focus-scope": "1.1.11",
+ "@radix-ui/react-id": "1.1.2",
+ "@radix-ui/react-portal": "1.1.13",
+ "@radix-ui/react-presence": "1.1.6",
+ "@radix-ui/react-primitive": "2.1.7",
+ "@radix-ui/react-slot": "1.3.0",
+ "@radix-ui/react-use-controllable-state": "1.2.3",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.7.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-direction": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.2.tgz",
+ "integrity": "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dismissable-layer": {
+ "version": "1.1.14",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.14.tgz",
+ "integrity": "sha512-4lUhWTWAjbDIqFrAPWJ3WqBOpO5YchVZ88X3nh6H9Lu5AFi5nCUeTPj3D8FSDmabmFeRe9ME0BDA4MwKTha5GQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.4",
+ "@radix-ui/react-compose-refs": "1.1.3",
+ "@radix-ui/react-primitive": "2.1.7",
+ "@radix-ui/react-use-callback-ref": "1.1.2",
+ "@radix-ui/react-use-effect-event": "0.0.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dropdown-menu": {
+ "version": "2.1.19",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.19.tgz",
+ "integrity": "sha512-HZccBkbK0LOi8nYKIp5jll/zIRW0cCOmG6WWyqsSpmXCU+ZlcBbTqIwlBvPCu886C5RVu6c/kHV7xSP8IgYNHw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.4",
+ "@radix-ui/react-compose-refs": "1.1.3",
+ "@radix-ui/react-context": "1.1.4",
+ "@radix-ui/react-id": "1.1.2",
+ "@radix-ui/react-menu": "2.1.19",
+ "@radix-ui/react-primitive": "2.1.7",
+ "@radix-ui/react-use-controllable-state": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-focus-guards": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz",
+ "integrity": "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-focus-scope": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.11.tgz",
+ "integrity": "sha512-Mn88Vg2whaRocGJNOH+DKFqYm6ySFPQaiwHNxZPyjn99B52KAEJWWY9NP83+nWdk2HM3rdov+STu9AG471Rt9w==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.3",
+ "@radix-ui/react-primitive": "2.1.7",
+ "@radix-ui/react-use-callback-ref": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-id": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz",
+ "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-menu": {
+ "version": "2.1.19",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.19.tgz",
+ "integrity": "sha512-Mht9BVd1AIsNFVQr4KG3bIK7XQn5IXF0TL/2ObsrzOdc1loaly/+kBDL5roSCYn8j8XZkvpOD0WYLz2FQtH1Eg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.4",
+ "@radix-ui/react-collection": "1.1.11",
+ "@radix-ui/react-compose-refs": "1.1.3",
+ "@radix-ui/react-context": "1.1.4",
+ "@radix-ui/react-direction": "1.1.2",
+ "@radix-ui/react-dismissable-layer": "1.1.14",
+ "@radix-ui/react-focus-guards": "1.1.4",
+ "@radix-ui/react-focus-scope": "1.1.11",
+ "@radix-ui/react-id": "1.1.2",
+ "@radix-ui/react-popper": "1.3.2",
+ "@radix-ui/react-portal": "1.1.13",
+ "@radix-ui/react-presence": "1.1.6",
+ "@radix-ui/react-primitive": "2.1.7",
+ "@radix-ui/react-roving-focus": "1.1.14",
+ "@radix-ui/react-slot": "1.3.0",
+ "@radix-ui/react-use-callback-ref": "1.1.2",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.7.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-popover": {
+ "version": "1.1.18",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.18.tgz",
+ "integrity": "sha512-qdXDes+eHlnMUGlBAAAe5EG7oOQvqsXuq4mq585diMudg80iB+jHbsSeG3+Q4eWNsogNyhqU2p/3i+Y0iEepqg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.4",
+ "@radix-ui/react-compose-refs": "1.1.3",
+ "@radix-ui/react-context": "1.1.4",
+ "@radix-ui/react-dismissable-layer": "1.1.14",
+ "@radix-ui/react-focus-guards": "1.1.4",
+ "@radix-ui/react-focus-scope": "1.1.11",
+ "@radix-ui/react-id": "1.1.2",
+ "@radix-ui/react-popper": "1.3.2",
+ "@radix-ui/react-portal": "1.1.13",
+ "@radix-ui/react-presence": "1.1.6",
+ "@radix-ui/react-primitive": "2.1.7",
+ "@radix-ui/react-slot": "1.3.0",
+ "@radix-ui/react-use-controllable-state": "1.2.3",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.7.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-popper": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.2.tgz",
+ "integrity": "sha512-3QXNeMkdshed1MR3LNoiCirBywRFPkD8ETJa/HlPuLwSajaQixf2ro+isoDNJlGABg9ug41XuZpINZJIle4XWg==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/react-dom": "^2.0.0",
+ "@radix-ui/react-arrow": "1.1.11",
+ "@radix-ui/react-compose-refs": "1.1.3",
+ "@radix-ui/react-context": "1.1.4",
+ "@radix-ui/react-primitive": "2.1.7",
+ "@radix-ui/react-use-callback-ref": "1.1.2",
+ "@radix-ui/react-use-layout-effect": "1.1.2",
+ "@radix-ui/react-use-rect": "1.1.2",
+ "@radix-ui/react-use-size": "1.1.2",
+ "@radix-ui/rect": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-portal": {
+ "version": "1.1.13",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.13.tgz",
+ "integrity": "sha512-z3oXfmaHLJTF1wktbjgD6cn9jiEbq3WSondB10LIuIt2m2Ym4iJlrW04/euMwENDdWDdE7z+OuY7Qyp1YpRSwA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.7",
+ "@radix-ui/react-use-layout-effect": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-presence": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.6.tgz",
+ "integrity": "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-primitive": {
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.7.tgz",
+ "integrity": "sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.3.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-roving-focus": {
+ "version": "1.1.14",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.14.tgz",
+ "integrity": "sha512-8Qcnx9447tx/aCBgw6Jenfqg4Skq+vqab9mCBmuGNipIS5YXvL275wbKEu7+ICYHIlAPgCduUMJH1XOYewKF6Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.4",
+ "@radix-ui/react-collection": "1.1.11",
+ "@radix-ui/react-compose-refs": "1.1.3",
+ "@radix-ui/react-context": "1.1.4",
+ "@radix-ui/react-direction": "1.1.2",
+ "@radix-ui/react-id": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.7",
+ "@radix-ui/react-use-callback-ref": "1.1.2",
+ "@radix-ui/react-use-controllable-state": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-select": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.2.tgz",
+ "integrity": "sha512-brXD6C/V0fVK0DDbscLVw6LsXrjQ+ay8jdOBaN+tLb4vsHsAMm6Gt6eT77wHX1Eq8GPtD5rJ+RxFtfDozsb4+Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/number": "1.1.2",
+ "@radix-ui/primitive": "1.1.4",
+ "@radix-ui/react-collection": "1.1.11",
+ "@radix-ui/react-compose-refs": "1.1.3",
+ "@radix-ui/react-context": "1.1.4",
+ "@radix-ui/react-direction": "1.1.2",
+ "@radix-ui/react-dismissable-layer": "1.1.14",
+ "@radix-ui/react-focus-guards": "1.1.4",
+ "@radix-ui/react-focus-scope": "1.1.11",
+ "@radix-ui/react-id": "1.1.2",
+ "@radix-ui/react-popper": "1.3.2",
+ "@radix-ui/react-portal": "1.1.13",
+ "@radix-ui/react-presence": "1.1.6",
+ "@radix-ui/react-primitive": "2.1.7",
+ "@radix-ui/react-slot": "1.3.0",
+ "@radix-ui/react-use-callback-ref": "1.1.2",
+ "@radix-ui/react-use-controllable-state": "1.2.3",
+ "@radix-ui/react-use-layout-effect": "1.1.2",
+ "@radix-ui/react-use-previous": "1.1.2",
+ "@radix-ui/react-visually-hidden": "1.2.7",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.7.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-slot": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.0.tgz",
+ "integrity": "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-switch": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.3.2.tgz",
+ "integrity": "sha512-tgRBI3DdNwAJYE4BBZyZcz/HRRCvAsPkRvG1wvKc+41tBGMxPn/a87T/wikXAvyDypNQ9kaZwHbeZe+veHCGpA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.4",
+ "@radix-ui/react-compose-refs": "1.1.3",
+ "@radix-ui/react-context": "1.1.4",
+ "@radix-ui/react-primitive": "2.1.7",
+ "@radix-ui/react-use-controllable-state": "1.2.3",
+ "@radix-ui/react-use-previous": "1.1.2",
+ "@radix-ui/react-use-size": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-tooltip": {
+ "version": "1.2.11",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.11.tgz",
+ "integrity": "sha512-8XZ6Py3y3W2nEzAUGCN5cfVKaUi+CVApcz1d6lrNVVf2hvYEixMRkq8k9ggPKnQUpRRuOV5avt8uvxViH2jLwA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.4",
+ "@radix-ui/react-compose-refs": "1.1.3",
+ "@radix-ui/react-context": "1.1.4",
+ "@radix-ui/react-dismissable-layer": "1.1.14",
+ "@radix-ui/react-id": "1.1.2",
+ "@radix-ui/react-popper": "1.3.2",
+ "@radix-ui/react-portal": "1.1.13",
+ "@radix-ui/react-presence": "1.1.6",
+ "@radix-ui/react-primitive": "2.1.7",
+ "@radix-ui/react-slot": "1.3.0",
+ "@radix-ui/react-use-controllable-state": "1.2.3",
+ "@radix-ui/react-visually-hidden": "1.2.7"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-callback-ref": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz",
+ "integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-controllable-state": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz",
+ "integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-effect-event": "0.0.3",
+ "@radix-ui/react-use-layout-effect": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-effect-event": {
+ "version": "0.0.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz",
+ "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-layout-effect": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz",
+ "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-previous": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.2.tgz",
+ "integrity": "sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-rect": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.2.tgz",
+ "integrity": "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/rect": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-size": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.2.tgz",
+ "integrity": "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-visually-hidden": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.7.tgz",
+ "integrity": "sha512-1wNZBggTDK3GRuuQ6nP4k2yi7a6l7I5qbMPbZcRsrGsGVead/f/d5FhEzUvqFs0bcrDLx7n1zKQ3JvLR6whaaw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.7"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/rect": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.2.tgz",
+ "integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==",
+ "license": "MIT"
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
+ "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz",
+ "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz",
+ "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz",
+ "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz",
+ "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz",
+ "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz",
+ "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz",
+ "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz",
+ "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz",
+ "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz",
+ "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz",
+ "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz",
+ "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "1.11.1",
+ "@emnapi/runtime": "1.11.1",
+ "@napi-rs/wasm-runtime": "^1.1.6"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz",
+ "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz",
+ "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "license": "MIT"
+ },
+ "node_modules/@tailwindcss/node": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz",
+ "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.5",
+ "enhanced-resolve": "5.21.6",
+ "jiti": "^2.7.0",
+ "lightningcss": "1.32.0",
+ "magic-string": "^0.30.21",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.3.2"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz",
+ "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.3.2",
+ "@tailwindcss/oxide-darwin-arm64": "4.3.2",
+ "@tailwindcss/oxide-darwin-x64": "4.3.2",
+ "@tailwindcss/oxide-freebsd-x64": "4.3.2",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.3.2",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.3.2",
+ "@tailwindcss/oxide-linux-x64-musl": "4.3.2",
+ "@tailwindcss/oxide-wasm32-wasi": "4.3.2",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.3.2"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-android-arm64": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz",
+ "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz",
+ "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz",
+ "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz",
+ "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz",
+ "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz",
+ "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz",
+ "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz",
+ "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz",
+ "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz",
+ "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==",
+ "bundleDependencies": [
+ "@napi-rs/wasm-runtime",
+ "@emnapi/core",
+ "@emnapi/runtime",
+ "@tybys/wasm-util",
+ "@emnapi/wasi-threads",
+ "tslib"
+ ],
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.11.1",
+ "@emnapi/runtime": "^1.11.1",
+ "@emnapi/wasi-threads": "^1.2.2",
+ "@napi-rs/wasm-runtime": "^1.1.4",
+ "@tybys/wasm-util": "^0.10.2",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz",
+ "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz",
+ "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/vite": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz",
+ "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==",
+ "license": "MIT",
+ "dependencies": {
+ "@tailwindcss/node": "4.3.2",
+ "@tailwindcss/oxide": "4.3.2",
+ "tailwindcss": "4.3.2"
+ },
+ "peerDependencies": {
+ "vite": "^5.2.0 || ^6 || ^7 || ^8"
+ }
+ },
+ "node_modules/@tanstack/history": {
+ "version": "1.162.0",
+ "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.162.0.tgz",
+ "integrity": "sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/query-core": {
+ "version": "5.101.2",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz",
+ "integrity": "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/react-query": {
+ "version": "5.101.2",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.2.tgz",
+ "integrity": "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/query-core": "5.101.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19"
+ }
+ },
+ "node_modules/@tanstack/react-router": {
+ "version": "1.170.17",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.170.17.tgz",
+ "integrity": "sha512-ppLkjCfSMaeug9rmFRYzOd4TIqWV+yTE7tzIny7alJsSnM7w4lzEZm6eqCehG0SPetpZ0R3K+UnanSmBgOAVcQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/history": "1.162.0",
+ "@tanstack/react-store": "^0.9.3",
+ "@tanstack/router-core": "1.171.14",
+ "isbot": "^5.1.22"
+ },
+ "engines": {
+ "node": ">=20.19"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": ">=18.0.0 || >=19.0.0",
+ "react-dom": ">=18.0.0 || >=19.0.0"
+ }
+ },
+ "node_modules/@tanstack/react-store": {
+ "version": "0.9.3",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.3.tgz",
+ "integrity": "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/store": "0.9.3",
+ "use-sync-external-store": "^1.6.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/@tanstack/router-core": {
+ "version": "1.171.14",
+ "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.171.14.tgz",
+ "integrity": "sha512-Mo3hwx0qB0cJsVYGDjG0+Ouf7VV74h/vsoDMGztdlyzDanp4gBA2s7IVvm6hFrmQM6GpD9F0Z7SqD7OldfLE7g==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/history": "1.162.0",
+ "cookie-es": "^3.0.0",
+ "seroval": "^1.5.4",
+ "seroval-plugins": "^1.5.4"
+ },
+ "engines": {
+ "node": ">=20.19"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/store": {
+ "version": "0.9.3",
+ "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.3.tgz",
+ "integrity": "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
+ "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "24.13.2",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
+ "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.18.0"
+ }
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.17",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
+ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.3",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
+ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+ "devOptional": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz",
+ "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "^1.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
+ "babel-plugin-react-compiler": "^1.0.0",
+ "vite": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@rolldown/plugin-babel": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/aria-hidden": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
+ "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/class-variance-authority": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
+ "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "clsx": "^2.1.1"
+ },
+ "funding": {
+ "url": "https://polar.sh/cva"
+ }
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/cookie-es": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz",
+ "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==",
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/detect-node-es": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
+ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
+ "license": "MIT"
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.21.6",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz",
+ "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==",
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.3.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/framer-motion": {
+ "version": "12.42.2",
+ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.42.2.tgz",
+ "integrity": "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-dom": "^12.42.2",
+ "motion-utils": "^12.39.0",
+ "tslib": "^2.4.0"
+ },
+ "peerDependencies": {
+ "@emotion/is-prop-valid": "*",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/is-prop-valid": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/get-nonce": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
+ "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "license": "ISC"
+ },
+ "node_modules/isbot": {
+ "version": "5.1.44",
+ "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.44.tgz",
+ "integrity": "sha512-PGEHtwMnKbZpeSEXW2Utx+/JWed7dp6DiH0WWg33vGSDA7RUvpUeJSVlLrVkQ1RCpvDOUc/eH9ql7VsdbBZ8pA==",
+ "license": "Unlicense",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/jiti": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
+ "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lucide-react": {
+ "version": "1.23.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.23.0.tgz",
+ "integrity": "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "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==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/motion": {
+ "version": "12.42.2",
+ "resolved": "https://registry.npmjs.org/motion/-/motion-12.42.2.tgz",
+ "integrity": "sha512-Atvv11yUKIid41cVrRBDVX5m8tF8kNpExRSlbpt6APClhDjtwQssgFHhQzejxw7/7YYbjHSPKBVbHo05BuJT5Q==",
+ "license": "MIT",
+ "dependencies": {
+ "framer-motion": "^12.42.2",
+ "tslib": "^2.4.0"
+ },
+ "peerDependencies": {
+ "@emotion/is-prop-valid": "*",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/is-prop-valid": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/motion-dom": {
+ "version": "12.42.2",
+ "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.42.2.tgz",
+ "integrity": "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-utils": "^12.39.0"
+ }
+ },
+ "node_modules/motion-utils": {
+ "version": "12.39.0",
+ "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz",
+ "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==",
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.18",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
+ "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/oxlint": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.72.0.tgz",
+ "integrity": "sha512-1rhdZIP/EvoI91ABIwNU5Q8+bWf8mjrS5UzIOZld4d4bXxJvtlUhlQvaoTogIGin/qdErMOrwaIJvCSIAKTLhA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "oxlint": "bin/oxlint"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ },
+ "optionalDependencies": {
+ "@oxlint/binding-android-arm-eabi": "1.72.0",
+ "@oxlint/binding-android-arm64": "1.72.0",
+ "@oxlint/binding-darwin-arm64": "1.72.0",
+ "@oxlint/binding-darwin-x64": "1.72.0",
+ "@oxlint/binding-freebsd-x64": "1.72.0",
+ "@oxlint/binding-linux-arm-gnueabihf": "1.72.0",
+ "@oxlint/binding-linux-arm-musleabihf": "1.72.0",
+ "@oxlint/binding-linux-arm64-gnu": "1.72.0",
+ "@oxlint/binding-linux-arm64-musl": "1.72.0",
+ "@oxlint/binding-linux-ppc64-gnu": "1.72.0",
+ "@oxlint/binding-linux-riscv64-gnu": "1.72.0",
+ "@oxlint/binding-linux-riscv64-musl": "1.72.0",
+ "@oxlint/binding-linux-s390x-gnu": "1.72.0",
+ "@oxlint/binding-linux-x64-gnu": "1.72.0",
+ "@oxlint/binding-linux-x64-musl": "1.72.0",
+ "@oxlint/binding-openharmony-arm64": "1.72.0",
+ "@oxlint/binding-win32-arm64-msvc": "1.72.0",
+ "@oxlint/binding-win32-ia32-msvc": "1.72.0",
+ "@oxlint/binding-win32-x64-msvc": "1.72.0"
+ },
+ "peerDependencies": {
+ "oxlint-tsgolint": ">=0.22.1",
+ "vite-plus": "*"
+ },
+ "peerDependenciesMeta": {
+ "oxlint-tsgolint": {
+ "optional": true
+ },
+ "vite-plus": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.25",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
+ "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.16",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.7",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
+ "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.7",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
+ "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.7"
+ }
+ },
+ "node_modules/react-remove-scroll": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
+ "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
+ "license": "MIT",
+ "dependencies": {
+ "react-remove-scroll-bar": "^2.3.7",
+ "react-style-singleton": "^2.2.3",
+ "tslib": "^2.1.0",
+ "use-callback-ref": "^1.3.3",
+ "use-sidecar": "^1.1.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-remove-scroll-bar": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
+ "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
+ "license": "MIT",
+ "dependencies": {
+ "react-style-singleton": "^2.2.2",
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-style-singleton": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
+ "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
+ "license": "MIT",
+ "dependencies": {
+ "get-nonce": "^1.0.0",
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz",
+ "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==",
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.138.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.1.4",
+ "@rolldown/binding-darwin-arm64": "1.1.4",
+ "@rolldown/binding-darwin-x64": "1.1.4",
+ "@rolldown/binding-freebsd-x64": "1.1.4",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.1.4",
+ "@rolldown/binding-linux-arm64-gnu": "1.1.4",
+ "@rolldown/binding-linux-arm64-musl": "1.1.4",
+ "@rolldown/binding-linux-ppc64-gnu": "1.1.4",
+ "@rolldown/binding-linux-s390x-gnu": "1.1.4",
+ "@rolldown/binding-linux-x64-gnu": "1.1.4",
+ "@rolldown/binding-linux-x64-musl": "1.1.4",
+ "@rolldown/binding-openharmony-arm64": "1.1.4",
+ "@rolldown/binding-wasm32-wasi": "1.1.4",
+ "@rolldown/binding-win32-arm64-msvc": "1.1.4",
+ "@rolldown/binding-win32-x64-msvc": "1.1.4"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/seroval": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.4.tgz",
+ "integrity": "sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/seroval-plugins": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.4.tgz",
+ "integrity": "sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "seroval": "^1.0"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tailwind-merge": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz",
+ "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/dcastil"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz",
+ "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==",
+ "license": "MIT"
+ },
+ "node_modules/tapable": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
+ "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/typescript": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
+ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.18.2",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
+ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/use-callback-ref": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
+ "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/use-sidecar": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
+ "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
+ "license": "MIT",
+ "dependencies": {
+ "detect-node-es": "^1.1.0",
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "8.1.3",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
+ "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.32.0",
+ "picomatch": "^4.0.4",
+ "postcss": "^8.5.16",
+ "rolldown": "~1.1.3",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.3.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 00000000..a937d824
--- /dev/null
+++ b/web/package.json
@@ -0,0 +1,44 @@
+{
+ "name": "web",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "build:app": "tsc -b && vite build --base=/app/",
+ "build:demo": "vite build --base=./ --mode demo",
+ "lint": "oxlint --deny-warnings",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@fontsource-variable/inter": "^5.2.8",
+ "@fontsource/jetbrains-mono": "^5.2.8",
+ "@radix-ui/react-dialog": "^1.1.18",
+ "@radix-ui/react-dropdown-menu": "^2.1.19",
+ "@radix-ui/react-popover": "^1.1.18",
+ "@radix-ui/react-select": "^2.3.2",
+ "@radix-ui/react-switch": "^1.3.2",
+ "@radix-ui/react-tooltip": "^1.2.11",
+ "@tailwindcss/vite": "^4.3.2",
+ "@tanstack/react-query": "^5.101.2",
+ "@tanstack/react-router": "^1.170.17",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "lucide-react": "^1.23.0",
+ "motion": "^12.42.2",
+ "react": "^19.2.7",
+ "react-dom": "^19.2.7",
+ "tailwind-merge": "^3.6.0",
+ "tailwindcss": "^4.3.2"
+ },
+ "devDependencies": {
+ "@types/node": "^24.13.2",
+ "@types/react": "^19.2.17",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^6.0.3",
+ "oxlint": "^1.71.0",
+ "typescript": "~6.0.2",
+ "vite": "^8.1.1"
+ }
+}
diff --git a/web/public/ccx.svg b/web/public/ccx.svg
new file mode 100644
index 00000000..abac770b
--- /dev/null
+++ b/web/public/ccx.svg
@@ -0,0 +1,167 @@
+
+
+
+
diff --git a/web/public/favicon.svg b/web/public/favicon.svg
new file mode 100644
index 00000000..6893eb13
--- /dev/null
+++ b/web/public/favicon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/web/public/icons.svg b/web/public/icons.svg
new file mode 100644
index 00000000..e9522193
--- /dev/null
+++ b/web/public/icons.svg
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/assets/hero.png b/web/src/assets/hero.png
new file mode 100644
index 00000000..02251f4b
Binary files /dev/null and b/web/src/assets/hero.png differ
diff --git a/web/src/assets/vite.svg b/web/src/assets/vite.svg
new file mode 100644
index 00000000..5101b674
--- /dev/null
+++ b/web/src/assets/vite.svg
@@ -0,0 +1 @@
+Vite
diff --git a/web/src/components/Login.tsx b/web/src/components/Login.tsx
new file mode 100644
index 00000000..48895028
--- /dev/null
+++ b/web/src/components/Login.tsx
@@ -0,0 +1,171 @@
+import { Loader2, LogIn, Moon, Sun } from "lucide-react";
+import { motion } from "motion/react";
+import { useEffect, useState } from "react";
+
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { requestPasswordReset, requestSignup } from "@/lib/api";
+import { login } from "@/lib/auth";
+import { asset } from "@/lib/utils";
+
+const LINK = "cursor-pointer text-primary hover:underline";
+
+type Mode = "signin" | "reset" | "signup";
+
+const COPY: Record = {
+ signin: { title: "", action: "", sent: "" },
+ reset: {
+ title: "Reset your password",
+ action: "Email me a reset link",
+ // Deliberately the same whether or not the address has an account: a
+ // different answer here would tell an anonymous caller who is registered.
+ sent: "If that address has an account, a reset link is on its way.",
+ },
+ signup: {
+ title: "Create an account",
+ action: "Email me a signup link",
+ sent: "If that address can be registered, a signup link is on its way.",
+ },
+};
+
+/** Full-screen sign-in against POST /api/v1/auth/tokens. */
+export function Login({ onDone }: Readonly<{ onDone: () => void }>) {
+ const [mode, setMode] = useState("signin");
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [error, setError] = useState(null);
+ const [sent, setSent] = useState(false);
+ const [busy, setBusy] = useState(false);
+
+ const go = (next: Mode) => {
+ setMode(next);
+ setError(null);
+ setSent(false);
+ };
+
+ const submit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setBusy(true);
+ setError(null);
+ try {
+ if (mode === "signin") {
+ await login(email, password);
+ onDone();
+ } else {
+ await (mode === "reset" ? requestPasswordReset : requestSignup)(email);
+ setSent(true);
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "That did not work.");
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const [dark, setDark] = useState(() => localStorage.getItem("sp-theme") === "dark");
+ useEffect(() => {
+ document.documentElement.classList.toggle("dark", dark);
+ localStorage.setItem("sp-theme", dark ? "dark" : "light");
+ }, [dark]);
+
+ return (
+
+
setDark(!dark)}
+ aria-label="Toggle theme"
+ className="absolute right-5 top-5 cursor-pointer rounded-lg border bg-card p-2 text-muted-foreground shadow-card transition-colors hover:text-foreground"
+ >
+ {dark ? : }
+
+
+
+
+
+
Sample Platform
+
CCExtractor regression testing
+
+
+
+ {mode !== "signin" && (
+ {COPY[mode].title}
+ )}
+
+
+ Email
+
+ setEmail(e.target.value)}
+ placeholder="you@example.com"
+ className="mb-3"
+ />
+ {mode === "signin" && (
+ <>
+
+ Password
+
+ setPassword(e.target.value)}
+ className="mb-4"
+ />
+ >
+ )}
+ {error && (
+
+ {error}
+
+ )}
+ {sent && (
+
+ {COPY[mode].sent}
+
+ )}
+
+ {busy ? : }
+ {mode === "signin" ? "Sign in" : COPY[mode].action}
+
+
+ {/* Both links finish on the classic site, which is where accounts are
+ created and passwords are set. */}
+
+ {mode === "signin" ? (
+ <>
+ go("reset")} className={LINK}>
+ Forgot your password?
+
+ ·
+ go("signup")} className={LINK}>
+ Create an account
+
+ >
+ ) : (
+ go("signin")} className={LINK}>
+ Back to sign in
+
+ )}
+
+
+
+ Use the same account as the CCExtractor Sample Platform.
+
+
+
+ );
+}
diff --git a/web/src/components/ResetPassword.tsx b/web/src/components/ResetPassword.tsx
new file mode 100644
index 00000000..38a8c011
--- /dev/null
+++ b/web/src/components/ResetPassword.tsx
@@ -0,0 +1,162 @@
+import { KeyRound, Loader2 } from "lucide-react";
+import { motion } from "motion/react";
+import { useEffect, useState } from "react";
+
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { completePasswordReset } from "@/lib/api";
+import { asset } from "@/lib/utils";
+
+/**
+ * Set a new password from the link in a recovery email.
+ *
+ * Reached without a session, so it renders on its own rather than inside
+ * the shell. The three values in the query string are the platform's
+ * signed link: they are handed straight back to the API, which is what
+ * checks them. Nothing here can decide whether a link is good.
+ */
+export function ResetPassword() {
+ const [params, setParams] = useState<{
+ uid: number;
+ expires: number;
+ mac: string;
+ } | null>(null);
+ const [password, setPassword] = useState("");
+ const [confirm, setConfirm] = useState("");
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+ const [done, setDone] = useState(false);
+
+ // Read from the URL rather than the router: the link is built by the
+ // platform's email template, and lands here under either history mode.
+ useEffect(() => {
+ const raw = window.location.href;
+ const query = raw.slice(raw.indexOf("?") + 1);
+ const q = new URLSearchParams(raw.includes("?") ? query : "");
+ const uid = Number(q.get("uid"));
+ const expires = Number(q.get("expires"));
+ const mac = q.get("mac");
+ if (uid && expires && mac) setParams({ uid, expires, mac });
+ }, []);
+
+ const submit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!params) return;
+ setBusy(true);
+ setError(null);
+ try {
+ await completePasswordReset({
+ user_id: params.uid,
+ expires: params.expires,
+ mac: params.mac,
+ password,
+ });
+ setDone(true);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "That did not work.");
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ Choose a new password
+
+
CCExtractor Sample Platform
+
+
+
+ {!params && (
+
+ This link is missing part of itself. Ask for a new one from the sign-in
+ page.
+
+ )}
+
+ {params && !done && (
+ <>
+
+ New password
+
+ setPassword(e.target.value)}
+ className="mb-3"
+ />
+
+ Repeat it
+
+ setConfirm(e.target.value)}
+ className="mb-4"
+ />
+ {confirm && password !== confirm && (
+
+ The two do not match.
+
+ )}
+ {error && (
+
+ {error}
+
+ )}
+
+ {busy ? : } Set password
+
+ {/* The link carries the old password's signature, so using it
+ once is the last thing it can do. */}
+
+ This link works once, and stops working as soon as the password
+ changes.
+
+ >
+ )}
+
+ {done && (
+ <>
+
+ Password changed. You can sign in with it now.
+
+ {
+ window.location.href = window.location.pathname;
+ }}
+ >
+ Go to sign in
+
+ >
+ )}
+
+
+ );
+}
diff --git a/web/src/components/ResizeHandle.tsx b/web/src/components/ResizeHandle.tsx
new file mode 100644
index 00000000..1ddc5592
--- /dev/null
+++ b/web/src/components/ResizeHandle.tsx
@@ -0,0 +1,103 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+
+import { cn } from "@/lib/utils";
+
+/**
+ * Persisted, draggable width for a pane. Returns the current width and a
+ * to drop on the pane's trailing edge.
+ *
+ * Pass edge="leading" for a pane anchored to the right of the screen: its
+ * handle sits on the left, where dragging towards the left has to widen
+ * rather than narrow it.
+ */
+export function useResizableWidth(
+ key: string,
+ initial: number,
+ min: number,
+ max: number,
+ edge: "trailing" | "leading" = "trailing",
+) {
+ const [width, setWidth] = useState(() => {
+ const saved = Number(localStorage.getItem(`sp-w-${key}`));
+ return saved >= min && saved <= max ? saved : initial;
+ });
+ const dragging = useRef(false);
+ const sign = edge === "leading" ? -1 : 1;
+
+ const onMouseDown = useCallback(() => {
+ dragging.current = true;
+ document.body.style.cursor = "col-resize";
+ document.body.style.userSelect = "none";
+ }, []);
+
+ useEffect(() => {
+ const onMove = (e: MouseEvent) => {
+ if (!dragging.current) return;
+ setWidth((w) => {
+ const next = Math.min(max, Math.max(min, w + sign * e.movementX));
+ return next;
+ });
+ };
+ const onUp = () => {
+ if (!dragging.current) return;
+ dragging.current = false;
+ document.body.style.cursor = "";
+ document.body.style.userSelect = "";
+ setWidth((w) => {
+ localStorage.setItem(`sp-w-${key}`, String(w));
+ return w;
+ });
+ };
+ window.addEventListener("mousemove", onMove);
+ window.addEventListener("mouseup", onUp);
+ return () => {
+ window.removeEventListener("mousemove", onMove);
+ window.removeEventListener("mouseup", onUp);
+ };
+ }, [key, min, max, sign]);
+
+ // A splitter has to be operable without a mouse, so the same width change
+ // is reachable from the arrow keys. Shift moves in coarser steps.
+ const onKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
+ e.preventDefault();
+ const step = (e.shiftKey ? 40 : 8) * (e.key === "ArrowRight" ? 1 : -1) * sign;
+ setWidth((w) => {
+ const next = Math.min(max, Math.max(min, w + step));
+ localStorage.setItem(`sp-w-${key}`, String(next));
+ return next;
+ });
+ };
+
+ // role="slider" rather than "separator": what this actually is, to both a
+ // screen reader and a linter, is a one-dimensional value the user drags or
+ // arrows between a min and a max. A focusable separator is the older
+ // window-splitter idiom and reads as non-interactive.
+ const handle = (
+
+ );
+
+ return { width, handle };
+}
diff --git a/web/src/components/Sparkline.tsx b/web/src/components/Sparkline.tsx
new file mode 100644
index 00000000..b19ad4d4
--- /dev/null
+++ b/web/src/components/Sparkline.tsx
@@ -0,0 +1,24 @@
+import type { SparkResult } from "@/lib/types";
+import { cn } from "@/lib/utils";
+
+/** Pass/fail history dots, newest last. */
+export function Sparkline({ results, className }: Readonly<{ results: SparkResult[]; className?: string }>) {
+ if (results.length === 0) {
+ return no runs yet ;
+ }
+ return (
+
+ {results.map((r, i) => ({ id: `run-${i}`, r })).map(({ id, r }) => (
+
+ ))}
+
+ );
+}
diff --git a/web/src/components/StatusBadge.tsx b/web/src/components/StatusBadge.tsx
new file mode 100644
index 00000000..6b332210
--- /dev/null
+++ b/web/src/components/StatusBadge.tsx
@@ -0,0 +1,32 @@
+import type { RunStatus } from "@/lib/types";
+import { cn } from "@/lib/utils";
+
+/** Colored dot + quiet label; in-flight states pulse. */
+const RUN_STATUS: Record = {
+ queued: { label: "Queued", dot: "bg-faint" },
+ running: { label: "Running", dot: "bg-primary", pulse: true },
+ pass: { label: "Passed", dot: "bg-success" },
+ fail: { label: "Failed", dot: "bg-destructive" },
+ canceled: { label: "Canceled", dot: "bg-faint" },
+ error: { label: "Error", dot: "bg-destructive" },
+ incomplete: { label: "Incomplete", dot: "bg-warning" },
+ preparation: { label: "Preparing", dot: "bg-warning", pulse: true },
+ building: { label: "Building", dot: "bg-warning", pulse: true },
+ testing: { label: "Testing", dot: "bg-primary", pulse: true },
+ completed: { label: "Completed", dot: "bg-success" },
+};
+
+export function RunStatusBadge({ status, className }: Readonly<{ status: RunStatus; className?: string }>) {
+ const s = RUN_STATUS[status] ?? RUN_STATUS.queued;
+ return (
+
+
+ {s.pulse && (
+
+ )}
+
+
+ {s.label}
+
+ );
+}
diff --git a/web/src/components/ui/badge.tsx b/web/src/components/ui/badge.tsx
new file mode 100644
index 00000000..9e7ce9c7
--- /dev/null
+++ b/web/src/components/ui/badge.tsx
@@ -0,0 +1,32 @@
+import { cva, type VariantProps } from "class-variance-authority";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const badgeVariants = cva(
+ "inline-flex items-center gap-1 rounded-md px-1.5 py-px text-[11px] font-medium leading-4 whitespace-nowrap",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground",
+ secondary: "border border-border bg-muted/60 text-muted-foreground",
+ outline: "border border-border text-muted-foreground",
+ success: "bg-success/10 text-success",
+ destructive: "bg-destructive/10 text-destructive",
+ warning: "bg-warning/12 text-warning",
+ accent: "bg-accent text-accent-foreground",
+ },
+ },
+ defaultVariants: { variant: "default" },
+ },
+);
+
+function Badge({
+ className,
+ variant,
+ ...props
+}: Readonly & VariantProps>) {
+ return ;
+}
+
+export { Badge, badgeVariants };
diff --git a/web/src/components/ui/button.tsx b/web/src/components/ui/button.tsx
new file mode 100644
index 00000000..74d021d5
--- /dev/null
+++ b/web/src/components/ui/button.tsx
@@ -0,0 +1,42 @@
+import { cva, type VariantProps } from "class-variance-authority";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const buttonVariants = cva(
+ "press inline-flex cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap rounded-lg text-[13px] font-medium transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-45 [&_svg]:size-3.5 [&_svg]:shrink-0",
+ {
+ variants: {
+ variant: {
+ default:
+ "bg-primary text-primary-foreground shadow-sm hover:bg-primary-hover",
+ secondary:
+ "border border-border bg-card text-foreground shadow-xs hover:bg-muted",
+ outline:
+ "border border-border bg-transparent hover:bg-muted hover:border-border-strong",
+ ghost: "text-muted-foreground hover:bg-muted hover:text-foreground",
+ destructive: "bg-destructive text-white shadow-sm hover:opacity-90",
+ },
+ size: {
+ default: "h-8 px-3.5",
+ sm: "h-7 rounded-md px-2.5 text-xs",
+ lg: "h-9 rounded-lg px-5",
+ icon: "h-8 w-8",
+ },
+ },
+ defaultVariants: { variant: "default", size: "default" },
+ },
+);
+
+function Button({
+ className,
+ variant,
+ size,
+ ...props
+}: Readonly & VariantProps>) {
+ return (
+
+ );
+}
+
+export { Button, buttonVariants };
diff --git a/web/src/components/ui/confirm.tsx b/web/src/components/ui/confirm.tsx
new file mode 100644
index 00000000..1b039780
--- /dev/null
+++ b/web/src/components/ui/confirm.tsx
@@ -0,0 +1,74 @@
+import * as DialogPrimitive from "@radix-ui/react-dialog";
+import { TriangleAlert } from "lucide-react";
+import { AnimatePresence, motion } from "motion/react";
+import * as React from "react";
+
+import { Button } from "@/components/ui/button";
+
+/** Deliberate-action dialog for destructive operations. */
+export function ConfirmDialog({
+ open,
+ onOpenChange,
+ title,
+ body,
+ confirmLabel,
+ busy,
+ onConfirm,
+}: Readonly<{
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ title: string;
+ body: React.ReactNode;
+ confirmLabel: string;
+ busy?: boolean;
+ onConfirm: () => void;
+}>) {
+ return (
+
+
+ {open && (
+
+
+
+
+
+
+
+
+
+
+
+ {title}
+
+
+
+ {body}
+
+
+ onOpenChange(false)}>
+ Cancel
+
+
+ {confirmLabel}
+
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/web/src/components/ui/input.tsx b/web/src/components/ui/input.tsx
new file mode 100644
index 00000000..20ca5050
--- /dev/null
+++ b/web/src/components/ui/input.tsx
@@ -0,0 +1,29 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Input({ className, ...props }: Readonly>) {
+ return (
+
+ );
+}
+
+function Textarea({ className, ...props }: Readonly>) {
+ return (
+
+ );
+}
+
+export { Input, Textarea };
diff --git a/web/src/components/ui/sheet.tsx b/web/src/components/ui/sheet.tsx
new file mode 100644
index 00000000..ed24c0a9
--- /dev/null
+++ b/web/src/components/ui/sheet.tsx
@@ -0,0 +1,90 @@
+import * as DialogPrimitive from "@radix-ui/react-dialog";
+import { AnimatePresence, motion } from "motion/react";
+import { X } from "lucide-react";
+import * as React from "react";
+
+import { useResizableWidth } from "@/components/ResizeHandle";
+import { cn } from "@/lib/utils";
+
+const Sheet = DialogPrimitive.Root;
+const SheetTrigger = DialogPrimitive.Trigger;
+const SheetClose = DialogPrimitive.Close;
+
+/**
+ * Right-hand drawer with spring slide-in. Pass `open` to drive the
+ * enter/exit animation via AnimatePresence.
+ *
+ * Naming a `resizeKey` makes the drawer drag-resizable, remembering its
+ * width under that key. Without one it keeps the fixed max-width, so
+ * callers that never needed resizing are unaffected.
+ */
+function AnimatedSheet({
+ open,
+ onOpenChange,
+ children,
+ className,
+ resizeKey,
+}: Readonly<{
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ children: React.ReactNode;
+ className?: string;
+ resizeKey?: string;
+}>) {
+ // Handle sits on the left: the panel is anchored to the right edge, so
+ // that is the only side whose position changes with the width.
+ const { width, handle } = useResizableWidth(
+ resizeKey ?? "sheet",
+ 576,
+ 380,
+ 1180,
+ "leading",
+ );
+
+ return (
+
+
+ {open && (
+
+
+
+
+
+
+ {resizeKey && handle}
+
+ {children}
+
+
+
+
+
+
+
+ )}
+
+
+ );
+}
+
+const SheetTitle = DialogPrimitive.Title;
+const SheetDescription = DialogPrimitive.Description;
+
+export { Sheet, SheetTrigger, SheetClose, AnimatedSheet, SheetTitle, SheetDescription };
diff --git a/web/src/index.css b/web/src/index.css
new file mode 100644
index 00000000..836bb617
--- /dev/null
+++ b/web/src/index.css
@@ -0,0 +1,142 @@
+@import "@fontsource-variable/inter";
+@import "@fontsource/jetbrains-mono";
+@import "tailwindcss";
+
+@custom-variant dark (&:where(.dark, .dark *));
+
+/* CCExtractor palette — lifted from ccextractor.org (Compose theme as
+ deployed): green accent rgb(52,199,89) / rgb(48,209,88), light #fff +
+ haze #f2f5f7, dark rgb(28,28,30) + bubble rgb(36,36,38). */
+:root {
+ --background: #f7f9fa;
+ --foreground: #141010;
+ --card: #ffffff;
+ --card-foreground: #141010;
+ --muted: #f2f5f7;
+ --muted-foreground: #5c6670;
+ --faint: #96a0aa;
+ --border: #e7ebee;
+ --border-strong: #d8dee3;
+ --primary: rgb(40 167 74);
+ --primary-hover: rgb(34 145 64);
+ --primary-foreground: #ffffff;
+ --accent: #e8f7ec;
+ --accent-foreground: rgb(28 122 53);
+ --destructive: #d64550;
+ --success: rgb(40 167 74);
+ --warning: #b8860b;
+ --ring: rgb(52 199 89 / 0.35);
+ --shadow-card: 0 1px 2px rgb(20 16 16 / 0.04), 0 0 0 1px rgb(20 16 16 / 0.04);
+ --shadow-pop: 0 8px 32px rgb(20 16 16 / 0.12), 0 0 0 1px rgb(20 16 16 / 0.05);
+}
+
+.dark {
+ --background: rgb(28 28 30);
+ --foreground: #eeeeee;
+ --card: rgb(36 36 38);
+ --card-foreground: #eeeeee;
+ --muted: rgb(46 46 49);
+ --muted-foreground: #a3a8ad;
+ --faint: #71767b;
+ --border: rgb(56 56 60);
+ --border-strong: rgb(72 72 76);
+ --primary: rgb(48 209 88);
+ --primary-hover: rgb(74 222 110);
+ --primary-foreground: rgb(20 26 21);
+ --accent: rgb(38 58 44);
+ --accent-foreground: rgb(120 224 148);
+ --destructive: #e5707c;
+ --success: rgb(48 209 88);
+ --warning: #d2a24c;
+ --ring: rgb(48 209 88 / 0.4);
+ --shadow-card: 0 1px 2px rgb(0 0 0 / 0.3), 0 0 0 1px rgb(255 255 255 / 0.04);
+ --shadow-pop: 0 8px 32px rgb(0 0 0 / 0.5), 0 0 0 1px rgb(255 255 255 / 0.06);
+}
+
+@theme inline {
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-faint: var(--faint);
+ --color-border: var(--border);
+ --color-border-strong: var(--border-strong);
+ --color-primary: var(--primary);
+ --color-primary-hover: var(--primary-hover);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-success: var(--success);
+ --color-warning: var(--warning);
+ --color-ring: var(--ring);
+ --font-sans: "Inter Variable", ui-sans-serif, system-ui, sans-serif;
+ --font-mono: "JetBrains Mono", ui-monospace, "Cascadia Code", monospace;
+ --shadow-card: var(--shadow-card);
+ --shadow-pop: var(--shadow-pop);
+}
+
+@layer base {
+ * {
+ border-color: var(--color-border);
+ }
+ html {
+ color-scheme: light;
+ /* The type scale is authored in px, so there is no root font size to
+ raise — zoom scales the whole layout instead. Panes below size off
+ percentage heights rather than 100vh, which would keep measuring the
+ unzoomed viewport and overflow by exactly this factor. */
+ zoom: 1.15;
+ height: 100%;
+ }
+ html.dark {
+ color-scheme: dark;
+ }
+ body,
+ #root {
+ height: 100%;
+ }
+ body {
+ background: var(--color-background);
+ color: var(--color-foreground);
+ font-family: var(--font-sans);
+ font-size: 14px;
+ letter-spacing: -0.006em;
+ font-feature-settings: "cv11", "ss01", "tnum";
+ -webkit-font-smoothing: antialiased;
+ }
+ ::selection {
+ background: var(--color-accent);
+ color: var(--color-accent-foreground);
+ }
+ ::-webkit-scrollbar { width: 10px; height: 10px; }
+ ::-webkit-scrollbar-thumb {
+ background: var(--color-border-strong);
+ border-radius: 8px;
+ border: 3px solid transparent;
+ background-clip: content-box;
+ }
+ ::-webkit-scrollbar-track { background: transparent; }
+}
+
+/* Plain selectors rather than @utility with nesting: neither of these is
+ ever used behind a variant prefix, so they need no variant support, and
+ this form is readable by tooling that does not know Tailwind's at-rules. */
+@layer components {
+ .card-hover {
+ transition: border-color 150ms ease, box-shadow 150ms ease;
+ }
+ .card-hover:hover {
+ border-color: var(--color-border-strong);
+ box-shadow: var(--shadow-pop);
+ }
+
+ .press {
+ transition: transform 100ms ease;
+ }
+ .press:active {
+ transform: scale(0.98);
+ }
+}
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
new file mode 100644
index 00000000..0db2fb8d
--- /dev/null
+++ b/web/src/lib/api.ts
@@ -0,0 +1,1081 @@
+// Data layer. Everything here is live: the bundled snapshot that used to
+// fill in per-test history now only backs demo mode, which patches fetch in
+// lib/demo.ts and never reaches this file.
+
+import { useQuery } from "@tanstack/react-query";
+
+import { getSession, logout } from "@/lib/auth";
+import type {
+ LiveRun,
+ LogicalRun,
+ Platform,
+ RegressionTest,
+ RunSummary,
+ Sample,
+ SparkResult,
+} from "@/lib/types";
+
+/* ---------------- live fetch helpers ---------------- */
+
+const BASE = "/api/v1";
+
+class ApiError extends Error {
+ status: number;
+ constructor(status: number, message: string) {
+ super(message);
+ this.status = status;
+ }
+}
+
+/**
+ * Turn an API failure into copy safe to render. 4xx validation messages are
+ * written for end users and pass through; auth failures and server errors
+ * get generic copy so backend internals never reach the screen. The raw
+ * message goes to the console either way.
+ */
+function apiError(status: number, raw: string): ApiError {
+ console.warn(`API ${status}:`, raw);
+ if (status === 401) return new ApiError(status, "Session expired — sign in again.");
+ if (status === 403) return new ApiError(status, "You don't have permission to do that.");
+ if (status === 429) return new ApiError(status, "Rate limited — give it a minute.");
+ if (status >= 500) return new ApiError(status, "The platform hit an internal error. Try again shortly.");
+ return new ApiError(status, raw || "Request failed.");
+}
+
+async function apiGet(path: string): Promise {
+ const session = getSession();
+ const res = await fetch(`${BASE}${path}`, {
+ headers: session ? { Authorization: `Bearer ${session.token}` } : {},
+ });
+ if (res.status === 401 && session) {
+ // Token expired/revoked server-side — drop the stale session and land
+ // back on the login screen instead of erroring every query. logout()
+ // guards itself against the parallel-query stampede.
+ logout();
+ }
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({}));
+ throw apiError(res.status, body.message ?? res.statusText);
+ }
+ return res.json() as Promise;
+}
+
+async function apiSend(method: string, path: string, body: unknown): Promise {
+ const session = getSession();
+ const res = await fetch(`${BASE}${path}`, {
+ method,
+ headers: {
+ "Content-Type": "application/json",
+ ...(session ? { Authorization: `Bearer ${session.token}` } : {}),
+ },
+ body: JSON.stringify(body),
+ });
+ if (res.status === 401 && session) logout();
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) throw apiError(res.status, data.message ?? res.statusText);
+ return data as T;
+}
+
+interface Envelope {
+ data: T[];
+ pagination?: { total: number; next_offset: number | null };
+}
+
+/** Follow offset pagination until exhausted (API caps limit at 100). */
+async function fetchAll(path: string, cap = 500): Promise {
+ const sep = path.includes("?") ? "&" : "?";
+ const out: T[] = [];
+ let offset = 0;
+ for (;;) {
+ const page = await apiGet>(`${path}${sep}limit=100&offset=${offset}`);
+ out.push(...page.data);
+ const next = page.pagination?.next_offset ?? null;
+ if (next === null || out.length >= cap || page.data.length === 0) break;
+ offset = next;
+ }
+ return out;
+}
+
+/* ---------------- live queries ---------------- */
+
+/** How many past runs the sparklines and the average runtime look back over. */
+const HISTORY_RUNS = 6;
+
+interface TestHistory {
+ recent_results: SparkResult[];
+ avg_runtime_ms: number | null;
+}
+
+/**
+ * A run that skipped this test leaves a gap in the series rather than a
+ * failure, which is what customized runs produce.
+ */
+function sparkResultOf(status: string): SparkResult {
+ if (status === "pass") return "pass";
+ if (status === "fail") return "fail";
+ return "skip";
+}
+
+/** Collect one test's results and runtimes across the runs that covered it. */
+function foldRunRows(perRun: RunSampleRow[][]) {
+ const history = new Map();
+ const runtimes = new Map();
+
+ for (const row of perRun.flat()) {
+ const entry = history.get(row.regression_test_id) ?? {
+ recent_results: [],
+ avg_runtime_ms: null,
+ };
+ entry.recent_results.push(sparkResultOf(row.status));
+ history.set(row.regression_test_id, entry);
+
+ if (row.runtime_ms != null) {
+ runtimes.set(row.regression_test_id, [
+ ...(runtimes.get(row.regression_test_id) ?? []),
+ row.runtime_ms,
+ ]);
+ }
+ }
+
+ for (const [id, times] of runtimes) {
+ const entry = history.get(id);
+ if (entry && times.length > 0) {
+ entry.avg_runtime_ms = times.reduce((a, b) => a + b, 0) / times.length;
+ }
+ }
+ return history;
+}
+
+/**
+ * Per-test result history, assembled from recent runs.
+ *
+ * There is no per-test history endpoint, but /runs/{id}/samples already
+ * returns every test's status and runtime for one run, so reading a handful
+ * of runs and transposing them costs a few requests rather than one per
+ * test. Only commit runs count: a PR run failing says something about the
+ * pull request, not about the health of the test.
+ */
+export function useTestHistory() {
+ const { data: runs = [] } = useRuns();
+ const runIds = runs
+ .filter((r) => r.test_type === "commit")
+ .slice(0, HISTORY_RUNS)
+ .flatMap((r) => r.platforms.map((p) => p.run_id))
+ .toSorted((a, b) => a - b);
+
+ return useQuery({
+ queryKey: ["test-history", runIds],
+ enabled: runIds.length > 0,
+ staleTime: 60_000,
+ queryFn: async () =>
+ foldRunRows(
+ await Promise.all(
+ runIds.map((id) => fetchAll(`/runs/${id}/samples`, 400)),
+ ),
+ ),
+ });
+}
+
+interface RunSampleRow {
+ regression_test_id: number;
+ status: string;
+ runtime_ms: number | null;
+}
+
+/** Regression tests: the live list, with history and sha joined in. */
+export function useRegressionTests() {
+ const { data: history } = useTestHistory();
+ const { data: samples = [] } = useSamples();
+
+ const rows = useQuery({
+ queryKey: ["regression-tests"],
+ staleTime: 60_000,
+ queryFn: async () => {
+ const [act, inact] = await Promise.all([
+ fetchAll("/regression-tests?active=true"),
+ fetchAll("/regression-tests?active=false"),
+ ]);
+ return [...act, ...inact].sort(
+ (a, b) => a.regression_test_id - b.regression_test_id,
+ );
+ },
+ });
+
+ const shaById = new Map(samples.map((s) => [s.id, s.sha]));
+ const data = (rows.data ?? []).map((r): RegressionTest => {
+ const past = history?.get(r.regression_test_id);
+ return {
+ id: r.regression_test_id,
+ sample_id: r.sample_id,
+ sample_name: r.sample_name,
+ sample_sha: shaById.get(r.sample_id) ?? "",
+ command: r.command,
+ input_type: r.input_type,
+ output_type: r.output_type,
+ expected_rc: r.expected_rc,
+ active: r.active,
+ description: r.description ?? "",
+ categories: r.categories,
+ avg_runtime_ms: past?.avg_runtime_ms ?? null,
+ recent_results: past?.recent_results ?? [],
+ };
+ });
+
+ return { data, isLoading: rows.isLoading };
+}
+
+/**
+ * Where a file lives, rather than the file itself.
+ *
+ * Samples and baselines are handed out as signed URLs so large transfers do
+ * not run through the API. A null download_url means the only copy is on the
+ * platform's own disk and there is nothing to link to.
+ */
+export interface StoredFile {
+ filename: string;
+ download_url: string | null;
+ storage_status: "ok" | "degraded";
+}
+
+export const sampleFile = (sampleId: number) =>
+ apiGet(`/samples/${sampleId}/download`);
+
+export const baselineFile = (testId: number, outputId: number) =>
+ apiGet(`/regression-tests/${testId}/outputs/${outputId}/download`);
+
+export const variantFile = (testId: number, outputId: number, variantId: number) =>
+ apiGet(
+ `/regression-tests/${testId}/outputs/${outputId}/variants/${variantId}/download`,
+ );
+
+/** One test with its baselines, which the list endpoint leaves out. */
+export function useRegressionTestDetail(id: number | null) {
+ return useQuery({
+ queryKey: ["regression-test", id],
+ enabled: id !== null,
+ staleTime: 60_000,
+ queryFn: () => apiGet(`/regression-tests/${id}`),
+ });
+}
+
+export interface RegressionTestOutputDetail {
+ id: number;
+ correct: string;
+ correct_extension: string;
+ expected_filename: string | null;
+ ignore: boolean;
+ variants: { id: number; hash: string }[];
+}
+
+interface RegressionTestDetail {
+ regression_test_id: number;
+ outputs: RegressionTestOutputDetail[];
+}
+
+interface LiveRegressionTest {
+ regression_test_id: number;
+ sample_id: number;
+ sample_name: string;
+ command: string;
+ input_type: string | null;
+ output_type: string | null;
+ expected_rc: number;
+ active: boolean;
+ description: string | null;
+ categories: string[];
+}
+
+export function useSamples() {
+ return useQuery({
+ queryKey: ["samples"],
+ staleTime: 60_000,
+ queryFn: async () => {
+ const res = await fetchAll("/samples");
+ return res.map(
+ (s): Sample => ({
+ id: s.sample_id,
+ sha: s.sha,
+ extension: s.extension.startsWith(".") ? s.extension : `.${s.extension}`,
+ original_name: s.original_name,
+ tags: s.tags ?? [],
+ test_count: s.regression_test_count ?? 0,
+ }),
+ );
+ },
+ });
+}
+
+interface LiveSample {
+ sample_id: number;
+ sha: string;
+ extension: string;
+ original_name: string;
+ tags: string[];
+ regression_test_count: number;
+}
+
+/** Runs, grouped client-side into logical (per-commit) entries. */
+export function useRuns() {
+ return useQuery({
+ queryKey: ["runs"],
+ staleTime: 30_000,
+ refetchInterval: 30_000,
+ queryFn: async () => {
+ const res = await apiGet>("/runs?limit=40&sort=-created_at");
+ const groups = new Map();
+ for (const r of res.data) {
+ const key = `${r.commit_sha}:${r.pr_number ?? ""}`;
+ groups.set(key, [...(groups.get(key) ?? []), r]);
+ }
+ const logical: LogicalRun[] = [...groups.values()].map((g) => {
+ const first = g[0];
+ return {
+ id: String(Math.max(...g.map((x) => x.run_id))),
+ commit: first.commit_sha.slice(0, 9),
+ branch: first.branch,
+ fork: first.repository,
+ pr_nr: first.pr_number,
+ test_type: first.test_type,
+ created_at: first.created_at,
+ github_link: first.github_link,
+ platforms: g
+ .toSorted((a, b) => a.platform.localeCompare(b.platform))
+ .map((x) => ({
+ run_id: x.run_id,
+ platform: x.platform,
+ status: x.status,
+ started_at: x.started_at,
+ completed_at: x.completed_at,
+ })),
+ };
+ });
+ return logical.toSorted((a, b) => Number(b.id) - Number(a.id));
+ },
+ });
+}
+
+/** Single run (one platform) — GET /runs/. */
+export function useRun(runId: number | null) {
+ return useQuery({
+ queryKey: ["run", runId],
+ enabled: runId !== null,
+ queryFn: () => apiGet(`/runs/${runId}`),
+ });
+}
+
+export interface ProgressEvent {
+ status: string;
+ message: string;
+ timestamp: string;
+}
+
+export function useRunProgress(runId: number | null) {
+ return useQuery({
+ queryKey: ["run-progress", runId],
+ enabled: runId !== null,
+ refetchInterval: (q) => {
+ const data = q.state.data as ProgressEvent[] | undefined;
+ const done = data?.some((e) => e.status === "completed" || e.status === "canceled");
+ return done ? false : 15_000; // poll live runs, stop once finished
+ },
+ queryFn: async () => {
+ const res = await apiGet>(`/runs/${runId}/progress`);
+ return res.data;
+ },
+ });
+}
+
+/** Every result row of a run (all statuses) — GET /runs//samples. */
+export function useRunSamples(runId: number | null) {
+ return useQuery({
+ queryKey: ["run-samples-all", runId],
+ enabled: runId !== null,
+ staleTime: 120_000,
+ queryFn: () => fetchAll(`/runs/${runId}/samples`, 400),
+ });
+}
+
+/** Per-run counts, fetched lazily when a run row expands. */
+export function useRunSummary(runId: number | null) {
+ return useQuery({
+ queryKey: ["run-summary", runId],
+ enabled: runId !== null,
+ staleTime: 300_000,
+ queryFn: () => apiGet(`/runs/${runId}/summary`),
+ });
+}
+
+/** One failing result row from GET /runs//samples?status=fail. */
+export interface RunFailure {
+ regression_test_id: number;
+ sample_id: number;
+ sample_name: string;
+ command: string;
+ categories: string[];
+ status: string;
+ exit_code: number | null;
+ expected_rc: number | null;
+ runtime_ms: number | null;
+ outputs: { output_id: number; filename: string; status: string }[];
+}
+
+/** Live failures for a platform run (used by triage + run expansion). */
+export function useRunFailures(runId: number | null) {
+ return useQuery({
+ queryKey: ["run-failures", runId],
+ enabled: runId !== null,
+ staleTime: 300_000,
+ queryFn: () => fetchAll(`/runs/${runId}/samples?status=fail`, 300),
+ });
+}
+
+export interface QueueEntry {
+ run_id: number;
+ platform: Platform;
+ status: string;
+ position: number | null;
+ queued_at: string | null;
+ started_at: string | null;
+}
+
+export function useQueue() {
+ return useQuery({
+ queryKey: ["queue"],
+ refetchInterval: 30_000,
+ queryFn: async () => {
+ const session = getSession();
+ const res = await fetch(`${BASE}/system/queue`, {
+ headers: session ? { Authorization: `Bearer ${session.token}` } : {},
+ });
+ if (!res.ok) throw apiError(res.status, res.statusText);
+ const body = (await res.json()) as {
+ data: QueueEntry[];
+ meta: { queue_depth: number; running_count: number };
+ };
+ return body;
+ },
+ });
+}
+
+export interface ApiToken {
+ id: number;
+ token_name: string;
+ token_prefix: string;
+ scopes: string[];
+ created_at: string;
+ expires_at: string;
+ is_revoked: boolean;
+}
+
+export function useTokens() {
+ return useQuery({
+ queryKey: ["tokens"],
+ queryFn: async () => {
+ const res = await apiGet>("/auth/tokens");
+ return res.data;
+ },
+ });
+}
+
+export async function revokeToken(id: number): Promise {
+ const session = getSession();
+ const res = await fetch(`${BASE}/auth/tokens/${id}`, {
+ method: "DELETE",
+ headers: session ? { Authorization: `Bearer ${session.token}` } : {},
+ });
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({}));
+ throw apiError(res.status, body.message ?? res.statusText);
+ }
+}
+
+export interface HistoryEntry {
+ run_id: number;
+ regression_test_id: number;
+ platform: Platform;
+ status: string;
+ commit_sha: string;
+ branch: string;
+ tested_at: string | null;
+}
+
+export interface MediaInfoNode {
+ name: string;
+ value: string | Record | { name: string; value: Record }[];
+}
+
+export interface SampleDetails {
+ sample_id: number;
+ sha: string;
+ extension: string;
+ original_name: string;
+ filename: string;
+ tags: string[];
+ upload: {
+ platform: string | null;
+ parameters: string;
+ notes: string;
+ version: string | null;
+ version_released: string | null;
+ } | null;
+ extra_files: { id: number; original_name: string; extension: string }[];
+ media_info: MediaInfoNode[] | null;
+}
+
+export function useSampleDetails(sampleId: number | null) {
+ return useQuery({
+ queryKey: ["sample-details", sampleId],
+ enabled: sampleId !== null,
+ staleTime: 300_000,
+ queryFn: () => apiGet(`/samples/${sampleId}/details`),
+ });
+}
+
+export function useSampleHistory(sampleId: number | null) {
+ return useQuery({
+ queryKey: ["sample-history", sampleId],
+ enabled: sampleId !== null,
+ staleTime: 300_000,
+ queryFn: async () => {
+ // Keep the query date-bounded: without created_after the endpoint
+ // loads every historical run before paginating, which takes ~10s on
+ // a long-lived sample. 45 days covers anything worth triaging.
+ const since = new Date(Date.now() - 45 * 86_400_000).toISOString().slice(0, 10);
+ const res = await apiGet>(
+ `/samples/${sampleId}/history?limit=20&created_after=${since}`,
+ );
+ return res.data;
+ },
+ });
+}
+
+/**
+ * Promote a run's actual output to the expected baseline. This replaces the
+ * stored hash for every future run, so the UI must confirm before calling.
+ * Admin-only and applied immediately — the server enforces admin role +
+ * baselines:write.
+ */
+export async function promoteBaseline(args: {
+ runId: number;
+ sampleId: number;
+ regressionId: number;
+ outputId: number;
+}): Promise<{ ok: boolean; message: string }> {
+ const session = getSession();
+ const res = await fetch(
+ `${BASE}/runs/${args.runId}/samples/${args.sampleId}/baseline-approval`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ ...(session ? { Authorization: `Bearer ${session.token}` } : {}),
+ },
+ body: JSON.stringify({
+ regression_id: args.regressionId,
+ output_id: args.outputId,
+ remove_variants: false,
+ }),
+ },
+ );
+ const body = await res.json().catch(() => ({}));
+ return {
+ ok: res.ok,
+ message: body.message ?? (res.ok ? "Baseline promoted." : res.statusText),
+ };
+}
+
+export interface PlatformUser {
+ user_id: number;
+ name: string;
+ email: string;
+ role: "admin" | "contributor" | "tester" | "user";
+ github_linked: boolean;
+ github_login: string | null;
+}
+
+export function useUsers() {
+ // Paged: the platform has hundreds of accounts, and a single page would
+ // silently show the first 50 as if that were everyone.
+ return useQuery({
+ queryKey: ["users"],
+ queryFn: () => fetchAll("/users"),
+ });
+}
+
+export function updateUserRole(id: number, role: string) {
+ return apiSend("PATCH", `/users/${id}`, { role });
+}
+
+/* ---------------- administration ---------------- */
+
+const apiDelete = (path: string) => apiSend("DELETE", path, undefined);
+
+export interface LiveCategory {
+ id: number;
+ name: string;
+ description: string;
+ test_count: number;
+}
+
+export function useCategories() {
+ return useQuery({
+ queryKey: ["categories"],
+ queryFn: () => fetchAll("/categories"),
+ });
+}
+
+export const createCategory = (name: string) =>
+ apiSend("POST", "/categories", { name });
+
+export const renameCategory = (id: number, name: string) =>
+ apiSend("PATCH", `/categories/${id}`, { name });
+
+/** Refused with 409 while any regression test still references the category. */
+export const deleteCategory = (id: number) =>
+ apiDelete<{ id: number; deleted: boolean }>(`/categories/${id}`);
+
+export interface MaintenanceState {
+ platform: Platform;
+ disabled: boolean;
+}
+
+export function useMaintenance() {
+ return useQuery({
+ queryKey: ["maintenance"],
+ queryFn: () => apiGet<{ platforms: MaintenanceState[] }>("/system/maintenance"),
+ });
+}
+
+export const setMaintenance = (platform: Platform, disabled: boolean) =>
+ apiSend("PATCH", `/system/maintenance/${platform}`, { disabled });
+
+export interface BlockedUser {
+ user_id: number;
+ comment: string;
+}
+
+export function useBlockedUsers() {
+ return useQuery({
+ queryKey: ["blocked-users"],
+ queryFn: () => fetchAll("/system/blocked-users"),
+ });
+}
+
+/** Keyed on the numeric GitHub account id — logins can be changed and reused. */
+export const blockUser = (userId: number, comment: string) =>
+ apiSend("POST", "/system/blocked-users", {
+ user_id: userId,
+ comment,
+ });
+
+export const unblockUser = (userId: number) =>
+ apiDelete<{ user_id: number }>(`/system/blocked-users/${userId}`);
+
+export function useForbiddenExtensions() {
+ return useQuery({
+ queryKey: ["forbidden-extensions"],
+ queryFn: () => fetchAll("/system/forbidden-extensions"),
+ });
+}
+
+export const forbidExtension = (extension: string) =>
+ apiSend<{ extension: string }>("POST", "/system/forbidden-extensions", {
+ extension,
+ });
+
+export const allowExtension = (extension: string) =>
+ apiDelete<{ extension: string }>(`/system/forbidden-extensions/${extension}`);
+
+export function useHealth() {
+ return useQuery({
+ queryKey: ["health"],
+ staleTime: 60_000,
+ queryFn: () =>
+ apiGet<{ status: string; dependencies: { name: string; status: string }[] }>(
+ "/system/health",
+ ),
+ });
+}
+
+/* ---------------- write operations (live) ---------------- */
+
+export interface RegressionTestPatch {
+ command?: string;
+ description?: string;
+ expected_rc?: number;
+ active?: boolean;
+ categories?: string[];
+}
+
+export function updateRegressionTest(id: number, patch: RegressionTestPatch) {
+ return apiSend("PATCH", `/regression-tests/${id}`, patch);
+}
+
+export function createRegressionTest(body: {
+ sample_id: number;
+ command: string;
+ input_type?: string;
+ output_type?: string;
+ expected_rc?: number;
+ description?: string;
+ categories: string[];
+}) {
+ return apiSend<{ regression_test_id: number }>("POST", "/regression-tests", body);
+}
+
+/** Queue a real CI run (old "customized tests"). One call per platform. */
+export function createRun(body: {
+ commit_sha: string;
+ platform: Platform;
+ branch?: string;
+ repository: string;
+ regression_test_ids?: number[];
+}) {
+ return apiSend<{ run_id: number }>("POST", "/runs", body);
+}
+
+/** Cancel a queued/running run. Idempotent — a finished run returns no_op. */
+export function cancelRun(runId: number, reason?: string) {
+ return apiSend<{ run_id: number; status: string; message: string }>(
+ "POST",
+ `/runs/${runId}/cancel`,
+ reason ? { reason } : {},
+ );
+}
+
+/** One artifact of a finished run: build log, expected/actual outputs. */
+export interface RunArtifact {
+ artifact_id: string;
+ run_id: number;
+ sample_id: number | null;
+ type: string;
+ filename: string;
+ content_type: string | null;
+ size_bytes: number | null;
+ storage_status: string;
+ download_url: string | null;
+}
+
+export function useRunArtifacts(runId: number | null) {
+ return useQuery({
+ queryKey: ["run-artifacts", runId],
+ enabled: runId !== null,
+ staleTime: 300_000,
+ queryFn: () => fetchAll(`/runs/${runId}/artifacts`, 200),
+ });
+}
+
+/**
+ * Fetch a run's whole build log and hand it back as one string.
+ *
+ * The artifacts endpoint reports the build log with a null download_url —
+ * it is read through /runs/{id}/logs rather than served as a file — so the
+ * only way to offer it as a download is to page it and rejoin it here. Each
+ * line's `message` is the raw log line verbatim, so joining on newline
+ * reproduces the file. Paging is cursor-based, not offset, so fetchAll
+ * cannot be reused.
+ */
+export async function fetchRunLog(runId: number, maxLines = 200_000): Promise {
+ const lines: string[] = [];
+ let cursor: string | null = null;
+ for (;;) {
+ const after = cursor ? `&cursor=${encodeURIComponent(cursor)}` : "";
+ const qs = `limit=500${after}`;
+ const page: { data: { message: string }[]; pagination?: { next_cursor: string | null } } =
+ await apiGet(`/runs/${runId}/logs?${qs}`);
+ for (const l of page.data) lines.push(l.message);
+ cursor = page.pagination?.next_cursor ?? null;
+ if (cursor === null || page.data.length === 0 || lines.length >= maxLines) break;
+ }
+ return lines.join("\n");
+}
+
+/** Infra fault derived from progress messages (VM died, build never came). */
+export interface InfraError {
+ type: string;
+ severity: string;
+ message: string;
+ timestamp?: string | null;
+}
+
+export function useInfraErrors(runId: number | null) {
+ return useQuery({
+ queryKey: ["run-infra-errors", runId],
+ enabled: runId !== null,
+ staleTime: 300_000,
+ queryFn: () => fetchAll(`/runs/${runId}/infrastructure-errors`, 50),
+ });
+}
+
+/** Unified diff for one failing output of a run. */
+export async function fetchDiff(args: {
+ runId: number;
+ sampleId: number;
+ regressionId: number;
+ outputId: number;
+}): Promise<{ content: string }> {
+ return apiGet<{ content: string }>(
+ `/runs/${args.runId}/samples/${args.sampleId}/regression-tests/${args.regressionId}` +
+ `/outputs/${args.outputId}/diff?format=unified`,
+ );
+}
+
+/**
+ * One side of a failing comparison as text.
+ *
+ * The API reads the file itself rather than handing back a storage link, so
+ * this needs no CORS grant on the bucket and works the same whether the copy
+ * lives locally or in GCS. Anything past 1 MiB comes back truncated.
+ */
+export const outputText = (
+ args: { runId: number; sampleId: number; regressionId: number; outputId: number },
+ which: "expected" | "actual",
+) =>
+ apiGet<{ filename: string; content: string; truncated: boolean }>(
+ `/runs/${args.runId}/samples/${args.sampleId}/regression-tests/${args.regressionId}` +
+ `/outputs/${args.outputId}/${which}?format=text`,
+ );
+
+/** Category list derived from the live test suite. */
+export function deriveCategories(tests: RegressionTest[]) {
+ const counts = new Map();
+ for (const t of tests) for (const c of t.categories) counts.set(c, (counts.get(c) ?? 0) + 1);
+ return [...counts.entries()]
+ .sort((a, b) => a[0].localeCompare(b[0]))
+ .map(([name, test_count], i) => ({ id: i + 1, name, description: "", test_count }));
+}
+
+/* ---------------- derived from the live suite ---------------- */
+
+export function healthOf(t: RegressionTest): SparkResult {
+ for (let i = t.recent_results.length - 1; i >= 0; i--) {
+ if (t.recent_results[i] !== "skip") return t.recent_results[i];
+ }
+ return "skip";
+}
+
+export function categoryHealth(name: string, tests: RegressionTest[]) {
+ const inCat = tests.filter((t) => t.categories.includes(name));
+ return {
+ total: inCat.length,
+ failing: inCat.filter((t) => healthOf(t) === "fail").length,
+ };
+}
+
+export function commandPresets(tests: RegressionTest[], limit = 6) {
+ const counts = new Map();
+ for (const t of tests) counts.set(t.command, (counts.get(t.command) ?? 0) + 1);
+ return [...counts.entries()]
+ .sort((a, b) => b[1] - a[1])
+ .slice(0, limit)
+ .map(([command, count]) => ({ command, count }));
+}
+
+/* ---------------- uploads and the queue ---------------- */
+
+export interface QueuedSample {
+ id: number;
+ sha: string;
+ extension: string;
+ original_name: string;
+ user_id: number;
+}
+
+/**
+ * Send a file to the upload queue.
+ *
+ * Multipart, so this cannot go through apiSend: that sets a JSON content
+ * type, and the boundary has to be the one fetch generates for the
+ * FormData.
+ */
+export async function uploadSample(file: File): Promise {
+ const session = getSession();
+ const form = new FormData();
+ form.append("file", file);
+ const res = await fetch(`${BASE}/samples/upload`, {
+ method: "POST",
+ headers: session ? { Authorization: `Bearer ${session.token}` } : {},
+ body: form,
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) throw apiError(res.status, data.message ?? res.statusText);
+ return data as QueuedSample;
+}
+
+export function useQueuedSamples() {
+ return useQuery({
+ queryKey: ["queued-samples"],
+ staleTime: 15_000,
+ queryFn: () => fetchAll("/queued-samples"),
+ });
+}
+
+export const finalizeUpload = (
+ id: number,
+ body: { version: string; platform: Platform; parameters?: string; notes?: string },
+) => apiSend<{ sample_id: number }>("POST", `/queued-samples/${id}/finalize`, body);
+
+export const linkUpload = (id: number, sampleId: number) =>
+ apiSend<{ id: number; sample_id: number }>("POST", `/queued-samples/${id}/link`, {
+ sample_id: sampleId,
+ });
+
+export const discardUpload = (id: number) =>
+ apiDelete<{ id: number; deleted: boolean }>(`/queued-samples/${id}`);
+
+export interface FtpCredentials {
+ host: string;
+ port: string;
+ username: string;
+ password: string;
+}
+
+export function useFtpCredentials(enabled: boolean) {
+ return useQuery({
+ queryKey: ["ftp-credentials"],
+ enabled,
+ staleTime: Infinity,
+ queryFn: () => apiGet("/auth/me/ftp-credentials"),
+ });
+}
+
+/* ---------------- tags and sample editing ---------------- */
+
+export interface Tag {
+ id: number;
+ name: string;
+ description: string;
+}
+
+export function useTags() {
+ return useQuery({
+ queryKey: ["tags"],
+ staleTime: 300_000,
+ queryFn: () => fetchAll("/tags"),
+ });
+}
+
+export const createTag = (name: string, description = "") =>
+ apiSend("POST", "/tags", { name, description });
+
+export const updateSample = (
+ id: number,
+ patch: { tags?: string[]; notes?: string; parameters?: string; platform?: string; version?: string },
+) => apiSend<{ sample_id: number; tags: string[] }>("PATCH", `/samples/${id}`, patch);
+
+export const deleteSample = (id: number) =>
+ apiDelete<{ sample_id: number; deleted: boolean }>(`/samples/${id}`);
+
+export const mediaInfoFile = (sampleId: number) =>
+ apiGet(`/samples/${sampleId}/media-info/download`);
+
+export const extraFile = (sampleId: number, extraId: number) =>
+ apiGet(`/samples/${sampleId}/extra-files/${extraId}/download`);
+
+export const deleteExtraFile = (sampleId: number, extraId: number) =>
+ apiDelete<{ id: number; deleted: boolean }>(
+ `/samples/${sampleId}/extra-files/${extraId}`,
+ );
+
+/* ---------------- baseline variants ---------------- */
+
+export const createVariant = (testId: number, outputId: number, hash: string) =>
+ apiSend<{ id: number; hash: string }>(
+ "POST", `/regression-tests/${testId}/outputs/${outputId}/variants`, { hash },
+ );
+
+export const deleteVariant = (testId: number, outputId: number, variantId: number) =>
+ apiDelete<{ id: number; deleted: boolean }>(
+ `/regression-tests/${testId}/outputs/${outputId}/variants/${variantId}`,
+ );
+
+/* ---------------- account ---------------- */
+
+export interface Me {
+ user_id: number;
+ name: string;
+ email: string;
+ role: PlatformUser["role"];
+ scopes: string[];
+}
+
+/**
+ * The account behind the current token.
+ *
+ * The stored session only keeps the email that was typed at sign-in, so
+ * anything that edits the account has to read the server's copy instead.
+ */
+export function useMe() {
+ return useQuery({
+ queryKey: ["me"],
+ staleTime: 300_000,
+ queryFn: () => apiGet("/auth/me"),
+ });
+}
+
+export const requestSignup = (email: string) =>
+ apiSend<{ sent: boolean }>("POST", "/auth/signup", { email });
+
+export const requestPasswordReset = (email: string) =>
+ apiSend<{ sent: boolean }>("POST", "/auth/password-reset", { email });
+
+/** Finish a reset from the link in the email. Nobody is signed in here. */
+export const completePasswordReset = (body: {
+ user_id: number;
+ expires: number;
+ mac: string;
+ password: string;
+}) =>
+ apiSend<{ user_id: number; password_changed: boolean }>(
+ "POST", "/auth/password-reset/complete", body,
+ );
+
+export interface GithubLink {
+ linked: boolean;
+ github_login: string | null;
+ authorize_url: string;
+}
+
+export function useGithubLink() {
+ return useQuery({
+ queryKey: ["github-link"],
+ staleTime: 60_000,
+ queryFn: () => apiGet("/auth/me/github"),
+ });
+}
+
+export const unlinkGithub = () =>
+ apiDelete<{ linked: boolean }>("/auth/me/github");
+
+export const updateAccount = (patch: {
+ name?: string;
+ email?: string;
+ new_password?: string;
+ current_password?: string;
+}) => apiSend("PATCH", "/auth/me", patch);
+
+export const sendUserReset = (userId: number) =>
+ apiSend<{ sent: boolean }>("POST", `/users/${userId}/password-reset`, {});
+
+export const deactivateUser = (userId: number) =>
+ apiSend<{ deactivated: boolean }>("POST", `/users/${userId}/deactivate`, {});
+
+/* ---------------- platform ---------------- */
+
+export interface About {
+ platform_commit: string | null;
+ ccextractor_version: string | null;
+ ccextractor_released: string | null;
+ last_tested_commit: string | null;
+}
+
+export function useAbout() {
+ return useQuery({
+ queryKey: ["about"],
+ staleTime: 600_000,
+ queryFn: () => apiGet("/system/about"),
+ });
+}
+
+export const restartRun = (runId: number) =>
+ apiSend<{ status: string; message: string }>("POST", `/runs/${runId}/restart`, {});
diff --git a/web/src/lib/auth.ts b/web/src/lib/auth.ts
new file mode 100644
index 00000000..fc0c20a6
--- /dev/null
+++ b/web/src/lib/auth.ts
@@ -0,0 +1,155 @@
+// Session handling. Login mints a bearer token via POST /auth/tokens, then
+// asks /auth/me for the server-side role. The role only gates what the UI
+// shows — every mutation is re-checked on the server.
+
+export type Role = "user" | "tester" | "contributor" | "admin";
+
+export interface Session {
+ token: string;
+ email: string;
+ role: Role;
+ expires_at: string;
+}
+
+const KEY = "sp-session";
+
+// Every role needs runs:write (fork runs are open to any signed-in user);
+// the write scopes that actually matter — baseline replacement and token
+// administration — are only minted once /auth/me confirms a role that can
+// use them. Keeps the stored token boring for everyone else.
+//
+// Only admins may request the elevated set: the API rejects those scopes for
+// every other role at mint time, and the endpoints behind them (baseline
+// approval, user administration, platform configuration) are admin-only
+// anyway. Contributors edit the suite with runs:write, already in the base set.
+const BASE_SCOPES = ["runs:read", "runs:write", "results:read", "system:read"];
+const ELEVATED_SCOPES = [
+ ...BASE_SCOPES,
+ "baselines:write",
+ "tokens:manage",
+ "system:write",
+];
+
+export function getSession(): Session | null {
+ try {
+ const raw = localStorage.getItem(KEY);
+ if (!raw) return null;
+ const s = JSON.parse(raw) as Session;
+ if (new Date(s.expires_at) < new Date()) {
+ localStorage.removeItem(KEY);
+ return null;
+ }
+ return s;
+ } catch {
+ return null;
+ }
+}
+
+export function canManage(s: Session | null): boolean {
+ return s?.role === "admin" || s?.role === "contributor";
+}
+
+/** Roles allowed to queue/cancel CI runs (mirrors the API's require_roles). */
+export function canOperateCi(s: Session | null): boolean {
+ return s?.role === "admin" || s?.role === "contributor" || s?.role === "tester";
+}
+
+interface TokenResponse {
+ token: string;
+ expires_at: string;
+}
+
+async function mintToken(email: string, password: string, scopes: string[]): Promise {
+ const res = await fetch("/api/v1/auth/tokens", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ email,
+ password,
+ token_name: `web-console-${crypto.randomUUID().slice(0, 8)}`,
+ scopes,
+ }),
+ });
+ if (!res.ok) {
+ // Deliberately vague on auth failures — precise reasons enable account
+ // enumeration. The server's message still lands in the console.
+ const body = await res.json().catch(() => ({}));
+ console.warn("login failed:", res.status, body.message ?? res.statusText);
+ throw new Error(
+ res.status === 401 || res.status === 403
+ ? "Email or password is incorrect."
+ : "Sign-in failed — try again in a moment.",
+ );
+ }
+ return res.json();
+}
+
+async function revokeOnServer(token: string): Promise {
+ try {
+ await fetch("/api/v1/auth/tokens/current", {
+ method: "DELETE",
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ } catch {
+ // Offline or server down — the token still dies at its server-side expiry.
+ }
+}
+
+export async function login(email: string, password: string): Promise {
+ let minted = await mintToken(email, password, BASE_SCOPES);
+
+ // Fetch the role the server actually assigned to this account.
+ const meRes = await fetch("/api/v1/auth/me", {
+ headers: { Authorization: `Bearer ${minted.token}` },
+ });
+ const me = meRes.ok ? await meRes.json() : { role: "user" };
+ const role = (me.role as Role) ?? "user";
+
+ if (role === "admin") {
+ // Swap the probe token for a write-capable one; the probe is revoked
+ // either way so a failed upgrade doesn't leave it behind.
+ const probe = minted.token;
+ try {
+ minted = await mintToken(email, password, ELEVATED_SCOPES);
+ revokeOnServer(probe);
+ } catch (e) {
+ // Keep the working base-scope session rather than failing the sign-in;
+ // admin-only actions will surface a permission error if it comes to it.
+ console.warn("elevated token mint failed, continuing with base scopes:", e);
+ }
+ }
+
+ const session: Session = {
+ token: minted.token,
+ email,
+ role,
+ expires_at: minted.expires_at,
+ };
+ localStorage.setItem(KEY, JSON.stringify(session));
+ return session;
+}
+
+/** Fired when the stored session changes under a mounted shell. */
+export const SESSION_CHANGED = "sp-session-changed";
+
+/** Keep the cached session in step once the account's email changes. */
+export function setSessionEmail(email: string) {
+ const s = getSession();
+ if (!s) return;
+ localStorage.setItem(KEY, JSON.stringify({ ...s, email }));
+ window.dispatchEvent(new Event(SESSION_CHANGED));
+}
+
+let loggingOut = false;
+
+export function logout() {
+ // Parallel queries all hit 401 at once when a token dies — only the first
+ // caller gets to tear the session down and reload.
+ if (loggingOut) return;
+ loggingOut = true;
+ const s = getSession();
+ localStorage.removeItem(KEY);
+ const done = () => window.location.reload();
+ if (s) revokeOnServer(s.token).finally(done);
+ else done();
+}
diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts
new file mode 100644
index 00000000..4d9aadba
--- /dev/null
+++ b/web/src/lib/types.ts
@@ -0,0 +1,123 @@
+// Domain types. Field names follow the API's serializers so responses can
+// be used mostly as-is; the snapshot JSON under src/mocks/generated uses
+// the same shapes.
+
+export type Platform = "linux" | "windows";
+
+/** Normalized run statuses from mod_api services/status.py, plus the VM
+ * progress phases reported while a run executes. */
+export type RunStatus =
+ | "queued"
+ | "running"
+ | "pass"
+ | "fail"
+ | "canceled"
+ | "error"
+ | "incomplete"
+ | "preparation"
+ | "building"
+ | "testing"
+ | "completed";
+
+export type SparkResult = "pass" | "fail" | "skip";
+
+export interface Category {
+ id: number;
+ name: string;
+ description: string;
+ test_count: number;
+}
+
+export interface RegressionTest {
+ id: number;
+ sample_id: number;
+ sample_name: string;
+ sample_sha: string;
+ command: string;
+ input_type: string | null;
+ output_type: string | null;
+ expected_rc: number;
+ active: boolean;
+ description: string;
+ categories: string[];
+ avg_runtime_ms: number | null;
+ /** Recent runs, oldest first. Baselines come from the detail endpoint. */
+ recent_results: SparkResult[];
+}
+
+export interface Sample {
+ id: number;
+ sha: string;
+ extension: string;
+ original_name: string;
+ tags: string[];
+ test_count: number;
+}
+
+/** Raw run row from GET /api/v1/runs. */
+export interface LiveRun {
+ run_id: number;
+ status: RunStatus;
+ platform: Platform;
+ test_type: "pr" | "commit";
+ repository: string;
+ branch: string;
+ commit_sha: string;
+ pr_number: number | null;
+ created_at: string | null;
+ queued_at: string | null;
+ started_at: string | null;
+ completed_at: string | null;
+ github_link: string | null;
+}
+
+/** GET /api/v1/runs//summary */
+export interface RunSummary {
+ run_id: number;
+ status: RunStatus;
+ total_samples: number;
+ pass_count: number;
+ fail_count: number;
+ error_count: number;
+ missing_output_count: number;
+ skipped_count: number;
+ duration_ms: number | null;
+}
+
+export interface LogicalPlatformRun {
+ run_id: number;
+ platform: Platform;
+ status: RunStatus;
+ started_at: string | null;
+ completed_at: string | null;
+}
+
+/** Logical run = all platforms for one commit/PR event (grouped client-side). */
+export interface LogicalRun {
+ id: string;
+ commit: string;
+ branch: string;
+ fork: string;
+ pr_nr: number | null;
+ test_type: "pr" | "commit";
+ created_at: string | null;
+ github_link: string | null;
+ platforms: LogicalPlatformRun[];
+}
+
+/** Snapshot run shape (mocks/generated/runs.json, from prod dump). */
+export interface SnapshotPlatformRun {
+ test_id: number;
+ platform: Platform;
+ status: string;
+ message: string;
+ passed: number;
+ failed: number;
+ new_failures: number;
+ total: number;
+ duration_s: number | null;
+ started_at: string | null;
+ failing_ids: number[];
+}
+
+
diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts
new file mode 100644
index 00000000..3d871804
--- /dev/null
+++ b/web/src/lib/utils.ts
@@ -0,0 +1,17 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
+
+/**
+ * URL for a file in public/, honouring the base the bundle was built with.
+ *
+ * The console is served from /app/ on the platform's domain rather than from
+ * a root, and Vite only rewrites paths it can see — those in index.html, not
+ * string literals inside components.
+ */
+export function asset(name: string): string {
+ return `${import.meta.env.BASE_URL}${name}`;
+}
diff --git a/web/src/lib/validate.ts b/web/src/lib/validate.ts
new file mode 100644
index 00000000..d6cf4430
--- /dev/null
+++ b/web/src/lib/validate.ts
@@ -0,0 +1,42 @@
+// Input validation for values that end up on CI infrastructure or inside
+// rendered links. The server is the real gate — these exist so obviously bad
+// input fails fast in the form, and so a hostile API response can't smuggle
+// a javascript: URL into an .
+
+/** Full 40-hex commit SHA. */
+export const isCommitSha = (v: string) => /^[a-fA-F0-9]{40}$/.test(v);
+
+/** GitHub "owner/repo" slug. */
+export const isRepoSlug = (v: string) => /^[\w.-]+\/[\w.-]+$/.test(v);
+
+/**
+ * Git allows almost anything in a ref name, but nothing we forward to CI
+ * needs whitespace, shell metacharacters or a leading dash. Rejecting them
+ * here beats trusting every downstream consumer to quote properly.
+ */
+export const isBranchName = (v: string) => /^[A-Za-z0-9][A-Za-z0-9._/-]{0,199}$/.test(v);
+
+/** Length cap for regression-test commands (they execute on CI VMs). */
+export const COMMAND_MAX = 512;
+
+/**
+ * Allowlist for outbound links. React does not sanitize href schemes, so an
+ * API-supplied javascript: URL would execute in our origin on click. Only
+ * github.com URLs ever come back from the platform, so only those render
+ * as links; anything else is dropped.
+ */
+export function githubUrl(url: string | null | undefined): string | null {
+ if (!url) return null;
+ return /^https:\/\/github\.com\/[\w.-]+\/[\w.-]+([/?#]|$)/.test(url) ? url : null;
+}
+
+/**
+ * Allowlist for artifact download links. Same reasoning as githubUrl — these
+ * come straight from the API (signed storage URLs), and a javascript:/data:
+ * value would run in our origin on click. Only https:// downloads are ever
+ * expected; anything else renders as no link.
+ */
+export function httpsUrl(url: string | null | undefined): string | null {
+ if (!url) return null;
+ return /^https:\/\//i.test(url) ? url : null;
+}
diff --git a/web/src/pages/Account.tsx b/web/src/pages/Account.tsx
new file mode 100644
index 00000000..fdfca224
--- /dev/null
+++ b/web/src/pages/Account.tsx
@@ -0,0 +1,368 @@
+import { useQueryClient } from "@tanstack/react-query";
+import { ExternalLink, GitBranch, KeyRound, Loader2, UserRound, UserX } from "lucide-react";
+import { useEffect, useState } from "react";
+
+import { Button } from "@/components/ui/button";
+import { ConfirmDialog } from "@/components/ui/confirm";
+import { Input } from "@/components/ui/input";
+import {
+ deactivateUser,
+ unlinkGithub,
+ updateAccount,
+ useGithubLink,
+ useMe,
+} from "@/lib/api";
+import { logout, setSessionEmail } from "@/lib/auth";
+
+/**
+ * Your own account: name, email, password and closing it.
+ *
+ * Everything here is the caller acting on themselves, so it needs no role.
+ * Administration of other people's accounts stays on the administration
+ * page, where the rest of the platform's privileged actions live.
+ */
+export function Account() {
+ const { data: me, isLoading } = useMe();
+
+ return (
+
+
Your account
+
+ {me ? (
+ <>
+ Signed in as {me.email} ·{" "}
+ {me.role}
+ >
+ ) : (
+ "Loading your details…"
+ )}
+
+
+ {isLoading &&
}
+ {me && (
+
+ )}
+
+ );
+}
+
+function ProfileSection({ name, email }: Readonly<{ name: string; email: string }>) {
+ const qc = useQueryClient();
+ const [draftName, setDraftName] = useState(name);
+ const [draftEmail, setDraftEmail] = useState(email);
+ const [current, setCurrent] = useState("");
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+ const [saved, setSaved] = useState(false);
+
+ // The query refetches after a save, so pick the server's values back up
+ // rather than leaving whatever was typed sitting in the inputs.
+ useEffect(() => {
+ setDraftName(name);
+ setDraftEmail(email);
+ }, [name, email]);
+
+ const emailChanged = draftEmail !== email;
+ const nameChanged = draftName !== name;
+ const dirty = emailChanged || nameChanged;
+ // Both are required on the account, so an emptied field is a round trip
+ // the server would only reject.
+ const complete = draftName.trim() !== "" && draftEmail.trim() !== "";
+
+ const save = async () => {
+ setBusy(true);
+ setError(null);
+ setSaved(false);
+ try {
+ const patch: Parameters[0] = {};
+ if (nameChanged) patch.name = draftName.trim();
+ if (emailChanged) {
+ patch.email = draftEmail.trim();
+ patch.current_password = current;
+ }
+ await updateAccount(patch);
+ setCurrent("");
+ // The sidebar reads the email out of the stored session rather than
+ // from the API, so it has to be told when this changes it.
+ if (emailChanged) setSessionEmail(draftEmail.trim());
+ await qc.invalidateQueries({ queryKey: ["me"] });
+ setSaved(true);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "That did not work.");
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+ );
+}
+
+function PasswordSection() {
+ const [current, setCurrent] = useState("");
+ const [next, setNext] = useState("");
+ const [confirm, setConfirm] = useState("");
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+ const [done, setDone] = useState(false);
+
+ const mismatch = confirm.length > 0 && next !== confirm;
+
+ const save = async () => {
+ setBusy(true);
+ setError(null);
+ setDone(false);
+ try {
+ await updateAccount({ current_password: current, new_password: next });
+ setCurrent("");
+ setNext("");
+ setConfirm("");
+ setDone(true);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "That did not work.");
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+ );
+}
+
+/**
+ * The GitHub connection, used for runs on your own forks and pull requests.
+ *
+ * Connecting is an OAuth redirect that finishes on the platform's classic
+ * callback, so this sends you there and picks the result up afterwards
+ * rather than handling the exchange itself.
+ */
+function GithubSection() {
+ const qc = useQueryClient();
+ const { data, isLoading } = useGithubLink();
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+
+ const disconnect = async () => {
+ setBusy(true);
+ setError(null);
+ try {
+ await unlinkGithub();
+ await qc.invalidateQueries({ queryKey: ["github-link"] });
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "That did not work.");
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+
+ GitHub
+
+
+ {isLoading &&
}
+ {data && (
+ <>
+
+ {data.linked ? (
+ <>
+ Connected as{" "}
+ @{data.github_login} .
+ Runs on your own forks and pull requests use this.
+ >
+ ) : (
+ <>
+ Not connected. Connecting lets the platform queue runs for your own
+ forks and pull requests.
+ >
+ )}
+
+ {error &&
{error}
}
+ {data.linked ? (
+
+ {busy && } Disconnect
+
+ ) : (
+
+
+ Connect to GitHub
+
+
+ )}
+ {/* The redirect finishes on the classic site, so this page will
+ not know about it until it is loaded again. */}
+
+ {data.linked
+ ? "Disconnecting only forgets the platform's copy. Withdraw the authorisation itself from your GitHub applications page."
+ : "GitHub opens in a new tab. Reload this page once you are done there."}
+
+ >
+ )}
+
+
+ );
+}
+
+function CloseSection({ userId }: Readonly<{ userId: number }>) {
+ const [confirming, setConfirming] = useState(false);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+
+ const go = async () => {
+ setBusy(true);
+ setError(null);
+ try {
+ await deactivateUser(userId);
+ logout();
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "That did not work.");
+ setBusy(false);
+ setConfirming(false);
+ }
+ };
+
+ return (
+
+
+ Close account
+
+
+
+ Your name and email are replaced with a placeholder and the password is scrambled.
+ The account itself stays so the samples and runs you own keep an author.
+
+ {error &&
{error}
}
+
setConfirming(true)}>
+ Close my account
+
+
+
+ You are signed out straight away and cannot sign back in.{" "}
+ This cannot be undone. Ask an administrator if you need the account back.
+ >
+ }
+ confirmLabel={busy ? "Closing…" : "Close account"}
+ busy={busy}
+ onConfirm={go}
+ />
+
+ );
+}
+
+function Field({ label, children }: Readonly<{ label: string; children: React.ReactNode }>) {
+ return (
+
+ {label}
+ {children}
+
+ );
+}
+
+function SectionLabel({ children }: Readonly<{ children: React.ReactNode }>) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json
new file mode 100644
index 00000000..18a3a5a7
--- /dev/null
+++ b/web/tsconfig.app.json
@@ -0,0 +1,33 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "es2023",
+ "lib": ["ES2023", "DOM"],
+ "module": "esnext",
+ "types": ["vite/client"],
+ "allowArbitraryExtensions": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Paths */
+ "paths": {
+ "@/*": ["./src/*"]
+ },
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"]
+}
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 00000000..1ffef600
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json
new file mode 100644
index 00000000..8455dcbc
--- /dev/null
+++ b/web/tsconfig.node.json
@@ -0,0 +1,23 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "es2023",
+ "lib": ["ES2023"],
+ "types": ["node"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "module": "nodenext",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/web/vite.config.ts b/web/vite.config.ts
new file mode 100644
index 00000000..20c1198b
--- /dev/null
+++ b/web/vite.config.ts
@@ -0,0 +1,24 @@
+import path from "node:path";
+import tailwindcss from "@tailwindcss/vite";
+import react from "@vitejs/plugin-react";
+import { defineConfig } from "vite";
+
+// https://vite.dev/config/
+export default defineConfig({
+ plugins: [react(), tailwindcss()],
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ },
+ },
+ server: {
+ port: 5173,
+ proxy: {
+ // Flask dev server (mod_api blueprint at /api/v1)
+ "/api": {
+ target: "http://localhost:5001",
+ changeOrigin: true,
+ },
+ },
+ },
+});