diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 17d4369..c8355ec 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,9 +2,16 @@ name: CI
# Branch pushes are covered by the pull_request trigger. Listening to both for
# every branch ran the whole matrix twice per PR.
+#
+# website/ holds the GitHub Pages site and no Rust. Ignoring it here must stay
+# in lockstep with the same list in release.yml: the two skip together, so the
+# release ci-gate never waits for a CI run that was never queued. Deliberately
+# not applied to pull_request — CI is a required check there, and a skipped
+# required check leaves the PR permanently unmergeable.
on:
push:
branches: [master]
+ paths-ignore: ['website/**']
pull_request:
branches: ['**']
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
new file mode 100644
index 0000000..d4ff9bb
--- /dev/null
+++ b/.github/workflows/pages.yml
@@ -0,0 +1,73 @@
+name: Pages
+
+# Only the website. The Rust workflows ignore this same path, so a site-only
+# push runs this alone and a code-only push never rebuilds the site.
+on:
+ push:
+ branches: [master]
+ paths: ['website/**', '.github/workflows/pages.yml']
+ pull_request:
+ branches: ['**']
+ paths: ['website/**', '.github/workflows/pages.yml']
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ build:
+ name: Build
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ defaults:
+ run:
+ working-directory: website
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ # --frozen-lockfile so a drifted bun.lock fails here rather than
+ # silently shipping different dependency versions than were tested.
+ - name: Install
+ run: bun install --frozen-lockfile
+
+ - name: Typecheck
+ run: bun run typecheck
+
+ - name: Lint
+ run: bun run lint
+
+ # Emits one static HTML file per route plus a 404, so direct links
+ # answer 200 with real markup instead of the SPA shell.
+ - name: Build
+ run: bun run build
+
+ - uses: actions/upload-pages-artifact@v3
+ with:
+ path: website/dist
+
+ deploy:
+ name: Deploy
+ # Pull requests get the build as a check; only master publishes.
+ if: github.ref == 'refs/heads/master' && github.event_name != 'pull_request'
+ needs: build
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ # Scoped to the deploy, not the workflow: a workflow-level group would
+ # queue PR builds behind an in-flight master deploy for no reason. Never
+ # cancel in flight — a half-uploaded artifact leaves the live site broken.
+ concurrency:
+ group: pages
+ cancel-in-progress: false
+ permissions:
+ pages: write
+ id-token: write
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index fbdef1d..707ec0b 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -2,7 +2,10 @@ name: Release
on:
push:
+ # Must match ci.yml's paths-ignore exactly. A website-only push skips both
+ # workflows, so ci-gate is never left polling for an absent CI run.
branches: [master]
+ paths-ignore: ['website/**']
workflow_dispatch:
inputs:
tag:
@@ -48,7 +51,11 @@ jobs:
2>/dev/null || echo "unreachable")
case "$verdict" in
success) echo "CI passed for $GITHUB_SHA"; exit 0 ;;
- absent|running|unreachable) ;;
+ # `cancelled` is what CI's own cancel-in-progress does to the
+ # older SHA when two pushes land back to back. Treating it as a
+ # failure turned every rapid push into a red release; keep
+ # waiting instead, and let the newer SHA's run decide.
+ absent|running|unreachable|cancelled) ;;
*) echo "::error::CI concluded '$verdict' for $GITHUB_SHA - refusing to release"; exit 1 ;;
esac
sleep 20
diff --git a/website/.gitignore b/website/.gitignore
new file mode 100644
index 0000000..a547bf3
--- /dev/null
+++ b/website/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/website/README.md b/website/README.md
new file mode 100644
index 0000000..1ad3950
--- /dev/null
+++ b/website/README.md
@@ -0,0 +1,50 @@
+# website
+
+The soulseek-rs project site. Vite + React + TypeScript + Tailwind v4,
+deployed to GitHub Pages by `.github/workflows/pages.yml`.
+
+```bash
+bun install
+bun run dev # http://localhost:5173/soulseek-rs/
+bun run build # client build, SSR build, then prerender
+bun run typecheck
+bun run lint
+```
+
+## How the static site is produced
+
+`bun run build` runs three steps:
+
+1. `vite build` — the client bundle, plus `dist/index.html` as the template.
+2. `vite build --ssr src/entry-server.tsx` — the same app compiled for Node.
+3. `bun scripts/prerender.ts` — renders every route in `src/lib/routes.ts` to
+ its own file (`dist/install/index.html`, and so on) with that route's title
+ and description substituted in, plus a `404.html`.
+
+Without step 3 GitHub Pages has no file at `/install` and answers a direct link
+with a 404. With it, every URL returns 200 with fully rendered markup, and the
+client hydrates that markup rather than replacing it.
+
+Adding a page means: a component in `src/pages/`, a `` in `src/App.tsx`,
+and an entry in `src/lib/routes.ts`. The last one is what the prerenderer walks.
+
+## Design system
+
+Tokens live in `src/index.css`, ported from the Claude Design project's
+`tokens/colors.css`. They are the same values that back the TUI in
+`soulseek-rs/src/ui/styles.rs` — change one, change both.
+
+Dark is the native theme; light re-cuts the semantic hues roughly 10% darker,
+because phosphor and tape are legible on vinyl but wash out on manila. Theme
+selection is `[data-theme]` on ``, defaulting to the OS setting, with an
+inline script in `index.html` applying it before first paint so the page never
+flashes the wrong background.
+
+## Notes
+
+- `base` in `vite.config.ts` is `/soulseek-rs/` because this is a project site.
+ Pointing a custom domain at it means setting that to `/` (the router reads
+ `import.meta.env.BASE_URL`, so it follows automatically).
+- Copy in `src/pages/` follows the project's tone of voice: plainspoken,
+ precise, honest about limits first. No copyrighted artist or album names
+ anywhere, including the TUI demo data in `src/components/tui/data.ts`.
diff --git a/website/bun.lock b/website/bun.lock
new file mode 100644
index 0000000..e524df0
--- /dev/null
+++ b/website/bun.lock
@@ -0,0 +1,713 @@
+{
+ "lockfileVersion": 1,
+ "configVersion": 1,
+ "workspaces": {
+ "": {
+ "name": "soulseek-rs-website",
+ "dependencies": {
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "react": "^19.2.8",
+ "react-dom": "^19.2.8",
+ "react-router": "^8.3.0",
+ "tailwind-merge": "^3.6.0",
+ },
+ "devDependencies": {
+ "@eslint/js": "^10.0.1",
+ "@tailwindcss/vite": "^4.3.3",
+ "@types/node": "^26.1.1",
+ "@types/react": "^19.2.17",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^6.0.4",
+ "eslint": "^10.7.0",
+ "eslint-plugin-jsx-a11y": "^6.10.2",
+ "eslint-plugin-react-hooks": "^7.1.1",
+ "eslint-plugin-react-refresh": "^0.5.3",
+ "globals": "^17.7.0",
+ "tailwindcss": "^4.3.3",
+ "typescript": "~6.0.3",
+ "typescript-eslint": "^8.65.0",
+ "vite": "^8.1.5",
+ },
+ },
+ },
+ "packages": {
+ "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
+
+ "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
+
+ "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
+
+ "@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="],
+
+ "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
+
+ "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
+
+ "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
+
+ "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
+
+ "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
+
+ "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
+
+ "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
+
+ "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="],
+
+ "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
+
+ "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
+
+ "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="],
+
+ "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
+
+ "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="],
+
+ "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
+
+ "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
+
+ "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.10.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg=="],
+
+ "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
+
+ "@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="],
+
+ "@eslint/config-helpers": ["@eslint/config-helpers@0.6.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA=="],
+
+ "@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="],
+
+ "@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="],
+
+ "@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="],
+
+ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="],
+
+ "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="],
+
+ "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="],
+
+ "@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="],
+
+ "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
+
+ "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
+
+ "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
+
+ "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
+
+ "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
+
+ "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
+
+ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
+
+ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="],
+
+ "@oxc-project/types": ["@oxc-project/types@0.139.0", "", {}, "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw=="],
+
+ "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.5", "", { "os": "android", "cpu": "arm64" }, "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ=="],
+
+ "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw=="],
+
+ "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g=="],
+
+ "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA=="],
+
+ "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.5", "", { "os": "linux", "cpu": "arm" }, "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw=="],
+
+ "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q=="],
+
+ "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA=="],
+
+ "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg=="],
+
+ "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA=="],
+
+ "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ=="],
+
+ "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg=="],
+
+ "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.5", "", { "os": "none", "cpu": "arm64" }, "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw=="],
+
+ "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.5", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA=="],
+
+ "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw=="],
+
+ "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA=="],
+
+ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
+
+ "@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="],
+
+ "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="],
+
+ "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.3", "", { "os": "android", "cpu": "arm64" }, "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw=="],
+
+ "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw=="],
+
+ "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw=="],
+
+ "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw=="],
+
+ "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ=="],
+
+ "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w=="],
+
+ "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA=="],
+
+ "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w=="],
+
+ "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img=="],
+
+ "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.3", "", { "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" }, "cpu": "none" }, "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ=="],
+
+ "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ=="],
+
+ "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw=="],
+
+ "@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="],
+
+ "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
+
+ "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
+
+ "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
+
+ "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
+
+ "@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="],
+
+ "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
+
+ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
+
+ "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.65.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.65.0", "@typescript-eslint/type-utils": "8.65.0", "@typescript-eslint/utils": "8.65.0", "@typescript-eslint/visitor-keys": "8.65.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.65.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA=="],
+
+ "@typescript-eslint/parser": ["@typescript-eslint/parser@8.65.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.65.0", "@typescript-eslint/types": "8.65.0", "@typescript-eslint/typescript-estree": "8.65.0", "@typescript-eslint/visitor-keys": "8.65.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA=="],
+
+ "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.65.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.65.0", "@typescript-eslint/types": "^8.65.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q=="],
+
+ "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.65.0", "", { "dependencies": { "@typescript-eslint/types": "8.65.0", "@typescript-eslint/visitor-keys": "8.65.0" } }, "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg=="],
+
+ "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.65.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg=="],
+
+ "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.65.0", "", { "dependencies": { "@typescript-eslint/types": "8.65.0", "@typescript-eslint/typescript-estree": "8.65.0", "@typescript-eslint/utils": "8.65.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g=="],
+
+ "@typescript-eslint/types": ["@typescript-eslint/types@8.65.0", "", {}, "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg=="],
+
+ "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.65.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.65.0", "@typescript-eslint/tsconfig-utils": "8.65.0", "@typescript-eslint/types": "8.65.0", "@typescript-eslint/visitor-keys": "8.65.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg=="],
+
+ "@typescript-eslint/utils": ["@typescript-eslint/utils@8.65.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.65.0", "@typescript-eslint/types": "8.65.0", "@typescript-eslint/typescript-estree": "8.65.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA=="],
+
+ "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.65.0", "", { "dependencies": { "@typescript-eslint/types": "8.65.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A=="],
+
+ "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.4", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg=="],
+
+ "acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="],
+
+ "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
+
+ "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
+
+ "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
+
+ "array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="],
+
+ "array-includes": ["array-includes@3.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.0", "es-object-atoms": "^1.1.1", "get-intrinsic": "^1.3.0", "is-string": "^1.1.1", "math-intrinsics": "^1.1.0" } }, "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ=="],
+
+ "array.prototype.flat": ["array.prototype.flat@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg=="],
+
+ "array.prototype.flatmap": ["array.prototype.flatmap@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg=="],
+
+ "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="],
+
+ "ast-types-flow": ["ast-types-flow@0.0.8", "", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="],
+
+ "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
+
+ "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
+
+ "axe-core": ["axe-core@4.12.1", "", {}, "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA=="],
+
+ "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
+
+ "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
+
+ "baseline-browser-mapping": ["baseline-browser-mapping@2.11.1", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A=="],
+
+ "brace-expansion": ["brace-expansion@5.0.8", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg=="],
+
+ "browserslist": ["browserslist@4.28.7", "", { "dependencies": { "baseline-browser-mapping": "^2.10.44", "caniuse-lite": "^1.0.30001806", "electron-to-chromium": "^1.5.393", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw=="],
+
+ "call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="],
+
+ "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
+
+ "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
+
+ "caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="],
+
+ "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
+
+ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
+
+ "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
+
+ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
+
+ "cookie-es": ["cookie-es@3.1.1", "", {}, "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg=="],
+
+ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
+
+ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
+
+ "damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="],
+
+ "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
+
+ "data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="],
+
+ "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="],
+
+ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+
+ "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
+
+ "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
+
+ "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
+
+ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
+
+ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
+
+ "electron-to-chromium": ["electron-to-chromium@1.5.396", "", {}, "sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ=="],
+
+ "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
+
+ "enhanced-resolve": ["enhanced-resolve@5.24.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ=="],
+
+ "es-abstract": ["es-abstract@1.24.2", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg=="],
+
+ "es-abstract-get": ["es-abstract-get@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "es-object-atoms": "^1.1.2", "is-callable": "^1.2.7", "object-inspect": "^1.13.4" } }, "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg=="],
+
+ "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
+
+ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
+
+ "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="],
+
+ "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
+
+ "es-shim-unscopables": ["es-shim-unscopables@1.1.0", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw=="],
+
+ "es-to-primitive": ["es-to-primitive@1.3.4", "", { "dependencies": { "es-abstract-get": "^1.0.0", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "is-callable": "^1.2.7", "is-date-object": "^1.1.0", "is-symbol": "^1.1.1" } }, "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw=="],
+
+ "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
+
+ "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
+
+ "eslint": ["eslint@10.7.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ=="],
+
+ "eslint-plugin-jsx-a11y": ["eslint-plugin-jsx-a11y@6.10.2", "", { "dependencies": { "aria-query": "^5.3.2", "array-includes": "^3.1.8", "array.prototype.flatmap": "^1.3.2", "ast-types-flow": "^0.0.8", "axe-core": "^4.10.0", "axobject-query": "^4.1.0", "damerau-levenshtein": "^1.0.8", "emoji-regex": "^9.2.2", "hasown": "^2.0.2", "jsx-ast-utils": "^3.3.5", "language-tags": "^1.0.9", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "safe-regex-test": "^1.0.3", "string.prototype.includes": "^2.0.1" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" } }, "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q=="],
+
+ "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.1.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g=="],
+
+ "eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.5.3", "", { "peerDependencies": { "eslint": "^9 || ^10" } }, "sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA=="],
+
+ "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="],
+
+ "eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
+
+ "espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="],
+
+ "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
+
+ "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
+
+ "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
+
+ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
+
+ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
+
+ "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
+
+ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
+
+ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
+
+ "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
+
+ "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
+
+ "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
+
+ "flatted": ["flatted@3.4.3", "", {}, "sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ=="],
+
+ "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
+
+ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+
+ "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
+
+ "function.prototype.name": ["function.prototype.name@1.2.0", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2", "hasown": "^2.0.4", "is-callable": "^1.2.7", "is-document.all": "^1.0.0" } }, "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew=="],
+
+ "functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="],
+
+ "generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="],
+
+ "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
+
+ "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
+
+ "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
+
+ "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="],
+
+ "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
+
+ "globals": ["globals@17.7.0", "", {}, "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg=="],
+
+ "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="],
+
+ "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
+
+ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
+
+ "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="],
+
+ "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="],
+
+ "has-proto": ["has-proto@1.2.0", "", { "dependencies": { "dunder-proto": "^1.0.0" } }, "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ=="],
+
+ "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
+
+ "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
+
+ "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
+
+ "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
+
+ "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
+
+ "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
+
+ "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
+
+ "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
+
+ "is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
+
+ "is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="],
+
+ "is-bigint": ["is-bigint@1.1.0", "", { "dependencies": { "has-bigints": "^1.0.2" } }, "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ=="],
+
+ "is-boolean-object": ["is-boolean-object@1.2.2", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="],
+
+ "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="],
+
+ "is-data-view": ["is-data-view@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw=="],
+
+ "is-date-object": ["is-date-object@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="],
+
+ "is-document.all": ["is-document.all@1.0.0", "", { "dependencies": { "call-bound": "^1.0.4" } }, "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g=="],
+
+ "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
+
+ "is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="],
+
+ "is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="],
+
+ "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
+
+ "is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="],
+
+ "is-negative-zero": ["is-negative-zero@2.0.3", "", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="],
+
+ "is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="],
+
+ "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
+
+ "is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="],
+
+ "is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="],
+
+ "is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="],
+
+ "is-symbol": ["is-symbol@1.1.1", "", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="],
+
+ "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="],
+
+ "is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="],
+
+ "is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="],
+
+ "is-weakset": ["is-weakset@2.0.4", "", { "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ=="],
+
+ "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
+
+ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
+
+ "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
+
+ "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
+
+ "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
+
+ "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
+
+ "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
+
+ "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
+
+ "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
+
+ "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="],
+
+ "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
+
+ "language-subtag-registry": ["language-subtag-registry@0.3.23", "", {}, "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ=="],
+
+ "language-tags": ["language-tags@1.0.9", "", { "dependencies": { "language-subtag-registry": "^0.3.20" } }, "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA=="],
+
+ "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
+
+ "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
+
+ "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
+
+ "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
+
+ "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="],
+
+ "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="],
+
+ "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="],
+
+ "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="],
+
+ "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="],
+
+ "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="],
+
+ "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="],
+
+ "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="],
+
+ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
+
+ "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
+
+ "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
+
+ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
+
+ "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
+
+ "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
+
+ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+
+ "nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="],
+
+ "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
+
+ "node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="],
+
+ "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
+
+ "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="],
+
+ "object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="],
+
+ "object.fromentries": ["object.fromentries@2.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-object-atoms": "^1.0.0" } }, "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ=="],
+
+ "object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="],
+
+ "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
+
+ "own-keys": ["own-keys@1.0.2", "", { "dependencies": { "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg=="],
+
+ "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
+
+ "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
+
+ "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
+
+ "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
+
+ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
+
+ "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
+
+ "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
+
+ "postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="],
+
+ "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
+
+ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
+
+ "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
+
+ "react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
+
+ "react-router": ["react-router@8.3.0", "", { "dependencies": { "cookie-es": "^3.1.1" }, "peerDependencies": { "react": ">=19.2.7", "react-dom": ">=19.2.7" }, "optionalPeers": ["react-dom"] }, "sha512-qyPMvW83jGIct3yiieisxdk9M745anqhpIMKN5m1t6yBMfgVPpt77aHOqs5fUlEJRMCGffg9BaQLH9oPVOL7xQ=="],
+
+ "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
+
+ "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
+
+ "rolldown": ["rolldown@1.1.5", "", { "dependencies": { "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.5", "@rolldown/binding-darwin-arm64": "1.1.5", "@rolldown/binding-darwin-x64": "1.1.5", "@rolldown/binding-freebsd-x64": "1.1.5", "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", "@rolldown/binding-linux-arm64-gnu": "1.1.5", "@rolldown/binding-linux-arm64-musl": "1.1.5", "@rolldown/binding-linux-ppc64-gnu": "1.1.5", "@rolldown/binding-linux-s390x-gnu": "1.1.5", "@rolldown/binding-linux-x64-gnu": "1.1.5", "@rolldown/binding-linux-x64-musl": "1.1.5", "@rolldown/binding-openharmony-arm64": "1.1.5", "@rolldown/binding-wasm32-wasi": "1.1.5", "@rolldown/binding-win32-arm64-msvc": "1.1.5", "@rolldown/binding-win32-x64-msvc": "1.1.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA=="],
+
+ "safe-array-concat": ["safe-array-concat@1.1.4", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg=="],
+
+ "safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="],
+
+ "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
+
+ "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
+
+ "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
+
+ "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
+
+ "set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="],
+
+ "set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="],
+
+ "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
+
+ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
+
+ "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="],
+
+ "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
+
+ "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
+
+ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
+
+ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
+
+ "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
+
+ "string.prototype.includes": ["string.prototype.includes@2.0.1", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="],
+
+ "string.prototype.trim": ["string.prototype.trim@1.2.11", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.2", "es-object-atoms": "^1.1.2", "has-property-descriptors": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w=="],
+
+ "string.prototype.trimend": ["string.prototype.trimend@1.0.10", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-object-atoms": "^1.1.2" } }, "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw=="],
+
+ "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="],
+
+ "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="],
+
+ "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
+
+ "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
+
+ "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
+
+ "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
+
+ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
+ "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
+
+ "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="],
+
+ "typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="],
+
+ "typed-array-byte-offset": ["typed-array-byte-offset@1.0.4", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.15", "reflect.getprototypeof": "^1.0.9" } }, "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ=="],
+
+ "typed-array-length": ["typed-array-length@1.0.8", "", { "dependencies": { "call-bind": "^1.0.9", "for-each": "^0.3.5", "gopd": "^1.2.0", "is-typed-array": "^1.1.15", "possible-typed-array-names": "^1.1.0", "reflect.getprototypeof": "^1.0.10" } }, "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g=="],
+
+ "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
+
+ "typescript-eslint": ["typescript-eslint@8.65.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.65.0", "@typescript-eslint/parser": "8.65.0", "@typescript-eslint/typescript-estree": "8.65.0", "@typescript-eslint/utils": "8.65.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA=="],
+
+ "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="],
+
+ "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
+
+ "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
+
+ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
+
+ "vite": ["vite@8.1.5", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", "postcss": "^8.5.17", "rolldown": "~1.1.5", "tinyglobby": "^0.2.17" }, "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" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw=="],
+
+ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
+
+ "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
+
+ "which-builtin-type": ["which-builtin-type@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "function.prototype.name": "^1.1.6", "has-tostringtag": "^1.0.2", "is-async-function": "^2.0.0", "is-date-object": "^1.1.0", "is-finalizationregistry": "^1.1.0", "is-generator-function": "^1.0.10", "is-regex": "^1.2.1", "is-weakref": "^1.0.2", "isarray": "^2.0.5", "which-boxed-primitive": "^1.1.0", "which-collection": "^1.0.2", "which-typed-array": "^1.1.16" } }, "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q=="],
+
+ "which-collection": ["which-collection@1.0.2", "", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="],
+
+ "which-typed-array": ["which-typed-array@1.1.22", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.9", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw=="],
+
+ "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
+
+ "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
+
+ "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
+
+ "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
+
+ "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
+
+ "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
+
+ "@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "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" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
+ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="],
+
+ "@typescript-eslint/typescript-estree/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
+
+ "eslint-plugin-jsx-a11y/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
+
+ "@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
+
+ "@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
+
+ "@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
+
+ "@tailwindcss/node/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
+
+ "@tailwindcss/node/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
+
+ "@tailwindcss/node/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
+
+ "@tailwindcss/node/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
+
+ "@tailwindcss/node/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
+
+ "@tailwindcss/node/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
+
+ "@tailwindcss/node/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
+
+ "@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
+
+ "eslint-plugin-jsx-a11y/minimatch/brace-expansion": ["brace-expansion@1.1.16", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw=="],
+
+ "eslint-plugin-jsx-a11y/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
+ }
+}
diff --git a/website/components.json b/website/components.json
new file mode 100644
index 0000000..ba0c18c
--- /dev/null
+++ b/website/components.json
@@ -0,0 +1,21 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": false,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "src/index.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "iconLibrary": "lucide",
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ }
+}
diff --git a/website/eslint.config.ts b/website/eslint.config.ts
new file mode 100644
index 0000000..6b11a48
--- /dev/null
+++ b/website/eslint.config.ts
@@ -0,0 +1,107 @@
+import js from '@eslint/js'
+import { defineConfig } from 'eslint/config'
+import jsxA11y from 'eslint-plugin-jsx-a11y'
+import reactHooks from 'eslint-plugin-react-hooks'
+import reactRefresh from 'eslint-plugin-react-refresh'
+import globals from 'globals'
+import tseslint from 'typescript-eslint'
+
+export default defineConfig(
+ { ignores: ['dist', 'dist-ssr', 'node_modules'] },
+
+ js.configs.recommended,
+
+ // strictTypeChecked over recommended: it needs type information (below) but
+ // catches the whole class of bugs that only types can see — floating
+ // promises, unsafe `any` flowing through, misused nullish operands.
+ tseslint.configs.strictTypeChecked,
+ tseslint.configs.stylisticTypeChecked,
+
+ // `configs.flat.*`, not `configs['recommended-latest']` — the latter is
+ // still the legacy eslintrc shape (plugins as a string array).
+ reactHooks.configs.flat['recommended-latest'],
+ jsxA11y.flatConfigs.recommended,
+
+ {
+ files: ['**/*.{ts,tsx}'],
+ languageOptions: {
+ ecmaVersion: 2023,
+ globals: globals.browser,
+ parserOptions: {
+ projectService: true,
+ tsconfigRootDir: import.meta.dirname,
+ },
+ },
+ plugins: {
+ 'react-refresh': reactRefresh,
+ },
+ rules: {
+ 'react-refresh/only-export-components': [
+ 'error',
+ { allowConstantExport: true },
+ ],
+
+ // Unused args are usually a mistake, but an `_`-prefixed one is an
+ // explicit "I know, I need the positional slot".
+ '@typescript-eslint/no-unused-vars': [
+ 'error',
+ {
+ argsIgnorePattern: '^_',
+ varsIgnorePattern: '^_',
+ caughtErrorsIgnorePattern: '^_',
+ },
+ ],
+
+ // Type-only imports must be erasable, so the bundler can drop them
+ // without consulting the type graph. Pairs with verbatimModuleSyntax.
+ '@typescript-eslint/consistent-type-imports': [
+ 'error',
+ { prefer: 'type-imports', fixStyle: 'inline-type-imports' },
+ ],
+
+ // A dropped promise in an event handler fails silently in production.
+ '@typescript-eslint/no-floating-promises': 'error',
+ '@typescript-eslint/no-misused-promises': 'error',
+
+ 'no-console': ['error', { allow: ['warn', 'error'] }],
+ eqeqeq: ['error', 'always', { null: 'ignore' }],
+ 'prefer-const': 'error',
+ 'no-var': 'error',
+ 'object-shorthand': 'error',
+ },
+ },
+
+ // The TUI demo is a composite widget: it declares role="application" and
+ // owns its keys (j/k, arrows, 1-3) the way the real terminal client does.
+ // These two rules police exactly the pattern ARIA prescribes for that, so
+ // they are off for this one file rather than worked around in the markup.
+ // Every control inside it is still a native button or input.
+ {
+ files: ['src/components/tui/hero-terminal.tsx'],
+ rules: {
+ 'jsx-a11y/no-noninteractive-tabindex': 'off',
+ 'jsx-a11y/no-noninteractive-element-interactions': 'off',
+ },
+ },
+
+ // Config and build scripts are Node, not browser. Build output is meant to
+ // be read on the terminal, so console is the interface, not a stray debug
+ // statement. entry-server is a build entry point that never enters the HMR
+ // graph, so the component-only export rule does not apply to it.
+ {
+ files: ['*.config.ts', 'scripts/**/*.ts'],
+ languageOptions: {
+ globals: globals.node,
+ },
+ rules: {
+ 'no-console': 'off',
+ '@typescript-eslint/no-unsafe-assignment': 'off',
+ },
+ },
+ {
+ files: ['src/entry-server.tsx'],
+ rules: {
+ 'react-refresh/only-export-components': 'off',
+ },
+ },
+)
diff --git a/website/index.html b/website/index.html
new file mode 100644
index 0000000..b02736b
--- /dev/null
+++ b/website/index.html
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+ soulseek-rs, a Soulseek client for your terminal
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/website/package.json b/website/package.json
new file mode 100644
index 0000000..7bd0f89
--- /dev/null
+++ b/website/package.json
@@ -0,0 +1,41 @@
+{
+ "name": "soulseek-rs-website",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build && bun run build:ssr && bun run prerender",
+ "build:ssr": "vite build --ssr src/entry-server.tsx --outDir dist-ssr",
+ "prerender": "bun scripts/prerender.ts",
+ "preview": "vite preview",
+ "typecheck": "tsc -b",
+ "lint": "eslint .",
+ "lint:fix": "eslint . --fix"
+ },
+ "dependencies": {
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "react": "^19.2.8",
+ "react-dom": "^19.2.8",
+ "react-router": "^8.3.0",
+ "tailwind-merge": "^3.6.0"
+ },
+ "devDependencies": {
+ "@eslint/js": "^10.0.1",
+ "@tailwindcss/vite": "^4.3.3",
+ "@types/node": "^26.1.1",
+ "@types/react": "^19.2.17",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^6.0.4",
+ "eslint": "^10.7.0",
+ "eslint-plugin-jsx-a11y": "^6.10.2",
+ "eslint-plugin-react-hooks": "^7.1.1",
+ "eslint-plugin-react-refresh": "^0.5.3",
+ "globals": "^17.7.0",
+ "tailwindcss": "^4.3.3",
+ "typescript": "~6.0.3",
+ "typescript-eslint": "^8.65.0",
+ "vite": "^8.1.5"
+ }
+}
diff --git a/website/public/favicon.svg b/website/public/favicon.svg
new file mode 100644
index 0000000..2c42539
--- /dev/null
+++ b/website/public/favicon.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/website/public/logo-ink.svg b/website/public/logo-ink.svg
new file mode 100644
index 0000000..620acb1
--- /dev/null
+++ b/website/public/logo-ink.svg
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/website/public/logo-light.svg b/website/public/logo-light.svg
new file mode 100644
index 0000000..ee37762
--- /dev/null
+++ b/website/public/logo-light.svg
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/website/scripts/prerender.ts b/website/scripts/prerender.ts
new file mode 100644
index 0000000..855fbdd
--- /dev/null
+++ b/website/scripts/prerender.ts
@@ -0,0 +1,91 @@
+/*
+ * Turns the SPA build into real static files.
+ *
+ * Without this, GitHub Pages has no file at /install and answers a direct
+ * link with its 404 page. Writing one HTML file per route means every URL
+ * returns 200 with its own title, description, and fully rendered body; the
+ * client then hydrates it into the same interactive app.
+ *
+ * Run after both `vite build` and `vite build --ssr`.
+ */
+import { mkdir, readFile, writeFile } from 'node:fs/promises'
+import { dirname, join } from 'node:path'
+
+import { NOT_FOUND_META, ROUTES, type RouteMeta } from '../src/lib/routes.ts'
+
+/*
+ * Held in a variable so TypeScript does not try to resolve it: the module is
+ * a build artefact, and `bun run typecheck` runs before `vite build --ssr`
+ * has produced it. The cast is the contract with src/entry-server.tsx.
+ */
+const SSR_ENTRY = '../dist-ssr/entry-server.js'
+const { render } = (await import(SSR_ENTRY)) as {
+ render: (location: string) => string
+}
+
+const DIST = join(import.meta.dirname, '..', 'dist')
+
+const SITE_URL = 'https://michel.github.io/soulseek-rs/'
+
+const escapeAttr = (value: string): string =>
+ value.replace(/&/g, '&').replace(/"/g, '"').replace(/` matched by attribute and value. */
+const setMeta = (html: string, attr: string, name: string, value: string): string =>
+ html.replace(
+ new RegExp(`( {
+ const canonical = meta.path === '/' ? SITE_URL : `${SITE_URL}${meta.path.slice(1)}/`
+ let out = html.replace(
+ /[\s\S]*?<\/title>/,
+ `${escapeAttr(meta.title)} `,
+ )
+ out = setMeta(out, 'name', 'description', meta.description)
+ out = setMeta(out, 'property', 'og:title', meta.title)
+ out = setMeta(out, 'property', 'og:description', meta.description)
+ out = setMeta(out, 'property', 'og:url', canonical)
+ return out.replace(
+ /( => {
+ const html = applyMeta(template, meta).replace(
+ '
',
+ `${render(location)}
`,
+ )
+ const target = join(DIST, outPath)
+ await mkdir(dirname(target), { recursive: true })
+ await writeFile(target, html, 'utf8')
+ console.log(` ${outPath}`)
+}
+
+const template = await readFile(join(DIST, 'index.html'), 'utf8')
+
+console.log('prerendering:')
+for (const route of ROUTES) {
+ const outPath = route.path === '/' ? 'index.html' : `${route.path.slice(1)}/index.html`
+ await writePage(template, route, outPath, route.path)
+}
+
+// GitHub Pages serves this whenever no file matches, which after the loop
+// above is only ever a genuinely unknown path.
+await writePage(template, NOT_FOUND_META, '404.html', '/__not_found__')
+
+console.log(`done — ${String(ROUTES.length + 1)} pages`)
diff --git a/website/src/App.tsx b/website/src/App.tsx
new file mode 100644
index 0000000..45627ba
--- /dev/null
+++ b/website/src/App.tsx
@@ -0,0 +1,45 @@
+import type { ReactElement } from 'react'
+import { Route, Routes } from 'react-router'
+
+import { SiteLayout } from '@/components/layout/site-layout'
+import { ROUTES, type RoutePath } from '@/lib/routes'
+import { Community } from '@/pages/community'
+import { Docs } from '@/pages/docs'
+import { Home } from '@/pages/home'
+import { Install } from '@/pages/install'
+import { NotFound } from '@/pages/not-found'
+
+/*
+ * Keyed by RoutePath, so adding an entry to ROUTES without a page here — or a
+ * page here that ROUTES does not know about — fails `bun run typecheck`.
+ *
+ * That drift used to be silent and shipped broken both ways: a prerendered
+ * file containing NotFound markup under the new page's title, or a real page
+ * with no prerendered file and "not found" in its tab.
+ */
+const PAGES: Record = {
+ '/': ,
+ '/install': ,
+ '/docs': ,
+ '/community': ,
+}
+
+/*
+ * Everything is eagerly imported: the whole site is a couple of hundred kB,
+ * so route-splitting would trade a network round-trip on every navigation for
+ * a bundle saving nobody would notice.
+ */
+export const App = () => (
+
+ }>
+ {ROUTES.map((route) =>
+ route.path === '/' ? (
+
+ ) : (
+
+ ),
+ )}
+ } />
+
+
+)
diff --git a/website/src/components/brand/logo.tsx b/website/src/components/brand/logo.tsx
new file mode 100644
index 0000000..8ba6511
--- /dev/null
+++ b/website/src/components/brand/logo.tsx
@@ -0,0 +1,27 @@
+import { cn } from '@/lib/utils'
+
+interface LogoProps {
+ /** Height in px. Width follows the artwork's aspect ratio. */
+ size?: number
+ className?: string
+}
+
+/** Native aspect ratio of the logo artwork (186.407 × 211). */
+const ASPECT = 186.407 / 211
+
+/*
+ * Two assets rather than one recoloured file — the ink and light cuts differ
+ * in more than colour — swapped by CSS.
+ *
+ * Background images, not : a hidden is still downloaded, so the
+ * two-element version fetched both cuts on every visit (~12-23 kB gzip wasted
+ * above the fold). A background on a non-painted rule is never requested.
+ */
+export const Logo = ({ size = 180, className }: LogoProps) => (
+
+)
diff --git a/website/src/components/brand/wordmark.tsx b/website/src/components/brand/wordmark.tsx
new file mode 100644
index 0000000..28cf575
--- /dev/null
+++ b/website/src/components/brand/wordmark.tsx
@@ -0,0 +1,32 @@
+import { cn } from '@/lib/utils'
+
+interface WordmarkProps {
+ /** Font size in px. */
+ size?: number
+ blink?: boolean
+ className?: string
+}
+
+/**
+ * The primary soulseek-rs mark: lowercase mono wordmark plus a solid block
+ * cursor. Live text rather than an asset, so it blinks for free and stays
+ * crisp at any size.
+ */
+export const Wordmark = ({ size = 28, blink = false, className }: WordmarkProps) => (
+
+ soulseek-rs
+
+
+)
diff --git a/website/src/components/layout/footer.tsx b/website/src/components/layout/footer.tsx
new file mode 100644
index 0000000..c381315
--- /dev/null
+++ b/website/src/components/layout/footer.tsx
@@ -0,0 +1,98 @@
+import { Link } from 'react-router'
+
+import { Wordmark } from '@/components/brand/wordmark'
+import { Wrap } from '@/components/ui/layout'
+import { LINKS, VERSION } from '@/lib/links'
+
+interface FooterLink {
+ label: string
+ href: string
+}
+
+interface FooterColumn {
+ heading: string
+ links: readonly FooterLink[]
+}
+
+const COLUMNS: readonly FooterColumn[] = [
+ {
+ heading: 'Project',
+ links: [
+ { label: 'GitHub', href: LINKS.gh },
+ { label: 'Issues', href: LINKS.issues },
+ { label: 'Releases', href: LINKS.releases },
+ { label: 'Changelog', href: LINKS.changelog },
+ { label: 'soulfind server', href: LINKS.soulfind },
+ ],
+ },
+ {
+ heading: 'Packages',
+ links: [
+ { label: 'crates.io · client', href: LINKS.cratesClient },
+ { label: 'crates.io · lib', href: LINKS.cratesLib },
+ { label: 'docs.rs', href: LINKS.docsrs },
+ { label: 'rustup', href: LINKS.rustup },
+ ],
+ },
+ {
+ heading: 'Read',
+ links: [
+ { label: 'Docs', href: '/docs' },
+ { label: 'Install', href: '/install' },
+ { label: 'Community', href: '/community' },
+ { label: 'License · MIT', href: LINKS.license },
+ ],
+ },
+]
+
+const LINK_CLASS = 'text-[13px] text-secondary hover:text-primary'
+
+const FooterAnchor = ({ label, href }: FooterLink) =>
+ href.startsWith('/') ? (
+
+ {label}
+
+ ) : (
+
+ {label}
+
+ )
+
+export const Footer = () => (
+
+)
diff --git a/website/src/components/layout/nav.tsx b/website/src/components/layout/nav.tsx
new file mode 100644
index 0000000..8809ca7
--- /dev/null
+++ b/website/src/components/layout/nav.tsx
@@ -0,0 +1,63 @@
+import { NavLink } from 'react-router'
+
+import { Wordmark } from '@/components/brand/wordmark'
+import { ThemeToggle } from '@/components/layout/theme-toggle'
+import { LINKS } from '@/lib/links'
+import { cn } from '@/lib/utils'
+
+const ITEMS = [
+ { label: 'home', to: '/' },
+ { label: 'install', to: '/install' },
+ { label: 'docs', to: '/docs' },
+ { label: 'community', to: '/community' },
+] as const
+
+/*
+ * No hamburger. Below 820px the links become their own horizontally
+ * scrollable row, which keeps every destination one tap away instead of
+ * two — the site has four pages, a drawer would be ceremony.
+ */
+export const Nav = () => (
+
+
+
+
+
+
+
+ {ITEMS.map((item) => (
+
+ cn(
+ 'relative inline-flex shrink-0 items-center rounded-md px-3.5 py-1.5 text-label uppercase tracking-[var(--tracking-label)] max-sm:min-h-11 sm:px-3',
+ isActive
+ ? "bg-raised text-primary before:text-accent before:content-['›_'] after:absolute after:inset-x-3 after:bottom-px after:h-0.5 after:bg-accent"
+ : 'text-secondary hover:bg-[var(--bg-selection)] hover:text-primary',
+ )
+ }
+ >
+ {item.label}
+
+ ))}
+
+
+
+
+
+)
diff --git a/website/src/components/layout/site-layout.tsx b/website/src/components/layout/site-layout.tsx
new file mode 100644
index 0000000..8bbd491
--- /dev/null
+++ b/website/src/components/layout/site-layout.tsx
@@ -0,0 +1,51 @@
+import { useEffect } from 'react'
+import { Outlet, useLocation } from 'react-router'
+
+import { Footer } from '@/components/layout/footer'
+import { Nav } from '@/components/layout/nav'
+import { usePageMeta } from '@/hooks/use-page-meta'
+
+/**
+ * On navigation: jump to the anchor if the URL carries one, otherwise back to
+ * the top, and move keyboard focus into the main landmark.
+ *
+ * That last part matters — without it focus stays on the link that was
+ * clicked, so a screen reader never announces that the page changed.
+ *
+ * replaces react-router's , which only works
+ * under a data router. Restoring exact scroll offsets on back/forward would
+ * mean adopting createBrowserRouter for four static pages.
+ */
+const useNavigationEffects = () => {
+ const { pathname, hash } = useLocation()
+
+ useEffect(() => {
+ if (hash !== '') {
+ document.querySelector(hash)?.scrollIntoView()
+ } else {
+ window.scrollTo(0, 0)
+ }
+ document.getElementById('main')?.focus({ preventScroll: true })
+ }, [pathname, hash])
+}
+
+export const SiteLayout = () => {
+ useNavigationEffects()
+ usePageMeta()
+
+ return (
+ <>
+
+ Skip to content
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/website/src/components/layout/theme-toggle.tsx b/website/src/components/layout/theme-toggle.tsx
new file mode 100644
index 0000000..53d6d16
--- /dev/null
+++ b/website/src/components/layout/theme-toggle.tsx
@@ -0,0 +1,42 @@
+import { useTheme } from '@/theme/use-theme'
+import type { Theme } from '@/theme/theme-context'
+
+/*
+ * Cycles system → light → dark → system rather than a plain two-way flip, so
+ * "follow the OS" stays reachable after the first click. The glyph shows what
+ * is painted; the label says which mode is selected.
+ */
+const NEXT: Record = {
+ system: 'light',
+ light: 'dark',
+ dark: 'system',
+}
+
+const LABEL: Record = {
+ system: 'system',
+ light: 'light',
+ dark: 'dark',
+}
+
+export const ThemeToggle = () => {
+ const { theme, resolvedTheme, setTheme } = useTheme()
+
+ return (
+ {
+ setTheme(NEXT[theme])
+ }}
+ aria-label={`Theme: ${LABEL[theme]}. Switch to ${LABEL[NEXT[theme]]}.`}
+ title={`Theme: ${LABEL[theme]}`}
+ className="inline-flex h-[38px] items-center justify-center gap-1.5 rounded-md border border-hairline bg-transparent px-2.5 font-mono text-xs text-secondary hover:border-line hover:bg-[var(--bg-selection)] hover:text-primary sm:h-8"
+ >
+ {resolvedTheme === 'light' ? '☀' : '☾'}
+ {theme === 'system' && (
+
+ auto
+
+ )}
+
+ )
+}
diff --git a/website/src/components/tui/chrome.tsx b/website/src/components/tui/chrome.tsx
new file mode 100644
index 0000000..62d54cb
--- /dev/null
+++ b/website/src/components/tui/chrome.tsx
@@ -0,0 +1,135 @@
+import { VERSION } from '@/lib/links'
+import { cn } from '@/lib/utils'
+
+import { CLOCK, DATE, HOST, SESSION, SHORTCUTS, WINDOWS } from './data'
+
+interface TuiPaneProps {
+ title: string
+ num?: number
+ active?: boolean
+ children: React.ReactNode
+ className?: string
+ bodyClassName?: string
+}
+
+/**
+ * The TUI's bordered pane: a legend that breaks the top border, green when
+ * the pane has focus. Mirrors `pane_block()` in the Rust client.
+ */
+export const TuiPane = ({
+ title,
+ num,
+ active = false,
+ children,
+ className,
+ bodyClassName,
+}: TuiPaneProps) => (
+
+
+ {num !== undefined && [{num}] }
+ {title}
+
+
{children}
+
+)
+
+/** The macOS window bezel the whole demo sits in. */
+export const MacWindow = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+)
+
+export const TmuxBar = () => (
+
+
+ {SESSION}
+
+ {WINDOWS.map((w) => (
+
+ {w.n} › {w.name}
+
+ ))}
+
+
+ {DATE} · {CLOCK}
+
+ {HOST}
+
+
+)
+
+export interface StatusCounts {
+ active: number
+ done: number
+ fail: number
+ queued: number
+}
+
+interface StatusLineProps {
+ counts: StatusCounts
+ pct: number
+}
+
+export const StatusLine = ({ counts, pct }: StatusLineProps) => (
+
+
+
+ soulseek-rs 🦀 {VERSION}
+
+
+ Downloads: {counts.active} active
+ , {counts.done} completed ,{' '}
+ {counts.fail} failed ,{' '}
+ {counts.queued} queued , 0 paused
+
+
+ 67.7/205.2 MB · 33.38 MB/s
+
+
+
+ {pct}%
+
+
+
+)
+
+export const ShortcutBar = ({ share }: { share: string }) => (
+
+
+ Sharing: {share}
+
+
+ {SHORTCUTS.map(([key, action]) => (
+
+ [{key} → {action}]
+
+ ))}
+
+
+)
diff --git a/website/src/components/tui/data.ts b/website/src/components/tui/data.ts
new file mode 100644
index 0000000..c62813c
--- /dev/null
+++ b/website/src/components/tui/data.ts
@@ -0,0 +1,216 @@
+/*
+ * Sample data for the TUI recreation in the hero.
+ *
+ * Everything here is invented, public-domain, or Creative Commons. No real
+ * artist, album, or track names — see TONE-OF-VOICE.md §5 "Banned for legal
+ * reasons". Anything added here has to keep that true.
+ */
+
+export interface SearchRow {
+ id: string
+ query: string
+ results: number | string
+ status: string
+}
+
+export interface ResultRow {
+ name: string
+ size: string
+ user: string
+ bitrate: string
+ speed: string
+ slots: number
+ /** Marked for download with Space, not yet queued. */
+ marked?: boolean
+ /** Handed to the transfer list. */
+ queued?: boolean
+}
+
+export type TransferStatus = 'queued' | 'active' | 'done' | 'fail'
+
+export interface TransferRow {
+ id: string
+ status: TransferStatus
+ name: string
+ user: string
+ progress: string
+ speed: string
+ /** Present only on transfers the demo drives; the static rows leave it out. */
+ pct?: number
+ sizeMb?: number
+ rateMbps?: number
+}
+
+interface TmuxWindow {
+ n: number
+ name: string
+ active?: boolean
+}
+
+export const HOST = 'michels-macbook-pro-m4'
+export const CLOCK = '01:45 PM'
+export const DATE = '2026-07-24'
+export const SESSION = 'develop'
+export const SHARE = '/Users/micheldegraaf/Downloads/Soulseek'
+
+export const WINDOWS: readonly TmuxWindow[] = [
+ { n: 1, name: 'fish #' },
+ { n: 2, name: 'fish #' },
+ { n: 3, name: 'fish #' },
+ { n: 4, name: 'fish #-' },
+ { n: 5, name: 'fish *', active: true },
+]
+
+export const SEARCHES: readonly SearchRow[] = [
+ { id: 's1', query: 'public domain field recordings', results: 606, status: 'Done' },
+ { id: 's2', query: 'creative commons ambient', results: 33, status: 'Done' },
+ { id: 's3', query: 'netlabel 2004', results: 608, status: 'Done' },
+]
+
+const pd = (name: string, size: string, bitrate: string): ResultRow => ({
+ name: `shared\\archive\\PD Field Recordings\\${name}`,
+ size,
+ user: 'loopdigger',
+ bitrate,
+ speed: '3.35 MB/s',
+ slots: 1,
+})
+
+export const RESULTS: Readonly> = {
+ s1: [
+ pd('01 - Coastal Dawn (Sunrise Take).flac', '30.5 MB', '675 kbps'),
+ pd('02 - Reservoir Wind.flac', '22.6 MB', '569 kbps'),
+ pd('cover.jpg', '0.0 MB', '-'),
+ pd('03 - Marsh at Night (Long).flac', '48.0 MB', '920 kbps'),
+ pd('04 - Tram Depot, 06h.flac', '32.2 MB', '725 kbps'),
+ pd('05 - Harbour Bells.flac', '52.5 MB', '902 kbps'),
+ pd('06 - Rain on Zinc Roof.flac', '30.4 MB', '804 kbps'),
+ pd('07 - Empty Chapel Tone.flac', '17.4 MB', '836 kbps'),
+ pd('08 - Forest Floor.flac', '38.0 MB', '741 kbps'),
+ pd('09 - Quarry Echo.flac', '34.6 MB', '753 kbps'),
+ pd('10 - Night Ferry.flac', '18.7 MB', '723 kbps'),
+ pd('11 - Last Train.flac', '19.5 MB', '621 kbps'),
+ {
+ name: 'shared\\netlabel\\Explorers of the Interior [CC-BY]\\readme.txt',
+ size: '1.0 MB',
+ user: 'loopdigger',
+ bitrate: '128 kbps',
+ speed: '3.35 MB/s',
+ slots: 1,
+ },
+ ],
+ s2: [
+ {
+ name: 'shared\\cc\\Ambient Works [CC0]\\01 - Slow Meadow.flac',
+ size: '41.2 MB',
+ user: 'tapehiss',
+ bitrate: '911 kbps',
+ speed: '2.10 MB/s',
+ slots: 2,
+ },
+ {
+ name: 'shared\\cc\\Ambient Works [CC0]\\02 - Held Note.flac',
+ size: '28.9 MB',
+ user: 'tapehiss',
+ bitrate: '740 kbps',
+ speed: '2.10 MB/s',
+ slots: 2,
+ },
+ {
+ name: 'shared\\cc\\Ambient Works [CC0]\\03 - Grain.flac',
+ size: '33.1 MB',
+ user: 'tapehiss',
+ bitrate: '812 kbps',
+ speed: '2.10 MB/s',
+ slots: 2,
+ },
+ ],
+ s3: [
+ {
+ name: 'shared\\netlabel\\2004 Comp [CC-BY-NC]\\01 - Opener.mp3',
+ size: '9.4 MB',
+ user: 'nectoUA',
+ bitrate: '320 kbps',
+ speed: '1.05 MB/s',
+ slots: 0,
+ },
+ {
+ name: 'shared\\netlabel\\2004 Comp [CC-BY-NC]\\02 - Interlude.mp3',
+ size: '5.1 MB',
+ user: 'nectoUA',
+ bitrate: '320 kbps',
+ speed: '1.05 MB/s',
+ slots: 0,
+ },
+ ],
+}
+
+export const TRANSFERS: readonly TransferRow[] = [
+ {
+ id: 'x1',
+ status: 'done',
+ name: '@@lrfkq\\Contents\\Rotterdam',
+ user: 'Dan Dillion',
+ progress: 'Completed',
+ speed: '-',
+ },
+ {
+ id: 'x2',
+ status: 'done',
+ name: '@@lrfkq\\Contents\\Journal',
+ user: 'Dan Dillion',
+ progress: 'Completed',
+ speed: '-',
+ },
+ {
+ id: 'x3',
+ status: 'active',
+ name: '@@lrfkq\\Contents\\Context',
+ user: 'Dan Dillion',
+ progress: '37.5 MB/53.4 MB (70%)',
+ speed: '12.4 MB/s',
+ },
+ {
+ id: 'x4',
+ status: 'fail',
+ name: '@@gufoo\\put all musics',
+ user: 'publicdomainbot',
+ progress: 'Failed',
+ speed: '-',
+ },
+ {
+ id: 'x5',
+ status: 'active',
+ name: '02-RawMusic\\Embark Studio',
+ user: 'tapehiss',
+ progress: '10.4 MB/14.3 MB (72%)',
+ speed: '11.5 MB/s',
+ },
+ {
+ id: 'x6',
+ status: 'fail',
+ name: '@@medvw\\Soulseek\\complete',
+ user: 'x100',
+ progress: 'Failed',
+ speed: '-',
+ },
+ {
+ id: 'x7',
+ status: 'active',
+ name: '@@hnttf\\complete\\(2004 netlabel)',
+ user: 'nectoUA',
+ progress: '10.5 MB/25.5 MB (41%)',
+ speed: '9.3 MB/s',
+ },
+]
+
+export const SHORTCUTS: readonly (readonly [string, string])[] = [
+ ['Space', 'select'],
+ ['Enter', 'download'],
+ ['b', 'browse owner'],
+ ['c', 'chat'],
+ ['/', 'filter'],
+ ['a/A', 'select all/none'],
+ ['1-3', 'focus pane'],
+ ['q', 'quit'],
+]
diff --git a/website/src/components/tui/hero-terminal.tsx b/website/src/components/tui/hero-terminal.tsx
new file mode 100644
index 0000000..bb588af
--- /dev/null
+++ b/website/src/components/tui/hero-terminal.tsx
@@ -0,0 +1,482 @@
+import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
+
+import { MacWindow, ShortcutBar, StatusLine, TmuxBar, type StatusCounts } from './chrome'
+import {
+ RESULTS,
+ SEARCHES,
+ SHARE,
+ TRANSFERS,
+ type ResultRow,
+ type SearchRow,
+ type TransferRow,
+} from './data'
+import { InfoPane, ResultsPane, SearchesPane, TransfersPane } from './panes'
+import { useFitScale } from './use-fit-scale'
+
+const DESIGN_WIDTH = 1360
+const TICK_MS = 480
+const MAX_CONCURRENT = 2
+/** Where the scripted run leaves the cursor. */
+const RESTING_ROW = 5
+
+type Phase = 'searching' | 'results' | 'interactive'
+
+/** Pages are prerendered in Node, where there is no window. */
+const isBrowser = typeof window !== 'undefined'
+
+const matches = (query: string): boolean =>
+ isBrowser && window.matchMedia(query).matches
+
+const prefersReducedMotion = (): boolean => matches('(prefers-reduced-motion: reduce)')
+
+const sizeMb = (size: string): number => {
+ const parsed = Number.parseFloat(size)
+ return Number.isNaN(parsed) || parsed === 0 ? 12 : parsed
+}
+
+/*
+ * Deterministic on purpose: the id is derived from the filename and the rate
+ * from its length, so queueing the same row twice is a no-op and nothing
+ * random runs inside a state updater. Both matter under StrictMode, which
+ * double-invokes updaters in development.
+ */
+const toTransfer = (row: ResultRow): TransferRow => ({
+ id: `t:${row.name}`,
+ status: 'queued',
+ name: row.name,
+ user: row.user,
+ progress: 'Queued',
+ speed: '-',
+ pct: 0,
+ sizeMb: sizeMb(row.size),
+ rateMbps: 1.4 + (row.name.length % 7) * 0.4,
+})
+
+/**
+ * The hero: a working recreation of the TUI.
+ *
+ * It plays one scripted run (search → results → queue two files) and then
+ * hands over to the keyboard. Only transfers carrying a `pct` are simulated;
+ * the seeded rows stay put so the pane looks lived-in from the first frame.
+ *
+ * Under `prefers-reduced-motion` it starts at the end state instead, so the
+ * page never animates at someone who asked it not to.
+ */
+export const HeroTerminal = () => {
+ const [searches, setSearches] = useState(SEARCHES)
+ const [results, setResults] =
+ useState>>(RESULTS)
+ const [activeId, setActiveId] = useState('s1')
+ const [transfers, setTransfers] = useState(TRANSFERS)
+ const [focus, setFocus] = useState(2)
+
+ // Reduced motion skips the performance, so the resting state is the initial
+ // state. Resolved once, in the initialiser, to keep it out of an effect.
+ const [phase, setPhase] = useState(() =>
+ prefersReducedMotion() ? 'interactive' : 'searching',
+ )
+ const [selected, setSelected] = useState(() =>
+ prefersReducedMotion() ? RESTING_ROW : 0,
+ )
+
+ const shellRef = useRef(null)
+
+ // Mirrors `results` so queueFrom can read the latest rows without taking a
+ // dependency on them. Synced after commit, which is soon enough: every
+ // reader is a timer or an event handler, never a render.
+ const resultsRef = useRef(results)
+ useEffect(() => {
+ resultsRef.current = results
+ }, [results])
+
+ const rows = useMemo(() => results[activeId] ?? [], [results, activeId])
+ const activeSearch = searches.find((search) => search.id === activeId)
+
+ const counts = useMemo(() => {
+ const tally: StatusCounts = { active: 0, done: 0, fail: 0, queued: 0 }
+ for (const transfer of transfers) tally[transfer.status] += 1
+ return tally
+ }, [transfers])
+
+ // Derived, not stored: the status bar is a pure function of what is moving.
+ const pct = useMemo(() => {
+ const live = transfers.filter(
+ (t) => t.pct !== undefined && (t.status === 'active' || t.status === 'done'),
+ )
+ if (live.length === 0) return 0
+ return Math.round(live.reduce((sum, t) => sum + (t.pct ?? 0), 0) / live.length)
+ }, [transfers])
+
+ /** True while any simulated transfer still has somewhere to go. */
+ const hasWork = useMemo(
+ () => transfers.some((t) => t.pct !== undefined && t.status !== 'done'),
+ [transfers],
+ )
+
+ // Stops once everything has finished. Without the `hasWork` gate the timer
+ // would tick twice a second for as long as the tab is open, re-rendering
+ // the whole TUI to produce identical output.
+ useEffect(() => {
+ if (!hasWork || prefersReducedMotion()) return
+
+ const timer = setInterval(() => {
+ setTransfers((list) => {
+ let live = list.filter((t) => t.status === 'active' && t.pct !== undefined).length
+
+ const next = list.map((t) => {
+ if (t.pct === undefined) return t
+ const total = t.sizeMb ?? 12
+ const rate = t.rateMbps ?? 2
+
+ if (t.status === 'queued' && live < MAX_CONCURRENT) {
+ live += 1
+ return {
+ ...t,
+ status: 'active' as const,
+ speed: `${rate.toFixed(2)} MB/s`,
+ progress: `0.0/${total.toFixed(1)} MB (0%)`,
+ }
+ }
+ if (t.status !== 'active') return t
+
+ // Step size varies per file but is a pure function of it, so the
+ // updater stays free of side effects under StrictMode's double call.
+ const step = 4 + ((t.name.length + Math.round(t.pct)) % 9)
+ const pct = Math.min(100, t.pct + step)
+ const finished = pct >= 100
+ return {
+ ...t,
+ status: finished ? ('done' as const) : ('active' as const),
+ pct,
+ speed: finished ? '-' : `${rate.toFixed(2)} MB/s`,
+ progress: finished
+ ? 'Complete'
+ : `${((total * pct) / 100).toFixed(1)}/${total.toFixed(1)} MB (${String(Math.round(pct))}%)`,
+ }
+ })
+
+ // map() always allocates, so without this every tick would hand React
+ // a fresh array and force a re-render that produces identical output.
+ // Returning the original lets React bail out instead.
+ const moved = next.some((t, i) => t !== list[i])
+ return moved ? next : list
+ })
+ }, TICK_MS)
+
+ return () => {
+ clearInterval(timer)
+ }
+ }, [hasWork])
+
+ /*
+ * Queue rows by index within a named search.
+ *
+ * Reads the current rows through a ref rather than closing over `results`,
+ * so the callback identity never changes — the opening script can then
+ * depend on it without restarting itself every time state moves.
+ */
+ const queueFrom = useCallback((searchId: string, indexes: readonly number[]) => {
+ if (indexes.length === 0) return
+
+ const current = resultsRef.current[searchId] ?? []
+ const picked = indexes
+ .map((i) => current[i])
+ .filter((row): row is ResultRow => row !== undefined && row.queued !== true)
+ if (picked.length === 0) return
+
+ const wanted = new Set(picked.map((row) => row.name))
+ setResults((prev) => ({
+ ...prev,
+ [searchId]: (prev[searchId] ?? []).map((row) =>
+ wanted.has(row.name) ? { ...row, queued: true, marked: false } : row,
+ ),
+ }))
+ setTransfers((list) => {
+ const seen = new Set(list.map((transfer) => transfer.id))
+ const additions = picked.map(toTransfer).filter((t) => !seen.has(t.id))
+ return additions.length === 0 ? list : [...additions, ...list]
+ })
+ }, [])
+
+ const queueIndexes = useCallback(
+ (indexes: readonly number[]) => {
+ queueFrom(activeId, indexes)
+ },
+ [queueFrom, activeId],
+ )
+
+ const toggleMark = useCallback(
+ (index: number) => {
+ setResults((prev) => ({
+ ...prev,
+ [activeId]: (prev[activeId] ?? []).map((row, i) =>
+ i === index && row.queued !== true ? { ...row, marked: row.marked !== true } : row,
+ ),
+ }))
+ },
+ [activeId],
+ )
+
+ const markAll = useCallback(
+ (on: boolean) => {
+ setResults((prev) => ({
+ ...prev,
+ [activeId]: (prev[activeId] ?? []).map((row) =>
+ row.queued === true ? row : { ...row, marked: on },
+ ),
+ }))
+ },
+ [activeId],
+ )
+
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.target instanceof HTMLInputElement) return
+ const list = results[activeId] ?? []
+
+ switch (event.key) {
+ case 'ArrowDown':
+ case 'j':
+ event.preventDefault()
+ setFocus(2)
+ setSelected((s) => Math.min(list.length - 1, s + 1))
+ return
+ case 'ArrowUp':
+ case 'k':
+ event.preventDefault()
+ setFocus(2)
+ setSelected((s) => Math.max(0, s - 1))
+ return
+ case ' ':
+ event.preventDefault()
+ setFocus(2)
+ toggleMark(selected)
+ return
+ case 'Enter': {
+ event.preventDefault()
+ const marked = list.flatMap((row, i) =>
+ row.marked === true && row.queued !== true ? [i] : [],
+ )
+ queueIndexes(marked.length > 0 ? marked : [selected])
+ setFocus(3)
+ return
+ }
+ case 'a':
+ event.preventDefault()
+ markAll(true)
+ return
+ case 'A':
+ event.preventDefault()
+ markAll(false)
+ return
+ case '1':
+ case '2':
+ case '3':
+ event.preventDefault()
+ setFocus(Number(event.key))
+ return
+ default:
+ return
+ }
+ }
+
+ /*
+ * The scripted opening run.
+ *
+ * No "has this already started" ref: the cleanup cancels every timer, so a
+ * StrictMode remount simply replays from the top. A ref guard would let the
+ * first cleanup cancel the run and the re-run skip scheduling it, leaving
+ * the demo frozen on its first frame in development.
+ *
+ * It targets 's1' directly rather than whatever is selected, because the
+ * script only ever describes the opening state.
+ */
+ useEffect(() => {
+ if (prefersReducedMotion()) return
+
+ const timers: ReturnType[] = []
+ const at = (fn: () => void, ms: number) => timers.push(setTimeout(fn, ms))
+ const mark = (i: number) => {
+ setResults((prev) => ({
+ ...prev,
+ s1: (prev['s1'] ?? []).map((row, ix) => (ix === i ? { ...row, marked: true } : row)),
+ }))
+ }
+
+ at(() => {
+ setPhase('results')
+ }, 1050)
+ at(() => {
+ setSelected(3)
+ }, 1550)
+ at(() => {
+ setSelected(7)
+ }, 2050)
+ at(() => {
+ setSelected(10)
+ }, 2550)
+ at(() => {
+ mark(10)
+ }, 2850)
+ at(() => {
+ queueFrom('s1', [10])
+ }, 3050)
+ at(() => {
+ setSelected(3)
+ }, 3950)
+ at(() => {
+ mark(3)
+ }, 4150)
+ at(() => {
+ queueFrom('s1', [3])
+ }, 4350)
+ at(() => {
+ setSelected(RESTING_ROW)
+ setPhase('interactive')
+ }, 5050)
+
+ return () => {
+ for (const timer of timers) clearTimeout(timer)
+ }
+ }, [queueFrom])
+
+ const submitSearch = (query: string) => {
+ const id = `s${String(searches.length + 1)}`
+ setSearches((prev) => [
+ ...prev,
+ { id, query, results: 40 + Math.floor(Math.random() * 900), status: 'Done' },
+ ])
+ setResults((prev) => ({
+ ...prev,
+ [id]: Array.from({ length: 6 }, (_, i) => ({
+ name: `shared\\results\\${query.replace(/[^a-z0-9]+/gi, '_')}\\0${String(i + 1)} - track.flac`,
+ size: `${(10 + i * 7).toFixed(1)} MB`,
+ user: ['loopdigger', 'tapehiss', 'nectoUA'][i % 3] ?? 'loopdigger',
+ bitrate: `${String(500 + i * 60)} kbps`,
+ speed: '2.20 MB/s',
+ slots: i % 2,
+ })),
+ }))
+ setActiveId(id)
+ setSelected(0)
+ setFocus(2)
+ setPhase('interactive')
+ }
+
+ // Below 820px, stop shrinking at half size and let the wrapper scroll.
+ const [minScale, setMinScale] = useState(() =>
+ matches('(max-width: 820px)') ? 0.5 : 0,
+ )
+ useEffect(() => {
+ const query = window.matchMedia('(max-width: 820px)')
+ const sync = (event: MediaQueryListEvent) => {
+ setMinScale(event.matches ? 0.5 : 0)
+ }
+ query.addEventListener('change', sync)
+ return () => {
+ query.removeEventListener('change', sync)
+ }
+ }, [])
+
+ const { wrapRef, innerRef, scale, height, ready } = useFitScale(DESIGN_WIDTH, minScale)
+
+ const shownSearches = searches.map((search, i) =>
+ i === 0 && phase === 'searching' ? { ...search, status: '⋯', results: '—' } : search,
+ )
+ const shownRows = phase === 'searching' ? [] : rows
+
+ return (
+
+
+
+
+
+ {/*
+ role="application" is the ARIA escape hatch for a widget that
+ handles its own keys: it stops screen readers intercepting
+ j/k/arrows so the demo behaves like the real TUI. Every control
+ inside is still a native button or input.
+ */}
+
+
+
+
+
+ {
+ setActiveId(id)
+ setSelected(0)
+ setFocus(1)
+ setPhase('interactive')
+ }}
+ onSubmit={submitSearch}
+ />
+
+
+
+
+
{
+ setSelected(i)
+ setFocus(2)
+ }}
+ onQueue={(i) => {
+ queueIndexes([i])
+ setFocus(3)
+ }}
+ />
+ {phase === 'searching' && (
+
+ searching the network
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/website/src/components/tui/panes.tsx b/website/src/components/tui/panes.tsx
new file mode 100644
index 0000000..54a7b43
--- /dev/null
+++ b/website/src/components/tui/panes.tsx
@@ -0,0 +1,286 @@
+import { useState, type SyntheticEvent } from 'react'
+
+import { cn } from '@/lib/utils'
+
+import { TuiPane } from './chrome'
+import type { ResultRow, SearchRow, TransferRow, TransferStatus } from './data'
+
+const RESULT_COLS = '30px 1fr 74px 92px 78px 74px 42px'
+const TRANSFER_COLS = '48px 1fr 96px 150px 74px'
+
+interface SearchesPaneProps {
+ searches: readonly SearchRow[]
+ activeId: string
+ active: boolean
+ onPick: (id: string) => void
+ onSubmit: (query: string) => void
+}
+
+export const SearchesPane = ({
+ searches,
+ activeId,
+ active,
+ onPick,
+ onSubmit,
+}: SearchesPaneProps) => {
+ const [query, setQuery] = useState('')
+
+ const submit = (event: SyntheticEvent) => {
+ event.preventDefault()
+ const trimmed = query.trim()
+ if (trimmed === '') return
+ onSubmit(trimmed)
+ setQuery('')
+ }
+
+ return (
+
+
+
+
+ Status
+ Query
+ Results
+
+
+ {searches.map((search) => (
+ {
+ onPick(search.id)
+ }}
+ className={cn(
+ 'grid w-full cursor-pointer p-1 text-left text-[13px] text-[var(--color-paper)]',
+ search.id === activeId && 'bg-[color-mix(in_srgb,var(--color-signal)_14%,transparent)]',
+ )}
+ style={{ gridTemplateColumns: '56px 1fr 58px' }}
+ >
+
+ {search.id === activeId ? '→' : ' '}
+ {search.status}
+
+
+ {search.query}
+
+ {search.results}
+
+ ))}
+
+ )
+}
+
+const resultGlyph = (row: ResultRow): { glyph: string; tone: string } => {
+ if (row.queued === true) return { glyph: '✓', tone: 'text-[var(--color-phosphor)]' }
+ if (row.marked === true) return { glyph: '[✓]', tone: 'text-[var(--color-oxide)]' }
+ return { glyph: '[ ]', tone: 'text-[var(--color-dust)]' }
+}
+
+interface ResultsPaneProps {
+ query: string
+ rows: readonly ResultRow[]
+ selected: number
+ active: boolean
+ onSelect: (index: number) => void
+ onQueue: (index: number) => void
+}
+
+export const ResultsPane = ({
+ query,
+ rows,
+ selected,
+ active,
+ onSelect,
+ onQueue,
+}: ResultsPaneProps) => (
+
+
+ ✓
+ Filename
+ Size
+ User
+ Bitrate
+ Speed
+ Slots
+
+
+ {rows.map((row, i) => {
+ const { glyph, tone } = resultGlyph(row)
+ const isSelected = selected === i
+ return (
+ {
+ onSelect(i)
+ }}
+ onDoubleClick={() => {
+ onQueue(i)
+ }}
+ className={cn(
+ 'grid w-full cursor-pointer gap-2 px-3.5 py-[3px] text-left text-[12.5px] text-[var(--color-paper)]',
+ isSelected && 'bg-[color-mix(in_srgb,var(--color-oxide)_16%,transparent)]',
+ )}
+ style={{ gridTemplateColumns: RESULT_COLS }}
+ >
+ {glyph}
+
+ {row.name}
+
+ {row.size}
+ {row.user}
+ {row.bitrate}
+ {row.speed}
+ {row.slots}
+
+ )
+ })}
+
+
+)
+
+const TRANSFER_GLYPH: Record = {
+ queued: ['⋯', 'text-[var(--color-tape)]'],
+ done: ['✓', 'text-[var(--color-phosphor)]'],
+ active: ['↯', 'text-[var(--color-oxide)]'],
+ fail: ['✗', 'text-[var(--color-alarm)]'],
+}
+
+const PROGRESS_TONE: Record = {
+ queued: 'text-[var(--color-paper)]',
+ active: 'text-[var(--color-paper)]',
+ done: 'text-[var(--color-phosphor)]',
+ fail: 'text-[var(--color-alarm)]',
+}
+
+interface TransfersPaneProps {
+ rows: readonly TransferRow[]
+ active: boolean
+}
+
+export const TransfersPane = ({ rows, active }: TransfersPaneProps) => (
+
+
+ Status
+ Filename
+ User
+ Progress
+ Speed
+
+
+ {rows.map((row) => {
+ const [glyph, tone] = TRANSFER_GLYPH[row.status]
+ return (
+
+ {glyph}
+
+ {row.name}
+
+ {row.user}
+ {row.progress}
+ {row.speed}
+
+ )
+ })}
+
+
+)
+
+interface InfoPaneProps {
+ row: ResultRow | undefined
+}
+
+export const InfoPane = ({ row }: InfoPaneProps) => {
+ const parts = (row?.name ?? '').split('\\')
+ const file = parts.at(-1) ?? '—'
+ const path = parts.slice(0, -1).join('/')
+ const queued = row?.queued === true
+
+ const fields: readonly (readonly [string, string, string])[] = [
+ ['User', row?.user ?? '—', 'text-[var(--color-signal)]'],
+ ['Size', row?.size ?? '—', 'text-[var(--color-tape)]'],
+ [
+ 'Status',
+ queued ? 'Queued' : 'Available',
+ queued ? 'text-[var(--color-tape)]' : 'text-[var(--color-phosphor)]',
+ ],
+ ['Bitrate', row?.bitrate ?? '-', 'text-[var(--color-paper)]'],
+ ]
+
+ return (
+
+ {file}
+
+ {path === '' ? '—' : path}
+
+
+ {fields.map(([key, value, tone]) => (
+
+ {key}
+ {value}
+
+ ))}
+
Queue position
+
{queued ? '3' : 'unknown'}
+
Free slots
+
+ {row !== undefined && row.slots > 0 ? `${String(row.slots)} available` : '0'}
+
+
+
+ )
+}
diff --git a/website/src/components/tui/use-fit-scale.ts b/website/src/components/tui/use-fit-scale.ts
new file mode 100644
index 0000000..3aa5c5f
--- /dev/null
+++ b/website/src/components/tui/use-fit-scale.ts
@@ -0,0 +1,64 @@
+import { useEffect, useRef, useState, type RefObject } from 'react'
+
+interface FitScale {
+ wrapRef: RefObject
+ innerRef: RefObject
+ scale: number
+ height: number
+ ready: boolean
+}
+
+/**
+ * Scales a fixed-width design down to whatever width it is given.
+ *
+ * The TUI is laid out on a 1360px character grid; reflowing it would stop it
+ * looking like a terminal. So it renders at native width and gets a CSS
+ * transform, and the wrapper is told the resulting height so the page below
+ * does not sit under it.
+ *
+ * `minScale` stops the shrink at a floor on small screens — past that the
+ * wrapper scrolls sideways instead, which beats unreadable 4px text.
+ */
+export const useFitScale = (designWidth: number, minScale: number): FitScale => {
+ const wrapRef = useRef(null)
+ const innerRef = useRef(null)
+ const [{ scale, height }, setState] = useState({ scale: 0, height: 0 })
+
+ useEffect(() => {
+ // ResizeObserver is browser-only; prerendering never reaches this effect.
+ if (typeof ResizeObserver === 'undefined') return
+
+ const measure = () => {
+ const wrap = wrapRef.current
+ const inner = innerRef.current
+ if (wrap === null || inner === null) return
+
+ const width = wrap.clientWidth
+ if (width === 0) return
+
+ const next = Math.max(minScale, Math.min(1, width / designWidth))
+ const nextHeight = inner.offsetHeight * next
+ if (nextHeight === 0) return
+
+ setState((prev) =>
+ Math.abs(prev.scale - next) < 0.001 && Math.abs(prev.height - nextHeight) < 0.5
+ ? prev
+ : { scale: next, height: nextHeight },
+ )
+ }
+
+ measure()
+
+ // Both boxes matter: the wrapper for available width, the inner for the
+ // height that width produces once the webfont has swapped in.
+ const observer = new ResizeObserver(measure)
+ if (wrapRef.current !== null) observer.observe(wrapRef.current)
+ if (innerRef.current !== null) observer.observe(innerRef.current)
+
+ return () => {
+ observer.disconnect()
+ }
+ }, [designWidth, minScale])
+
+ return { wrapRef, innerRef, scale, height, ready: scale > 0 }
+}
diff --git a/website/src/components/ui/button.tsx b/website/src/components/ui/button.tsx
new file mode 100644
index 0000000..94166a9
--- /dev/null
+++ b/website/src/components/ui/button.tsx
@@ -0,0 +1,57 @@
+import { cva, type VariantProps } from 'class-variance-authority'
+import { Link } from 'react-router'
+
+import { cn } from '@/lib/utils'
+
+const buttonVariants = cva(
+ 'inline-flex items-center gap-2 rounded-md border font-mono font-medium transition-[background,border-color] duration-[120ms] disabled:pointer-events-none disabled:opacity-50',
+ {
+ variants: {
+ variant: {
+ default:
+ 'border-line bg-transparent text-primary hover:border-line-strong hover:bg-[var(--bg-selection)]',
+ accent:
+ 'border-accent bg-accent text-white hover:border-accent-hover hover:bg-accent-hover',
+ },
+ size: {
+ default: 'h-11 px-5 text-sm max-sm:h-[42px] max-sm:px-4 max-sm:text-[13px]',
+ sm: 'h-8 px-2.5 text-xs',
+ },
+ },
+ defaultVariants: { variant: 'default', size: 'default' },
+ },
+)
+
+type ButtonVariants = VariantProps
+
+interface ButtonProps extends ButtonVariants {
+ /** Internal route path, or an absolute URL for an off-site link. */
+ href: string
+ children: React.ReactNode
+ className?: string
+}
+
+const isExternal = (href: string): boolean => /^https?:\/\//.test(href)
+
+/**
+ * Every call to action on the site is a link, so this renders an anchor
+ * rather than a ``. External links open in a new tab and carry
+ * `rel="noopener"`; internal ones route through React Router so navigation
+ * stays client-side.
+ */
+export const Button = ({ href, variant, size, children, className }: ButtonProps) => {
+ const classes = cn(buttonVariants({ variant, size }), className)
+
+ if (isExternal(href))
+ return (
+
+ {children}
+
+ )
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/website/src/components/ui/command-pill.tsx b/website/src/components/ui/command-pill.tsx
new file mode 100644
index 0000000..af26b4c
--- /dev/null
+++ b/website/src/components/ui/command-pill.tsx
@@ -0,0 +1,43 @@
+import { useCopy } from '@/hooks/use-copy'
+import { cn } from '@/lib/utils'
+
+interface CommandPillProps {
+ text: string
+ className?: string
+}
+
+/** The one-line install command, click-to-copy. */
+export const CommandPill = ({ text, className }: CommandPillProps) => {
+ const { copied, copy } = useCopy()
+
+ return (
+ {
+ copy(text)
+ }}
+ className={cn(
+ 'inline-flex h-11 items-center gap-2.5 rounded-md border border-line bg-panel py-0 pr-2.5 pl-3.5 font-mono text-[13.5px] tabular-nums text-primary hover:border-line-strong hover:bg-raised max-sm:w-full max-sm:justify-between',
+ className,
+ )}
+ >
+
+ $
+
+ {text}
+
+ {copied ? '✓ copied' : '⧉ copy'}
+
+
+ {copied ? 'Copied to clipboard' : ''}
+
+
+ )
+}
diff --git a/website/src/components/ui/copy-button.tsx b/website/src/components/ui/copy-button.tsx
new file mode 100644
index 0000000..7ccb831
--- /dev/null
+++ b/website/src/components/ui/copy-button.tsx
@@ -0,0 +1,32 @@
+import { useCopy } from '@/hooks/use-copy'
+import { cn } from '@/lib/utils'
+
+interface CopyButtonProps {
+ text: string
+ className?: string
+}
+
+/** The copy control in a terminal header. Styled against the terminal
+ * surface tokens, not the page ones, so it reads as part of the window. */
+export const CopyButton = ({ text, className }: CopyButtonProps) => {
+ const { copied, copy } = useCopy()
+
+ return (
+ {
+ copy(text)
+ }}
+ className={cn(
+ 'inline-flex items-center gap-1.5 rounded-md border px-2 py-[3px] font-mono text-[11px] transition-colors',
+ copied
+ ? 'border-[var(--tm-ok)] text-[var(--tm-ok)]'
+ : 'border-[var(--tm-bd)] text-[var(--tm-dim)] hover:border-[var(--tm-bd3)] hover:text-[var(--tm-fg)]',
+ className,
+ )}
+ >
+ {copied ? '✓' : '⧉'}
+ {copied ? 'copied' : 'copy'}
+
+ )
+}
diff --git a/website/src/components/ui/feature-card.tsx b/website/src/components/ui/feature-card.tsx
new file mode 100644
index 0000000..815e8f9
--- /dev/null
+++ b/website/src/components/ui/feature-card.tsx
@@ -0,0 +1,81 @@
+import { useCopy } from '@/hooks/use-copy'
+import { cn } from '@/lib/utils'
+
+interface FeatureCommandProps {
+ text: string
+}
+
+const FeatureCommand = ({ text }: FeatureCommandProps) => {
+ const { copied, copy } = useCopy()
+
+ return (
+ {
+ copy(text)
+ }}
+ className={cn(
+ 'mt-auto flex w-full items-center gap-2 rounded-md border bg-[var(--cmd-bg)] px-2.5 py-2 text-left font-mono text-[11.5px] leading-[1.5] text-[var(--tm-fg)]',
+ copied
+ ? 'border-[var(--tm-ok)]'
+ : 'border-[var(--tm-bd)] hover:border-[var(--tm-bd3)]',
+ )}
+ >
+
+ $
+
+ {text}
+
+ {copied ? '✓' : '⧉'}
+
+
+ )
+}
+
+interface FeatureCardProps {
+ /** A single glyph. Decorative — the title carries the meaning. */
+ glyph?: string
+ /** A CSS colour for the glyph, from the semantic token set. */
+ color?: string
+ title: string
+ children: React.ReactNode
+ cmd?: string
+ className?: string
+}
+
+export const FeatureCard = ({
+ glyph,
+ color,
+ title,
+ children,
+ cmd,
+ className,
+}: FeatureCardProps) => (
+
+
+ {glyph !== undefined && (
+
+ {glyph}
+
+ )}
+
{title}
+
+
+ {children}
+
+ {cmd !== undefined &&
}
+
+)
diff --git a/website/src/components/ui/inline-code.tsx b/website/src/components/ui/inline-code.tsx
new file mode 100644
index 0000000..57dfd84
--- /dev/null
+++ b/website/src/components/ui/inline-code.tsx
@@ -0,0 +1,18 @@
+import { cn } from '@/lib/utils'
+
+interface CodeProps {
+ children: React.ReactNode
+ className?: string
+}
+
+/** Anything typeable: flags, commands, env vars, crate names, paths. */
+export const Code = ({ children, className }: CodeProps) => (
+
+ {children}
+
+)
diff --git a/website/src/components/ui/layout.tsx b/website/src/components/ui/layout.tsx
new file mode 100644
index 0000000..789816c
--- /dev/null
+++ b/website/src/components/ui/layout.tsx
@@ -0,0 +1,136 @@
+import { cn } from '@/lib/utils'
+
+interface WrapProps {
+ children: React.ReactNode
+ className?: string
+}
+
+/** The site's single content column. One max-width, one gutter, everywhere. */
+export const Wrap = ({ children, className }: WrapProps) => (
+
+ {children}
+
+)
+
+interface SectionProps {
+ children: React.ReactNode
+ /** Drops the top hairline — for the first section under the nav. */
+ flush?: boolean
+ /** Panel background plus rules top and bottom. */
+ band?: boolean
+ id?: string
+ className?: string
+}
+
+export const Section = ({ children, flush, band, id, className }: SectionProps) => (
+
+)
+
+interface EyebrowProps {
+ children: React.ReactNode
+ className?: string
+}
+
+export const Eyebrow = ({ children, className }: EyebrowProps) => (
+
+ {children}
+
+)
+
+interface SectionHeadProps {
+ eyebrow?: string
+ title: React.ReactNode
+ children?: React.ReactNode
+ className?: string
+}
+
+export const SectionHead = ({
+ eyebrow,
+ title,
+ children,
+ className,
+}: SectionHeadProps) => (
+
+ {eyebrow !== undefined &&
{eyebrow} }
+
+ {title}
+
+ {children !== undefined && (
+
{children}
+ )}
+
+)
+
+interface PageHeadProps {
+ eyebrow: string
+ title: string
+ children: React.ReactNode
+}
+
+/** The h1 block every subpage opens with. */
+export const PageHead = ({ eyebrow, title, children }: PageHeadProps) => (
+
+
+
{eyebrow}
+
+ {title}
+
+
{children}
+
+
+)
+
+interface ColsProps {
+ children: React.ReactNode
+ /** Aligns columns to their tops instead of stretching. */
+ start?: boolean
+ center?: boolean
+ className?: string
+}
+
+/** The site's only multi-column layout: two up on desktop, stacked below. */
+export const Cols = ({ children, start, center, className }: ColsProps) => (
+
+ {children}
+
+)
+
+interface ProseProps {
+ children: React.ReactNode
+ className?: string
+}
+
+/** Body copy: secondary colour, generous leading, `+p` spacing. */
+export const Prose = ({ children, className }: ProseProps) => (
+ p+p]:mt-4 [&>p]:leading-[26px] [&>p]:text-pretty [&>p]:text-secondary',
+ className,
+ )}
+ >
+ {children}
+
+)
diff --git a/website/src/components/ui/os-icon.tsx b/website/src/components/ui/os-icon.tsx
new file mode 100644
index 0000000..238a909
--- /dev/null
+++ b/website/src/components/ui/os-icon.tsx
@@ -0,0 +1,22 @@
+export type OsName = 'macOS' | 'Linux' | 'Windows'
+
+const PATHS: Record, string> = {
+ macOS:
+ 'M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701',
+ Linux:
+ 'M12.504 0c-.155 0-.315.008-.48.021-4.226.333-3.105 4.807-3.17 6.298-.076 1.092-.3 1.953-1.05 3.02-.885 1.051-2.127 2.75-2.716 4.521-.278.832-.41 1.684-.287 2.489a.424.424 0 00-.11.135c-.26.268-.45.6-.663.839-.199.199-.485.267-.797.4-.313.136-.658.269-.864.68-.09.189-.136.394-.132.602 0 .199.027.4.055.536.058.399.116.728.04.97-.249.68-.28 1.145-.106 1.484.174.334.535.47.94.601.81.2 1.91.135 2.774.6.926.466 1.866.67 2.616.47.526-.116.97-.464 1.208-.946.587-.003 1.23-.269 2.26-.334.699-.058 1.574.267 2.577.2.025.134.063.198.114.333l.003.003c.391.778 1.113 1.132 1.884 1.071.771-.06 1.592-.536 2.257-1.306.631-.765 1.683-1.084 2.378-1.503.348-.199.629-.469.649-.853.023-.4-.2-.811-.714-1.376v-.097l-.003-.003c-.17-.2-.25-.535-.338-.926-.085-.401-.182-.786-.492-1.046h-.003c-.059-.054-.123-.067-.188-.135a.357.357 0 00-.19-.064c.431-1.278.264-2.55-.173-3.694-.533-1.41-1.465-2.638-2.175-3.483-.796-1.005-1.576-1.957-1.56-3.368.026-2.152.236-6.133-3.544-6.139z',
+}
+
+const WINDOWS_PATH =
+ 'M2 3.6l8.5-1.2v9.1H2V3.6zm10 -1.35L22.5 1v10.5H12V2.25zM2 12.5h8.5v9.1L2 20.4v-7.9zm10 0h10.5V23L12 21.75V12.5z'
+
+/** Decorative — the adjacent label already names the platform. */
+export const OsIcon = ({ name }: { name: OsName }) => (
+
+
+
+)
diff --git a/website/src/components/ui/panel.tsx b/website/src/components/ui/panel.tsx
new file mode 100644
index 0000000..658fc50
--- /dev/null
+++ b/website/src/components/ui/panel.tsx
@@ -0,0 +1,62 @@
+import { cn } from '@/lib/utils'
+
+interface PanelProps {
+ /** Pane number, rendered in signal blue like the TUI's `[2]` tags. */
+ num?: number | undefined
+ title: string
+ children: React.ReactNode
+ className?: string
+}
+
+/**
+ * The fieldset/legend motif lifted from the TUI: a bordered box whose title
+ * breaks the top border. `bg-base` on the legend is what punches the gap, so
+ * it has to match whatever surface the panel sits on.
+ */
+export const Panel = ({ num, title, children, className }: PanelProps) => (
+
+
+ {/* --color-info, not the raw --color-signal: this panel renders on the
+ page, outside .tui-content, where the raw palette never re-cuts. */}
+ {num !== undefined && [{num}] }
+ {title}
+
+ {children}
+
+)
+
+interface CalloutProps {
+ title?: string
+ tone?: 'accent' | 'warn'
+ children: React.ReactNode
+ className?: string
+}
+
+/** An aside with a coloured left rule. `warn` carries the tape amber. */
+export const Callout = ({ title, tone = 'accent', children, className }: CalloutProps) => (
+ p]:leading-[25px] [&>p]:text-secondary',
+ className,
+ )}
+ >
+ {title !== undefined && (
+
+ {title}
+
+ )}
+ {children}
+
+)
diff --git a/website/src/components/ui/terminal.tsx b/website/src/components/ui/terminal.tsx
new file mode 100644
index 0000000..5bacda6
--- /dev/null
+++ b/website/src/components/ui/terminal.tsx
@@ -0,0 +1,71 @@
+import { CopyButton } from '@/components/ui/copy-button'
+import { cn } from '@/lib/utils'
+
+/** `cmd` gets a prompt glyph; `cm` is a comment; `code` and `out` are plain. */
+export type TermLineKind = 'cmd' | 'cm' | 'code' | 'out'
+
+export interface TermLine {
+ t: TermLineKind
+ text: string
+}
+
+const LINE_TONE: Record = {
+ cmd: '',
+ cm: 'text-[var(--tm-cm)]',
+ code: '',
+ out: '',
+}
+
+interface TerminalProps {
+ label?: string
+ lines: readonly TermLine[]
+ /** Overrides what the copy button yields. Defaults to the commands only. */
+ copy?: string
+ className?: string
+}
+
+/**
+ * A terminal window. Paints from the `--tm-*` token set rather than the page
+ * tokens, so it stays a dark terminal in light mode instead of dissolving
+ * into the page.
+ */
+export const Terminal = ({ label, lines, copy, className }: TerminalProps) => {
+ const copyText =
+ copy ??
+ lines
+ .filter((line) => line.t === 'cmd')
+ .map((line) => line.text)
+ .join('\n')
+
+ return (
+
+
+
+ {label ?? 'terminal'}
+
+
+
+
+ {lines.map((line, i) => (
+
+ {line.t === 'cmd' && (
+ <>
+ $ {' '}
+ >
+ )}
+ {line.text}
+
+ ))}
+
+
+ )
+}
diff --git a/website/src/entry-server.tsx b/website/src/entry-server.tsx
new file mode 100644
index 0000000..8dfe96f
--- /dev/null
+++ b/website/src/entry-server.tsx
@@ -0,0 +1,23 @@
+import { StrictMode } from 'react'
+import { renderToString } from 'react-dom/server'
+import { StaticRouter } from 'react-router'
+
+import { App } from '@/App'
+import { ThemeProvider } from '@/theme/theme-provider'
+
+/**
+ * Renders one route to a static HTML string.
+ *
+ * `location` is the app-relative path (no base prefix) — the client mounts
+ * with `basename`, so the two see the same paths and hydration matches.
+ */
+export const render = (location: string): string =>
+ renderToString(
+
+
+
+
+
+
+ ,
+ )
diff --git a/website/src/hooks/use-copy.ts b/website/src/hooks/use-copy.ts
new file mode 100644
index 0000000..3f46b01
--- /dev/null
+++ b/website/src/hooks/use-copy.ts
@@ -0,0 +1,40 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+
+const RESET_MS = 1400
+
+/**
+ * Copy-to-clipboard with a self-clearing "copied" flag.
+ *
+ * no execCommand fallback. `navigator.clipboard` is available in
+ * every secure context, and the site only ever ships over HTTPS (GitHub
+ * Pages) or localhost. If a copy does fail, the flag stays off and the text
+ * is still selectable by hand.
+ */
+export const useCopy = (): { copied: boolean; copy: (text: string) => void } => {
+ const [copied, setCopied] = useState(false)
+ const timer = useRef | null>(null)
+
+ useEffect(
+ () => () => {
+ if (timer.current !== null) clearTimeout(timer.current)
+ },
+ [],
+ )
+
+ const copy = useCallback((text: string) => {
+ navigator.clipboard.writeText(text).then(
+ () => {
+ setCopied(true)
+ if (timer.current !== null) clearTimeout(timer.current)
+ timer.current = setTimeout(() => {
+ setCopied(false)
+ }, RESET_MS)
+ },
+ () => {
+ // Permission denied or no clipboard: leave the button untouched.
+ },
+ )
+ }, [])
+
+ return { copied, copy }
+}
diff --git a/website/src/hooks/use-page-meta.ts b/website/src/hooks/use-page-meta.ts
new file mode 100644
index 0000000..290deed
--- /dev/null
+++ b/website/src/hooks/use-page-meta.ts
@@ -0,0 +1,23 @@
+import { useEffect } from 'react'
+import { useLocation } from 'react-router'
+
+import { metaFor } from '@/lib/routes'
+
+/**
+ * Keeps the title and meta description in step with the current route.
+ *
+ * Each route is also prerendered with its tags already in the HTML, so
+ * crawlers never depend on this running; it only covers client-side
+ * navigation after the first paint.
+ */
+export const usePageMeta = (): void => {
+ const { pathname } = useLocation()
+
+ useEffect(() => {
+ const meta = metaFor(pathname)
+ document.title = meta.title
+ document
+ .querySelector('meta[name="description"]')
+ ?.setAttribute('content', meta.description)
+ }, [pathname])
+}
diff --git a/website/src/index.css b/website/src/index.css
new file mode 100644
index 0000000..85aba26
--- /dev/null
+++ b/website/src/index.css
@@ -0,0 +1,285 @@
+@import 'tailwindcss';
+
+/*
+ * soulseek-rs — design tokens.
+ *
+ * Ported verbatim from the design system's tokens/{colors,typography,spacing}.css.
+ * The same values back the TUI in soulseek-rs/src/ui/styles.rs, so the terminal
+ * and the site stay one product. Dark is the native theme; light re-cuts the
+ * semantic hues ~10% darker because phosphor and tape read on vinyl but wash
+ * out on manila.
+ *
+ * Theme switching is driven by [data-theme] on , matching the design's
+ * own selector, rather than Tailwind's default `.dark` class.
+ */
+@custom-variant light (&:where([data-theme='light'], [data-theme='light'] *));
+
+@theme {
+ /* ---- Raw palette (dark-native names) ---- */
+ --color-sleeve: #14110f; /* warm off-black — cardboard shadow, never #000 */
+ --color-vinyl: #1e1a17; /* panels / surfaces, one step up from base */
+ --color-vinyl-2: #26211d; /* raised surface / hover on panels */
+ --color-dust: #8a8178; /* muted warm grey — secondary text, borders */
+ --color-paper: #e8e1d6; /* aged insert card — primary text, never #FFF */
+ --color-paper-dim: #c9c1b4;
+
+ --color-oxide: #c1502e; /* iron-oxide accent, deeper than Rust's own #CE422B */
+ --color-tape: #d8a657; /* cassette-shell amber — warnings, queued */
+ --color-phosphor: #7fb685; /* desaturated LCD green — success, connected */
+ --color-signal: #6e9dc9; /* softened windows-classic blue — links, info */
+ --color-alarm: #b33a3a; /* error red, distinguishable from oxide */
+
+ --color-manila: #f2ede4;
+ --color-manila-2: #e8e1d6;
+ --color-manila-3: #ded5c7;
+
+ /* ---- Semantic aliases, wired to the runtime vars below ---- */
+ --color-base: var(--bg-base);
+ --color-panel: var(--bg-panel);
+ --color-raised: var(--bg-raised);
+
+ --color-primary: var(--text-primary);
+ --color-secondary: var(--text-secondary);
+ --color-muted: var(--text-muted);
+ --color-inverse: var(--text-inverse);
+
+ --color-hairline: var(--border-hairline);
+ --color-line: var(--border-default);
+ --color-line-strong: var(--border-strong);
+
+ --color-accent: var(--accent);
+ --color-accent-hover: var(--accent-hover);
+ --color-accent-active: var(--accent-active);
+
+ --color-success: var(--status-success);
+ --color-warning: var(--status-warning);
+ --color-info: var(--status-info);
+ --color-error: var(--status-error);
+
+ --color-link: var(--link);
+ --color-link-hover: var(--link-hover);
+
+ /* ---- Typography: monospace, non-negotiable. It's a terminal application. ---- */
+ --font-mono:
+ 'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
+ /* the "earned wink" — the era's forum font. One surface only. */
+ --font-forum: Verdana, Tahoma, sans-serif;
+
+ /* Scale, from the brandbook specimen (48/56, 32/40, 20/28, 14/22, 12/18) */
+ --text-display: 48px;
+ --text-display--line-height: 56px;
+ --text-title: 32px;
+ --text-title--line-height: 40px;
+ --text-heading: 20px;
+ --text-heading--line-height: 28px;
+ --text-body: 14px;
+ --text-body--line-height: 22px;
+ --text-caption: 12px;
+ --text-caption--line-height: 18px;
+ --text-label: 11px;
+ --text-label--line-height: 14px;
+
+ --tracking-label: 0.06em;
+
+ /* Radii — favicon 16, panel card 8, cursor 3 */
+ --radius-sm: 3px;
+ --radius-md: 8px;
+ --radius-lg: 16px;
+
+ /* Restrained. A desk, not a dashboard. */
+ --shadow-panel:
+ 0 1px 0 rgb(0 0 0 / 0.35), 0 8px 24px rgb(0 0 0 / 0.35);
+
+ --animate-cursor-blink: ss-cursor-blink 1.06s steps(1) infinite;
+
+ @keyframes ss-cursor-blink {
+ 0%,
+ 50% {
+ opacity: 1;
+ }
+ 50.01%,
+ 100% {
+ opacity: 0;
+ }
+ }
+}
+
+/* ---- DARK is the default theme ---- */
+:root {
+ --bg-base: var(--color-sleeve);
+ --bg-panel: var(--color-vinyl);
+ --bg-raised: var(--color-vinyl-2);
+ --bg-selection: rgb(110 157 201 / 0.22);
+
+ /* Brightened from the raw palette for web body contrast — still warm. */
+ --text-primary: #ffffff;
+ --text-secondary: #e4dfd8;
+ --text-muted: #bdb4a9;
+ --text-inverse: var(--color-sleeve);
+
+ --border-hairline: rgb(138 129 120 / 0.35);
+ --border-default: rgb(138 129 120 / 0.7);
+ --border-strong: var(--color-dust);
+
+ --accent: var(--color-oxide);
+ --accent-hover: #d05f3c;
+ --accent-active: #a8431f;
+
+ --status-warning: var(--color-tape);
+ --status-success: var(--color-phosphor);
+ --status-info: var(--color-signal);
+ --status-error: var(--color-alarm);
+
+ --link: var(--color-signal);
+ --link-hover: #8bb4dc;
+
+ /* Terminal surface — a real terminal, darker than the page around it. */
+ --tm-bg: #1e1a17;
+ --tm-hd: #181513;
+ --tm-fg: #ffffff;
+ --tm-dim: #bdb4a9;
+ --tm-bd: rgb(138 129 120 / 0.32);
+ --tm-bd2: rgb(138 129 120 / 0.22);
+ --tm-bd3: rgb(138 129 120 / 0.6);
+ --tm-pr: #c1502e;
+ --tm-cm: #aba298;
+ --tm-ok: #7fb685;
+ --tm-fl: #d8a657;
+ --tm-sg: #6e9dc9;
+ --cmd-bg: #0e0c0a;
+ --tui-track: #2a2622;
+ --tui-bezel: #000000;
+
+ /* tmux's own status bar. Emulator chrome, so it does not follow the theme. */
+ --tmux-bg: #0e0c0a;
+ --tmux-hl: #b9a6e8;
+ --tmux-hl-fg: #1a1526;
+
+ color-scheme: dark;
+}
+
+/* ---- LIGHT: base becomes manila, sleeve becomes ink, accents darken ~10% ---- */
+[data-theme='light'] {
+ --bg-base: var(--color-manila);
+ --bg-panel: var(--color-manila-2);
+ --bg-raised: var(--color-manila-3);
+ --bg-selection: rgb(76 127 174 / 0.2);
+
+ --text-primary: var(--color-sleeve);
+ --text-secondary: #574e45;
+ --text-muted: #6f655b;
+ --text-inverse: var(--color-paper);
+
+ --border-hairline: rgb(20 17 15 / 0.15);
+ --border-default: rgb(20 17 15 / 0.28);
+ --border-strong: #6b6259;
+
+ --accent: #ae4728;
+ --accent-hover: #c1502e;
+ --accent-active: #953b1f;
+
+ --status-warning: #c08e3d;
+ --status-success: #5e9a66;
+ --status-info: #4c7fae;
+ --status-error: #9a2f2f;
+
+ --link: #4c7fae;
+ --link-hover: #3b6a95;
+
+ --tm-bg: #e9e2d7;
+ --tm-hd: #dcd3c4;
+ --tm-fg: #14110f;
+ --tm-dim: #574e45;
+ --tm-bd: rgb(20 17 15 / 0.2);
+ --tm-bd2: rgb(20 17 15 / 0.13);
+ --tm-bd3: rgb(20 17 15 / 0.42);
+ --tm-pr: #9e3f22;
+ --tm-cm: #6b6259;
+ --tm-ok: #3f7147;
+ --tm-fl: #8f6420;
+ --tm-sg: #35648f;
+ --cmd-bg: #dcd3c4;
+ --tui-track: #cfc5b5;
+ --tui-bezel: rgb(20 17 15 / 0.28);
+
+ color-scheme: light;
+}
+
+/*
+ * Only the cut that is actually shown gets fetched — a hidden would
+ * still be downloaded. url() is relative to this stylesheet, which Vite
+ * rebases against `base`, so the deploy path stays in vite.config.ts.
+ */
+.logo-mark {
+ background-image: url('/logo-ink.svg');
+}
+
+[data-theme='light'] .logo-mark {
+ background-image: url('/logo-light.svg');
+}
+
+/*
+ * The TUI panes paint from the raw palette (paper, dust, oxide…) because that
+ * is what the Rust client does. Those names do not flip with the theme, so on
+ * manila they would leave paper-on-paper. Light mode re-cuts the same names
+ * darker, scoped to the terminal's content.
+ *
+ * Deliberately not applied to the tmux bar above it: that is the emulator's
+ * own chrome, it keeps its near-black background in both themes, and needs
+ * the bright phosphor to stay legible against it.
+ */
+[data-theme='light'] .tui-content {
+ --color-paper: #14110f;
+ --color-paper-dim: #3a332c;
+ --color-dust: #645b52;
+ --color-oxide: #9e3f22;
+ --color-tape: #8f6420;
+ --color-phosphor: #3f7147;
+ --color-signal: #35648f;
+ --color-alarm: #8f2626;
+}
+
+@layer base {
+ html {
+ scroll-behavior: smooth;
+ /* Anchored headings clear the sticky nav instead of hiding under it. */
+ scroll-padding-top: 5rem;
+ -webkit-text-size-adjust: 100%;
+ }
+
+ body {
+ background: var(--bg-base);
+ color: var(--text-primary);
+ font-family: var(--font-mono);
+ font-size: var(--text-body);
+ line-height: var(--text-body--line-height);
+ /* Tabular figures EVERYWHERE — speeds, sizes, queue positions, ETAs. */
+ font-variant-numeric: tabular-nums;
+ -webkit-font-smoothing: antialiased;
+ text-rendering: optimizeLegibility;
+ }
+
+ ::selection {
+ background: var(--bg-selection);
+ }
+
+ /* The design system's own focus treatment, applied once globally. */
+ :focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ html {
+ scroll-behavior: auto;
+ }
+
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ }
+ }
+}
diff --git a/website/src/lib/links.ts b/website/src/lib/links.ts
new file mode 100644
index 0000000..6d9ac03
--- /dev/null
+++ b/website/src/lib/links.ts
@@ -0,0 +1,22 @@
+/** Every off-site destination, in one place. */
+export const LINKS = {
+ gh: 'https://github.com/michel/soulseek-rs',
+ issues: 'https://github.com/michel/soulseek-rs/issues',
+ releases: 'https://github.com/michel/soulseek-rs/releases',
+ changelog: 'https://github.com/michel/soulseek-rs/blob/master/CHANGELOG.md',
+ license: 'https://github.com/michel/soulseek-rs/blob/master/LICENSE',
+ cratesClient: 'https://crates.io/crates/soulseek-rs',
+ cratesLib: 'https://crates.io/crates/soulseek-rs-lib',
+ docsrs: 'https://docs.rs/soulseek-rs-lib',
+ rustup: 'https://rustup.rs',
+ soulfind: 'https://github.com/soulfind-dev/soulfind',
+ nicotine: 'https://nicotine-plus.org',
+ slskd: 'https://github.com/slskd/slskd',
+ slsknet: 'https://www.slsknet.org',
+} as const
+
+/** Injected from the workspace Cargo.toml at build time — see vite.config.ts. */
+export const VERSION = __APP_VERSION__
+
+/** Where the site is served from. Used for canonical and og:url. */
+export const SITE_URL = 'https://michel.github.io/soulseek-rs/'
diff --git a/website/src/lib/routes.ts b/website/src/lib/routes.ts
new file mode 100644
index 0000000..33af80e
--- /dev/null
+++ b/website/src/lib/routes.ts
@@ -0,0 +1,61 @@
+export interface RouteMeta {
+ /** Path without the base prefix, always leading-slashed. */
+ path: string
+ title: string
+ description: string
+}
+
+/**
+ * The single source of truth for what pages exist.
+ *
+ * Read three times: React Router matches against it, the layout applies the
+ * meta on navigation, and scripts/prerender.ts walks it to decide which HTML
+ * files to write. Adding a page means adding one entry here plus its route.
+ */
+/*
+ * `as const satisfies` rather than a plain annotation: the annotation widened
+ * `path` to `string`, which is what let App.tsx's hand-written route list
+ * drift from this one without failing the build. Literal paths make the
+ * mapping in App.tsx exhaustive-checkable.
+ */
+export const ROUTES = [
+ {
+ path: '/',
+ title: 'soulseek-rs, a Soulseek client for your terminal',
+ description:
+ 'soulseek-rs is a keyboard-driven Soulseek client and a Rust protocol library. Search, share, browse, and chat from your terminal.',
+ },
+ {
+ path: '/install',
+ title: 'soulseek-rs, install',
+ description:
+ 'Install the soulseek-rs client with cargo, build it from source, or depend on the protocol library and write your own client.',
+ },
+ {
+ path: '/docs',
+ title: 'soulseek-rs, docs',
+ description:
+ 'Quick start for soulseek-rs: install, run, drive it from the keyboard, and use the shell subcommands.',
+ },
+ {
+ path: '/community',
+ title: 'soulseek-rs, community',
+ description:
+ 'How to help with soulseek-rs, and where to go when another Soulseek client fits you better. MIT licensed. No telemetry.',
+ },
+] as const satisfies readonly RouteMeta[]
+
+/** Every path the site serves, as literals. */
+export type RoutePath = (typeof ROUTES)[number]['path']
+
+export const NOT_FOUND_META: RouteMeta = {
+ path: '/404',
+ title: 'soulseek-rs, not found',
+ description: 'That page does not exist.',
+}
+
+export const metaFor = (pathname: string): RouteMeta => {
+ const trimmed =
+ pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname
+ return ROUTES.find((route) => route.path === trimmed) ?? NOT_FOUND_META
+}
diff --git a/website/src/lib/utils.ts b/website/src/lib/utils.ts
new file mode 100644
index 0000000..19c2b38
--- /dev/null
+++ b/website/src/lib/utils.ts
@@ -0,0 +1,4 @@
+import { clsx, type ClassValue } from 'clsx'
+import { twMerge } from 'tailwind-merge'
+
+export const cn = (...inputs: ClassValue[]): string => twMerge(clsx(inputs))
diff --git a/website/src/main.tsx b/website/src/main.tsx
new file mode 100644
index 0000000..d969ab0
--- /dev/null
+++ b/website/src/main.tsx
@@ -0,0 +1,31 @@
+import { StrictMode } from 'react'
+import { createRoot, hydrateRoot } from 'react-dom/client'
+import { BrowserRouter } from 'react-router'
+
+import { App } from '@/App'
+import { ThemeProvider } from '@/theme/theme-provider'
+
+import './index.css'
+
+const container = document.getElementById('root')
+if (container === null) throw new Error('Root element #root is missing')
+
+const tree = (
+
+
+ {/* Must match `base` in vite.config.ts — the site is served from a
+ subpath, so the router has to strip it before matching. */}
+
+
+
+
+
+)
+
+// Every route ships prerendered markup, so the normal path is to hydrate it.
+// createRoot is the fallback for a container that somehow arrived empty.
+if (container.hasChildNodes()) {
+ hydrateRoot(container, tree)
+} else {
+ createRoot(container).render(tree)
+}
diff --git a/website/src/pages/community.tsx b/website/src/pages/community.tsx
new file mode 100644
index 0000000..f126087
--- /dev/null
+++ b/website/src/pages/community.tsx
@@ -0,0 +1,264 @@
+import { Button } from '@/components/ui/button'
+import { FeatureCard } from '@/components/ui/feature-card'
+import { Code } from '@/components/ui/inline-code'
+import { Cols, Eyebrow, PageHead, Prose, Section, SectionHead } from '@/components/ui/layout'
+import { Callout } from '@/components/ui/panel'
+import { Terminal } from '@/components/ui/terminal'
+import { LINKS } from '@/lib/links'
+
+interface Alternative {
+ question: string
+ answer: React.ReactNode
+ links: readonly { label: string; href: string }[]
+}
+
+const ALTERNATIVES: readonly Alternative[] = [
+ {
+ question: 'Want a window and a mouse?',
+ answer: <>SoulseekQt or Nicotine+. Both are good.>,
+ links: [
+ { label: 'SoulseekQt', href: LINKS.slsknet },
+ { label: 'Nicotine+', href: LINKS.nicotine },
+ ],
+ },
+ {
+ question: 'Want feature completeness?',
+ answer: (
+ <>
+ Nicotine+ has more: wishlists, user lists, transfer rules, plugins, translations.
+ It’s mature and well run.
+ >
+ ),
+ links: [{ label: 'Nicotine+', href: LINKS.nicotine }],
+ },
+ {
+ question: 'Want to point automation at the network?',
+ answer: <>slskd does that today. Daemon mode here is unbuilt.>,
+ links: [{ label: 'slskd', href: LINKS.slskd }],
+ },
+]
+
+const CITIZENSHIP = [
+ {
+ glyph: '↑',
+ color: 'var(--status-success)',
+ title: 'Share, and share real files',
+ body: (
+ <>
+ Point --shared-dir at the collection you actually listen to. Full
+ albums, honest bitrates, nothing transcoded up from a lossy source and relabelled.
+ >
+ ),
+ },
+ {
+ glyph: '≡',
+ color: 'var(--status-info)',
+ title: 'Organize so others can find it',
+ body: (
+ <>
+ Artist and album folders, tagged files, readable names. Someone else’s search
+ only reaches you if your filenames say what the music is.
+ >
+ ),
+ },
+ {
+ glyph: '∞',
+ color: 'var(--accent)',
+ title: 'Stay online',
+ body: (
+ <>
+ Leave it running in a tmux session on the box that is already on. A share that
+ appears for an hour a week is close to no share at all.
+ >
+ ),
+ },
+ {
+ glyph: '↔',
+ color: 'var(--status-warning)',
+ title: 'Let people reach you',
+ body: (
+ <>
+ Forward your listen port or let portmap try UPnP, and leave a free
+ upload slot. Two firewalled peers cannot trade with each other.
+ >
+ ),
+ },
+ {
+ glyph: '@',
+ color: 'var(--status-info)',
+ title: 'Answer people',
+ body: (
+ <>
+ Rooms and private messages are part of the client for a reason. Someone asking
+ where the rest of a discography went is worth a reply.
+ >
+ ),
+ },
+ {
+ glyph: '⋯',
+ color: 'var(--text-secondary)',
+ title: 'Wait your turn',
+ body: (
+ <>
+ Queues are one person’s upload bandwidth being divided up. Queue it, leave it,
+ come back later. Do not requeue the same file to jump the line.
+ >
+ ),
+ },
+] as const
+
+export const Community = () => {
+ return (
+ <>
+
+ It’s mostly one person who’s been on Soulseek since the early 2000s.
+ Issues and pull requests are welcome, and if another client fits you better, use it.
+
+
+
+
+ File an issue, open a pull request, or just say what’s missing. No
+ contributor agreement, no template gymnastics.
+
+
+
+
+ The project is a Cargo workspace: soulseek-rs-lib (the protocol)
+ and soulseek-rs (the client). The library stays lean on
+ dependencies; the client takes them freely.
+
+
+ Format and Clippy run clean on every push, and unit tests plus an end-to-end
+ suite against{' '}
+
+ soulfind
+ {' '}
+ run in CI, so a green checkmark means it genuinely built and talked to a
+ server.
+
+
+ That server is{' '}
+
+ soulfind-dev/soulfind
+
+ , an open-source Soulseek server. Run it locally and you can develop and test
+ against a real protocol implementation without touching the public network.
+
+
+
+ Open an issue ↗
+
+ GitHub ↗
+
+
+
+
+
+
+
+
+ Soulseek is a gem for music enthusiasts. Decades of records, live sets, and
+ pressings that never made it anywhere else, kept online by people who care about
+ them. It works because someone left their collection up for you. None of this is
+ enforced. It is just how the network stays worth using.
+
+
+ {CITIZENSHIP.map((item) => (
+
+ {item.body}
+
+ ))}
+
+
+
+
+
+ soulseek-rs has no GUI and no daemon mode yet. If one of these fits you better,
+ use it. Recommending them is the strongest trust signal there is.
+
+
+ {ALTERNATIVES.map((alt) => (
+
+
+
+ {alt.question}
+
+
+ {alt.answer}
+
+
+
+ {alt.links.map((link) => (
+
+ {link.label} ↗
+
+ ))}
+
+
+ ))}
+
+
+
+
+
+
+
license & promises
+
+ MIT, and no strings.
+
+
+ Free to use, copy, modify, and distribute. Copyright © 2026 Michel de Graaf.
+
+
+
+ Read the license ↗
+
+
+
+
+ No telemetry. Ever.
+
+ Not affiliated with or endorsed by the Soulseek project.
+
+
+
+
+ >
+ )
+}
diff --git a/website/src/pages/docs.tsx b/website/src/pages/docs.tsx
new file mode 100644
index 0000000..377d966
--- /dev/null
+++ b/website/src/pages/docs.tsx
@@ -0,0 +1,219 @@
+import { Link } from 'react-router'
+
+import { Button } from '@/components/ui/button'
+import { Code } from '@/components/ui/inline-code'
+import {
+ Cols,
+ Eyebrow,
+ PageHead,
+ Prose,
+ Section,
+ SectionHead,
+} from '@/components/ui/layout'
+import { Callout, Panel } from '@/components/ui/panel'
+import { Terminal } from '@/components/ui/terminal'
+import { LINKS } from '@/lib/links'
+
+const GLOBAL_KEYS: readonly (readonly [string, string])[] = [
+ ['Space', 'select a result'],
+ ['Enter', 'download / send'],
+ ['b', "browse the owner's files"],
+ ['c', 'chat rooms'],
+ ['m', 'compose a private message'],
+ ['i', 'inbox (unread counter)'],
+ ['/', 'filter the current list'],
+ ['a / A', 'select all / none'],
+ ['1–3', 'focus a pane'],
+ ['q', 'quit'],
+]
+
+const ROOM_KEYS: readonly (readonly [string, string])[] = [
+ ['Enter', 'join room / send message'],
+ ['Tab · ⇧Tab', 'switch between open rooms'],
+ ['x', 'leave the active room'],
+ ['l', 'back to the room list'],
+ ['↑ ↓', 'select a member'],
+ ['b', "browse the member's files"],
+ ['m', 'message the member'],
+]
+
+const PANES: readonly { num?: number; title: string; body: string }[] = [
+ {
+ num: 1,
+ title: 'Searches',
+ body: 'Your queries and how many results each returned. Type / to search the network; each search keeps its own result set.',
+ },
+ {
+ num: 2,
+ title: 'Results',
+ body: 'Files from the network: size, user, bitrate, speed, free slots. Space selects, Enter queues a download.',
+ },
+ {
+ num: 3,
+ title: 'Downloads / Uploads',
+ body: 'Live transfers in both directions with progress and speed: queued, active, complete, failed.',
+ },
+ {
+ title: 'Info',
+ body: 'Everything about the selected file: user, size, path, bitrate, length, queue position, free slots.',
+ },
+]
+
+const KeyRow = ({ combo, description }: { combo: string; description: string }) => (
+
+
+
+ {combo}
+
+
+ {description}
+
+)
+
+export const Docs = () => {
+ return (
+ <>
+
+ This is the short version. The full reference (every flag, every message) lives in
+ the README and on docs.rs.
+
+
+
+
+ You need a Rust toolchain. After cargo install, the binary is on your
+ PATH.
+
+
+
+
+
+ On first run soulseek-rs shows a login screen: sign in with an existing
+ Soulseek account or register a new one, right in the TUI. Your password is
+ stored in the OS keychain, not in plain text.
+
+
+ Downloads land in a conventional folder by default, and your TUI state
+ (searches, downloads, rooms) is saved and restored across restarts.
+
+
+
+
+
+
+
+ The whole client is these four boxes. Press 1–3 to focus
+ one; the focused pane’s legend turns green.
+
+
+ {PANES.map((pane) => (
+
+ {pane.body}
+
+ ))}
+
+
+
+
+
+ The bottom bar always shows the keys for where you are. The essentials:
+
+
+
+
anywhere
+
+ {GLOBAL_KEYS.map(([combo, description]) => (
+
+ ))}
+
+
+
+
+ in the chat-rooms popup (press c)
+
+
+ {ROOM_KEYS.map(([combo, description]) => (
+
+ ))}
+
+
+
+
+
+
+
+ Skip the TUI when you’re scripting or mid-task.
+
+
+ "hello there"' },
+ { t: 'cmd', text: 'soulseek-rs rooms' },
+ { t: 'cm', text: '# list public rooms, busiest first' },
+ { t: 'cmd', text: 'soulseek-rs chat "hello room"' },
+ { t: 'cmd', text: 'soulseek-rs portmap' },
+ ]}
+ />
+
+
+ The subcommands mirror the TUI: search, message,{' '}
+ browse, rooms, chat, and{' '}
+ portmap. Pass a room and a message to chat to say
+ one thing and exit.
+
+
+ portmap tests whether your router will let peers connect back,
+ worth running once.{' '}
+
+ More on being reachable →
+
+
+
+
+
+
+
+ If both you and a peer are behind routers with no forwarded port, browsing
+ that peer can’t work, that’s a fundamental Soulseek/peer-to-peer
+ limitation, not a bug.
+
+
+
+
+
+
+
+
+
+
+ Deeper docs live where they stay current: the full README on GitHub, and the
+ generated API reference for the library on docs.rs.
+
+
+
+
+ README ↗
+
+ docs.rs ↗
+ Changelog ↗
+
+
+
+
+ >
+ )
+}
diff --git a/website/src/pages/home.tsx b/website/src/pages/home.tsx
new file mode 100644
index 0000000..5d4c6bb
--- /dev/null
+++ b/website/src/pages/home.tsx
@@ -0,0 +1,261 @@
+import { Link } from 'react-router'
+
+import { Logo } from '@/components/brand/logo'
+import { HeroTerminal } from '@/components/tui/hero-terminal'
+import { Button } from '@/components/ui/button'
+import { CommandPill } from '@/components/ui/command-pill'
+import { FeatureCard } from '@/components/ui/feature-card'
+import { Code } from '@/components/ui/inline-code'
+import { Cols, Eyebrow, Prose, Section, SectionHead, Wrap } from '@/components/ui/layout'
+import { OsIcon, type OsName } from '@/components/ui/os-icon'
+import { Callout } from '@/components/ui/panel'
+import { Terminal } from '@/components/ui/terminal'
+import { LINKS } from '@/lib/links'
+
+const PLATFORMS: readonly { name: OsName; note: string; anchor: string }[] = [
+ { name: 'macOS', note: 'Apple Keychain', anchor: 'macos' },
+ { name: 'Linux', note: 'Secret Service', anchor: 'linux' },
+ { name: 'Windows', note: 'Credential Manager', anchor: 'windows' },
+]
+
+const FEATURES = [
+ {
+ glyph: '→',
+ color: 'var(--accent)',
+ title: 'Search & download',
+ cmd: 'soulseek-rs "field recordings"',
+ body: <>Search the network, pick results in the TUI, and queue downloads with pause, resume, and retry.>,
+ },
+ {
+ glyph: '↑',
+ color: 'var(--status-success)',
+ title: 'Sharing',
+ cmd: 'soulseek-rs --shared-dir ~/Music',
+ body: (
+ <>
+ Point --shared-dir at a directory and your files show up in searches;
+ peers browse and download them.
+ >
+ ),
+ },
+ {
+ glyph: '›',
+ color: 'var(--status-info)',
+ title: 'Browse',
+ cmd: 'soulseek-rs browse ',
+ body: <>List any user’s shared files and download straight from the tree.>,
+ },
+ {
+ glyph: '#',
+ color: 'var(--status-warning)',
+ title: 'Chat rooms',
+ cmd: 'soulseek-rs rooms',
+ body: <>List, join, and talk in public rooms, several open at once as tabs.>,
+ },
+ {
+ glyph: '@',
+ color: 'var(--status-info)',
+ title: 'Private messages',
+ cmd: 'soulseek-rs message "hi"',
+ body: <>Send and receive PMs, with an inbox and an unread counter in the TUI.>,
+ },
+ {
+ glyph: '↔',
+ color: 'var(--status-info)',
+ title: 'Firewalled peers',
+ cmd: 'soulseek-rs --listener-port 2234',
+ body: (
+ <>
+ Downloads and browsing fall back to a server-brokered connection when a peer
+ can’t be reached directly.
+ >
+ ),
+ },
+ {
+ glyph: '↯',
+ color: 'var(--status-warning)',
+ title: 'Automatic port mapping',
+ cmd: 'soulseek-rs portmap',
+ body: (
+ <>
+ Opens your listen port via UPnP-IGD and NAT-PMP, with a portmap{' '}
+ subcommand to test your router.
+ >
+ ),
+ },
+ {
+ glyph: '▮',
+ color: 'var(--accent)',
+ title: 'TUI and CLI',
+ cmd: 'soulseek-rs',
+ body: (
+ <>
+ A full terminal interface, plus scriptable subcommands: search, message, browse,
+ rooms, chat, portmap.
+ >
+ ),
+ },
+] as const
+
+export const Home = () => {
+ return (
+ <>
+
+
+
+
Soulseek client · terminal · Rust
+
+
+
+ A Soulseek client for people who live in the terminal.
+
+
+
+ Search the network, share your files, browse someone’s collection, join
+ a room. It runs over ssh on the machine where your music already lives, with
+ no window and no mouse.
+
+
+
+
+ Install
+
+ Read the docs
+ GitHub ↗
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Runs on
+
+ {PLATFORMS.map((platform, i) => (
+
+
+
+
+
+ {platform.name}
+
+ {platform.note}
+
+
+
+ ))}
+
+
+
+
+
+
+
+ No charts, no recommendations. You search, you find the person who has it, you
+ download it from them.
+
+
+
+
+ Soulseek is a peer-to-peer network from the early 2000s. People still use it
+ to share niche music by hand: the album that exists in one person’s
+ folder and nowhere else.
+
+
+ soulseek-rs speaks that protocol. It’s a third-party client, not the
+ official app, and not affiliated with the Soulseek project. It shares,
+ browses, and does rooms and private messages, because a client that only
+ downloads takes from the network without giving anything back.
+
+
+
+
+
+ Reach for soulseek-rs-lib to build a custom client or an agent
+ on the protocol directly.{' '}
+
+ Use the library →
+
+
+
+
+
+ Point --shared-dir at your library and leave it running in a
+ tmux session on your share or headless box. The network only works because
+ people share, and users who share back tend to get queue slots faster.
+
+
+
+
+
+
+
+
+ Everything happens in the TUI, and every command also runs as a one-off from the
+ shell, so the same client works for scripting, cron jobs, and agents driving it
+ programmatically.
+
+
+ {FEATURES.map((feature) => (
+
+ {feature.body}
+
+ ))}
+
+
+
+
+
+
+
get started
+
+ Install it, then press / to search.
+
+
+ Needs a Rust toolchain. From there it’s one command, then the TUI opens.
+
+
+
+ Read the docs
+
+ Install options
+
+
+
+
+
+ >
+ )
+}
diff --git a/website/src/pages/install.tsx b/website/src/pages/install.tsx
new file mode 100644
index 0000000..47c9c62
--- /dev/null
+++ b/website/src/pages/install.tsx
@@ -0,0 +1,347 @@
+import { Button } from '@/components/ui/button'
+import { FeatureCard } from '@/components/ui/feature-card'
+import { Code } from '@/components/ui/inline-code'
+import { Cols, PageHead, Prose, Section, SectionHead } from '@/components/ui/layout'
+import { Callout } from '@/components/ui/panel'
+import { Terminal, type TermLine } from '@/components/ui/terminal'
+import { LINKS } from '@/lib/links'
+
+const LIB_SRC = `use soulseek_rs::Client;
+use std::time::Duration;
+
+fn main() -> Result<(), Box> {
+ let mut client = Client::new("username", "password");
+ client.connect();
+ client.login()?;
+
+ let results = client.search("public domain field recordings", Duration::from_secs(10))?;
+ if let Some(r) = results.iter().find(|r| !r.files.is_empty()) {
+ let f = &r.files[0];
+ client.download(f.name.clone(), f.username.clone(), f.size, "~/Downloads".to_string())?;
+ }
+ Ok(())
+}`
+
+// LIB_SRC carries no comment lines, so every line is plain code.
+const LIB_LINES: TermLine[] = LIB_SRC.split('\n').map((text) => ({
+ t: 'code' as const,
+ text,
+}))
+
+interface PlatformNote {
+ id: string
+ name: string
+ credentialStore: string
+ config: string
+ state: string
+ downloads: string
+ note: string
+}
+
+const PLATFORMS: readonly PlatformNote[] = [
+ {
+ id: 'macos',
+ name: 'macOS',
+ credentialStore: 'Apple Keychain',
+ config: '~/.config/soulseek-rs/config.toml',
+ state: '~/.local/share/soulseek-rs/state',
+ downloads: '~/Downloads/Soulseek',
+ note: 'macOS deliberately follows the XDG layout rather than Application Support, so config sits beside your other dotfile config.',
+ },
+ {
+ id: 'linux',
+ name: 'Linux',
+ credentialStore: 'Secret Service (pure-Rust backend, no libdbus needed)',
+ config: '~/.config/soulseek-rs/config.toml',
+ state: '~/.local/share/soulseek-rs/state',
+ downloads: '~/Downloads/Soulseek',
+ note: 'Honours XDG_CONFIG_HOME and XDG_DATA_HOME when they are set.',
+ },
+ {
+ id: 'windows',
+ name: 'Windows',
+ credentialStore: 'Windows Credential Manager',
+ config: '%APPDATA%\\soulseek-rs\\config\\config.toml',
+ state: '%APPDATA%\\soulseek-rs\\data\\state',
+ downloads: '%USERPROFILE%\\Downloads\\Soulseek',
+ note: 'Uses the standard known-folder locations for roaming app data.',
+ },
+]
+
+interface StepProps {
+ n: number
+ title: string
+ children: React.ReactNode
+}
+
+const Step = ({ n, title, children }: StepProps) => (
+
+
+ {n}
+
+
+
+ {title}
+
+ {children}
+
+
+)
+
+export const Install = () => {
+ return (
+ <>
+
+ One command if you have Rust. Build from source if you’d rather. Or depend on
+ the library and write your own client.
+
+
+
+
+
+
+ Needs a Rust toolchain, available from{' '}
+
+ rustup.rs
+
+ . Then:
+
+
+
+ Cargo puts the binary on your PATH. Run soulseek-rs and the TUI
+ opens.
+
+
+ Prebuilt binaries ↗
+
+
+ No Rust toolchain? Grab a prebuilt archive for your platform from the{' '}
+
+ releases page
+
+ , unpack it, and put the binary somewhere on your PATH.
+
+
+
+
+
+ Clone the workspace and build a release binary.
+
+
+
+ You’ll find the binary at target/release/soulseek-rs.
+ Prebuilt archives for tagged releases are on the{' '}
+
+ releases page
+
+ .
+
+
+
+
+
+ Write your own client or bot on soulseek-rs-lib: the protocol
+ implementation, separate from the TUI.
+
+
+
+
+ Full API reference on{' '}
+
+ docs.rs
+
+ . Note: v6 moved sharing from one directory to a list. See the{' '}
+
+ changelog
+ {' '}
+ if you’re upgrading from 5.x.
+
+
+
+
+
+
+
+ Browsing and downloading are peer-to-peer, so at least one side has to accept an
+ incoming connection.
+
+
+
+
+ With the listener on (the default), soulseek-rs tries to open its listen port
+ on your router via{' '}
+ UPnP-IGD and{' '}
+ NAT-PMP . It’s best-effort:
+ if your router has those disabled it’s a no-op, and you forward the port
+ yourself.
+
+
+ The port is your --listener-port (env LISTENER_PORT,
+ default 2234); it’s renewed automatically and removed on
+ exit. Pass --disable-listener to turn it off. Check your own
+ network without launching the client:
+
+
+
+
+
+
+
+ If both you and a peer are behind routers with no forwarded port, browsing
+ that peer can’t work, that’s a fundamental Soulseek/peer-to-peer
+ limitation, not a bug. Forward --listener-port, or let{' '}
+ portmap try UPnP.
+
+
+
+
+
+
+
+
+
+ Three things exist on disk: the binary, the config, and the state. Nothing else is
+ written, and there is no telemetry to opt out of.
+
+
+
+ Windows Credentials' },
+ ]}
+ />
+
+
+
+
+ Your downloads and your shared directories are yours: they stay exactly where
+ they are. On macOS, delete the keychain entry for soulseek-rs in
+ Keychain Access if you saved a password.
+
+
+
+
+ >
+ )
+}
diff --git a/website/src/pages/not-found.tsx b/website/src/pages/not-found.tsx
new file mode 100644
index 0000000..f0006d4
--- /dev/null
+++ b/website/src/pages/not-found.tsx
@@ -0,0 +1,34 @@
+import { Button } from '@/components/ui/button'
+import { Eyebrow, Wrap } from '@/components/ui/layout'
+import { Terminal } from '@/components/ui/terminal'
+
+export const NotFound = () => {
+ return (
+
+
+
+
404
+
+ No such path.
+
+
+ That page doesn’t exist. The four that do are in the nav above.
+
+
+
+
+ Home
+
+ Read the docs
+
+
+
+
+ )
+}
diff --git a/website/src/theme/theme-context.ts b/website/src/theme/theme-context.ts
new file mode 100644
index 0000000..9b35301
--- /dev/null
+++ b/website/src/theme/theme-context.ts
@@ -0,0 +1,25 @@
+import { createContext } from 'react'
+
+export const THEMES = ['light', 'dark', 'system'] as const
+export type Theme = (typeof THEMES)[number]
+export type ResolvedTheme = Exclude
+
+export const isTheme = (value: unknown): value is Theme =>
+ typeof value === 'string' && (THEMES as readonly string[]).includes(value)
+
+export interface ThemeContextValue {
+ /** What the user picked — may be 'system'. */
+ theme: Theme
+ /** What is actually painted right now — never 'system'. */
+ resolvedTheme: ResolvedTheme
+ setTheme: (theme: Theme) => void
+}
+
+/*
+ * Split from the provider so this module exports only values, which keeps the
+ * provider file component-only for react-refresh.
+ */
+export const ThemeContext = createContext(null)
+
+/** Matches the key the design's own inline boot script reads. */
+export const THEME_STORAGE_KEY = 'ssrs-theme'
diff --git a/website/src/theme/theme-provider.tsx b/website/src/theme/theme-provider.tsx
new file mode 100644
index 0000000..dca9a00
--- /dev/null
+++ b/website/src/theme/theme-provider.tsx
@@ -0,0 +1,43 @@
+import { useCallback, useEffect, useMemo, useSyncExternalStore, type ReactNode } from 'react'
+
+import { ThemeContext, type Theme, type ThemeContextValue } from './theme-context'
+import {
+ readResolvedTheme,
+ readTheme,
+ serverResolvedTheme,
+ serverTheme,
+ subscribe,
+ writeTheme,
+} from './theme-store'
+
+interface ThemeProviderProps {
+ children: ReactNode
+}
+
+export const ThemeProvider = ({ children }: ThemeProviderProps) => {
+ const theme = useSyncExternalStore(subscribe, readTheme, serverTheme)
+ const resolvedTheme = useSyncExternalStore(
+ subscribe,
+ readResolvedTheme,
+ serverResolvedTheme,
+ )
+
+ useEffect(() => {
+ const root = document.documentElement
+ root.dataset['theme'] = resolvedTheme
+ // Paints native widgets (scrollbars, form controls) to match, which the
+ // attribute alone does not do.
+ root.style.colorScheme = resolvedTheme
+ }, [resolvedTheme])
+
+ const setTheme = useCallback((next: Theme) => {
+ writeTheme(next)
+ }, [])
+
+ const value = useMemo(
+ () => ({ theme, resolvedTheme, setTheme }),
+ [theme, resolvedTheme, setTheme],
+ )
+
+ return {children}
+}
diff --git a/website/src/theme/theme-store.ts b/website/src/theme/theme-store.ts
new file mode 100644
index 0000000..42eccb1
--- /dev/null
+++ b/website/src/theme/theme-store.ts
@@ -0,0 +1,64 @@
+import { isTheme, THEME_STORAGE_KEY, type ResolvedTheme, type Theme } from './theme-context'
+
+const DARK_QUERY = '(prefers-color-scheme: dark)'
+
+/*
+ * The theme lives outside React: the boot script in index.html resolves and
+ * paints it before the bundle loads, and the OS can change it at any time.
+ * That makes it an external store, which is what useSyncExternalStore is for
+ * — and it is the piece that gives correct hydration for free.
+ *
+ * The server snapshots below must match what the prerendered HTML ships
+ * (``). React renders the server snapshot for the
+ * hydration pass, then immediately re-renders with the real one, so a visitor
+ * whose actual theme is light never sees a mismatch, only a corrected render.
+ */
+
+const listeners = new Set<() => void>()
+
+/** Notifies subscribers after a local write; OS changes come via matchMedia. */
+const emit = () => {
+ for (const listener of listeners) listener()
+}
+
+export const subscribe = (onChange: () => void): (() => void) => {
+ listeners.add(onChange)
+ const query = window.matchMedia(DARK_QUERY)
+ query.addEventListener('change', onChange)
+ // Another tab changing the preference should move this one too.
+ window.addEventListener('storage', onChange)
+ return () => {
+ listeners.delete(onChange)
+ query.removeEventListener('change', onChange)
+ window.removeEventListener('storage', onChange)
+ }
+}
+
+export const readTheme = (): Theme => {
+ try {
+ const stored = localStorage.getItem(THEME_STORAGE_KEY)
+ return isTheme(stored) ? stored : 'system'
+ } catch {
+ // Safari in private mode throws on localStorage access.
+ return 'system'
+ }
+}
+
+export const readResolvedTheme = (): ResolvedTheme => {
+ const theme = readTheme()
+ if (theme !== 'system') return theme
+ return window.matchMedia(DARK_QUERY).matches ? 'dark' : 'light'
+}
+
+/** Both must agree with the `data-theme` baked into index.html. */
+export const serverTheme = (): Theme => 'system'
+export const serverResolvedTheme = (): ResolvedTheme => 'dark'
+
+export const writeTheme = (next: Theme): void => {
+ try {
+ localStorage.setItem(THEME_STORAGE_KEY, next)
+ } catch {
+ // Not persisting is survivable; the theme still applies this session.
+ }
+ emit()
+}
diff --git a/website/src/theme/use-theme.ts b/website/src/theme/use-theme.ts
new file mode 100644
index 0000000..fddf673
--- /dev/null
+++ b/website/src/theme/use-theme.ts
@@ -0,0 +1,10 @@
+import { useContext } from 'react'
+
+import { ThemeContext, type ThemeContextValue } from './theme-context'
+
+export const useTheme = (): ThemeContextValue => {
+ const context = useContext(ThemeContext)
+ if (context === null)
+ throw new Error('useTheme must be used within a ThemeProvider')
+ return context
+}
diff --git a/website/src/vite-env.d.ts b/website/src/vite-env.d.ts
new file mode 100644
index 0000000..daac02e
--- /dev/null
+++ b/website/src/vite-env.d.ts
@@ -0,0 +1,4 @@
+///
+
+/** Injected by `define` in vite.config.ts from the workspace Cargo.toml. */
+declare const __APP_VERSION__: string
diff --git a/website/tsconfig.app.json b/website/tsconfig.app.json
new file mode 100644
index 0000000..f55c377
--- /dev/null
+++ b/website/tsconfig.app.json
@@ -0,0 +1,40 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "ES2023",
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+
+ "moduleDetection": "force",
+ "isolatedModules": true,
+ "verbatimModuleSyntax": true,
+ "erasableSyntaxOnly": true,
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "noEmit": true,
+
+ /* Strictest. Every flag below is on deliberately — see README. */
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "exactOptionalPropertyTypes": true,
+ "noImplicitOverride": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true,
+ "noPropertyAccessFromIndexSignature": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "allowUnreachableCode": false,
+ "allowUnusedLabels": false,
+ "forceConsistentCasingInFileNames": true,
+
+ "skipLibCheck": true,
+
+ /* No baseUrl — deprecated in TS 6. `paths` resolves relative to this file. */
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["src"]
+}
diff --git a/website/tsconfig.json b/website/tsconfig.json
new file mode 100644
index 0000000..1ffef60
--- /dev/null
+++ b/website/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/website/tsconfig.node.json b/website/tsconfig.node.json
new file mode 100644
index 0000000..ed0b029
--- /dev/null
+++ b/website/tsconfig.node.json
@@ -0,0 +1,37 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "ES2023",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+
+ "moduleDetection": "force",
+ "isolatedModules": true,
+ "verbatimModuleSyntax": true,
+ "erasableSyntaxOnly": true,
+ /* scripts/ is run straight from source by Bun, which needs the extension. */
+ "allowImportingTsExtensions": true,
+ "noEmit": true,
+
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "exactOptionalPropertyTypes": true,
+ "noImplicitOverride": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "allowUnreachableCode": false,
+ "allowUnusedLabels": false,
+
+ "skipLibCheck": true,
+ "types": ["node"]
+ },
+ "include": [
+ "vite.config.ts",
+ "eslint.config.ts",
+ "scripts/**/*.ts",
+ "types/**/*.d.ts"
+ ]
+}
diff --git a/website/types/eslint-plugin-jsx-a11y.d.ts b/website/types/eslint-plugin-jsx-a11y.d.ts
new file mode 100644
index 0000000..31011b0
--- /dev/null
+++ b/website/types/eslint-plugin-jsx-a11y.d.ts
@@ -0,0 +1,14 @@
+/*
+ * eslint-plugin-jsx-a11y ships no types, and TS's inference from its JS picks
+ * up the legacy eslintrc `configs` shape (plugins as string[]) instead of the
+ * flat-config one. Declaring the flat export is enough to type the one thing
+ * eslint.config.ts touches.
+ */
+declare module 'eslint-plugin-jsx-a11y' {
+ import type { Linter } from 'eslint'
+
+ export const flatConfigs: {
+ recommended: Linter.Config
+ strict: Linter.Config
+ }
+}
diff --git a/website/vite.config.ts b/website/vite.config.ts
new file mode 100644
index 0000000..95be054
--- /dev/null
+++ b/website/vite.config.ts
@@ -0,0 +1,44 @@
+import { readFileSync } from 'node:fs'
+import { fileURLToPath, URL } from 'node:url'
+
+import tailwindcss from '@tailwindcss/vite'
+import react from '@vitejs/plugin-react'
+import { defineConfig } from 'vite'
+
+/*
+ * Read the version out of the workspace manifest rather than keeping a copy in
+ * TypeScript. release-plz bumps Cargo.toml; a hand-written constant here would
+ * silently advertise the previous release forever.
+ */
+const cargoToml = readFileSync(
+ fileURLToPath(new URL('../Cargo.toml', import.meta.url)),
+ 'utf8',
+)
+const versionMatch = /^\s*version\s*=\s*"([^"]+)"/m.exec(cargoToml)
+if (versionMatch?.[1] === undefined)
+ throw new Error('could not read version from ../Cargo.toml')
+const VERSION = `v${versionMatch[1]}`
+
+// Project sites are served from https://.github.io//, so every
+// asset URL needs that prefix baked in at build time.
+// hardcoded. Point a custom domain at the site and this becomes '/'
+// — one edit here plus the matching `basename` in src/main.tsx.
+const BASE = '/soulseek-rs/'
+
+export default defineConfig({
+ base: BASE,
+ define: {
+ __APP_VERSION__: JSON.stringify(VERSION),
+ },
+ plugins: [react(), tailwindcss()],
+ resolve: {
+ alias: {
+ '@': fileURLToPath(new URL('./src', import.meta.url)),
+ },
+ },
+ build: {
+ // Small site; a warning at the default 500 kB would be noise. A jump past
+ // this means something heavy got imported by accident.
+ chunkSizeWarningLimit: 600,
+ },
+})