feat(website): add the project site and deploy it to GitHub Pages

A Vite + React + TypeScript + Tailwind v4 site under website/, built from
the soulseek-rs design system so the site and the TUI stay one product.
The colour tokens in src/index.css are the same values as
soulseek-rs/src/ui/styles.rs; change one, change both.

The hero is a working recreation of the TUI: it plays one scripted run and
then takes the keyboard, or lands on the resting state directly under
prefers-reduced-motion. Its sample data is invented or public domain, per
the tone-of-voice guardrail against naming copyrighted material.

Static output rather than an SPA shell. `bun run build` compiles the client,
compiles the same app for Node, then renders every route in src/lib/routes.ts
to its own file with that route's title, description and canonical URL. A
direct link to /install therefore answers 200 with real markup instead of
GitHub's 404 page. routes.ts is keyed into App.tsx by literal path, so adding
one without the other fails typecheck rather than shipping a page whose body
says "not found" under the right title.

Theme is resolved once by an inline script before first paint and adopted by
React through useSyncExternalStore, so there is no flash and no hydration
mismatch on the toggle. Light mode re-cuts the semantic hues darker: phosphor
and tape read on vinyl but wash out on manila.

CI and release skip website-only pushes, and Pages skips Rust-only ones. The
two Rust workflows share one paths-ignore list so they can only skip
together, which keeps the release ci-gate from polling for a run that was
never queued. That gate also now treats a cancelled CI as "keep waiting"
rather than a failure, since cancel-in-progress cancels the older SHA on
every back-to-back push.
This commit is contained in:
Michel de Graaf
2026-07-24 23:42:41 +02:00
parent 54feb3bc5a
commit 33bfc34dc8
58 changed files with 5239 additions and 1 deletions
+7
View File
@@ -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: ['**']
+73
View File
@@ -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
+8 -1
View File
@@ -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
+24
View File
@@ -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?
+50
View File
@@ -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 `<Route>` 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 `<html>`, 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`.
+713
View File
@@ -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=="],
}
}
+21
View File
@@ -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"
}
}
+107
View File
@@ -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',
},
},
)
+63
View File
@@ -0,0 +1,63 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Root-relative on purpose: Vite rewrites this against `base`, so the
deploy path lives in vite.config.ts alone. -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>soulseek-rs, a Soulseek client for your terminal</title>
<meta
name="description"
content="soulseek-rs is a keyboard-driven Soulseek client and a Rust protocol library. Search, share, browse, and chat from your terminal."
/>
<!-- Browser chrome follows the theme; one value would be wrong half the time. -->
<meta name="theme-color" content="#14110F" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#F2EDE4" media="(prefers-color-scheme: light)" />
<meta property="og:type" content="website" />
<meta property="og:title" content="soulseek-rs" />
<meta
property="og:description"
content="A Soulseek client and protocol library for Rust. Search, share, browse, and chat from your terminal."
/>
<!-- og:url and canonical are rewritten per route by scripts/prerender.ts. -->
<meta property="og:url" content="https://michel.github.io/soulseek-rs/" />
<link rel="canonical" href="https://michel.github.io/soulseek-rs/" />
<meta name="twitter:card" content="summary" />
<!-- IBM Plex Mono is the brand typeface. CDN rather than self-hosted
binaries, matching the design system's tokens/fonts.css. OFL 1.1. -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;700&display=swap"
/>
<script>
// Runs before first paint so the right theme is already on <html>.
// Without it the page flashes the wrong background on every load.
// Mirrors the resolution order in src/theme/theme-provider.tsx.
;(function () {
try {
var stored = localStorage.getItem('ssrs-theme')
var dark =
stored === 'dark' ||
((stored === null || stored === 'system') &&
window.matchMedia('(prefers-color-scheme: dark)').matches)
var theme = dark ? 'dark' : 'light'
document.documentElement.dataset.theme = theme
document.documentElement.style.colorScheme = theme
} catch (e) {
// localStorage blocked; the dark default in the markup stands.
}
})()
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+41
View File
@@ -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"
}
}
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="160" height="160" viewBox="0 0 160 160" fill="none">
<g id="Favicon">
<rect width="160" height="160" rx="16" fill="#E8E1D6"></rect>
<path id="Letter S" d="M53.496 116.768C49.7413 116.768 46.4773 116.149 43.704 114.912C40.9307 113.675 38.6907 111.989 36.984 109.856L42.552 103.712C44.1733 105.504 45.944 106.827 47.864 107.68C49.8267 108.533 51.8107 108.96 53.816 108.96C56.12 108.96 57.8693 108.448 59.064 107.424C60.2587 106.4 60.856 104.928 60.856 103.008C60.856 101.429 60.3867 100.235 59.448 99.424C58.552 98.6133 57.016 98.0373 54.84 97.696L50.168 96.928C46.1573 96.2453 43.256 94.7733 41.464 92.512C39.672 90.208 38.776 87.4133 38.776 84.128C38.776 79.8187 40.184 76.4907 43 74.144C45.816 71.7547 49.7627 70.56 54.84 70.56C58.296 70.56 61.304 71.0933 63.864 72.16C66.424 73.2267 68.4933 74.6773 70.072 76.512L64.632 82.592C63.4373 81.2693 62.0293 80.2453 60.408 79.52C58.7867 78.752 56.952 78.368 54.904 78.368C50.5947 78.368 48.44 80.1387 48.44 83.68C48.44 85.216 48.9093 86.368 49.848 87.136C50.8293 87.904 52.408 88.48 54.584 88.864L59.256 89.696C62.8827 90.336 65.6773 91.7013 67.64 93.792C69.6027 95.8827 70.584 98.6987 70.584 102.24C70.584 104.331 70.2213 106.272 69.496 108.064C68.7707 109.813 67.6827 111.349 66.232 112.672C64.7813 113.952 62.9893 114.955 60.856 115.68C58.7653 116.405 56.312 116.768 53.496 116.768Z" fill="#14110F"></path>
<rect id="Cursor" x="91" y="48" width="31" height="72" rx="3" fill="#C1502E"></rect>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 59 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 27 KiB

+91
View File
@@ -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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;')
/** Replaces the content of one `<meta>` matched by attribute and value. */
const setMeta = (html: string, attr: string, name: string, value: string): string =>
html.replace(
new RegExp(`(<meta\\s+${attr}="${name}"\\s+content=")[\\s\\S]*?(")`),
`$1${escapeAttr(value)}$2`,
)
/**
* Swaps every per-page tag in the template for the route's own.
*
* All five are listed here together on purpose: og:title and og:url used to be
* left alone, so each prerendered page advertised the site title and the
* homepage URL to anything reading Open Graph.
*/
const applyMeta = (html: string, meta: RouteMeta): string => {
const canonical = meta.path === '/' ? SITE_URL : `${SITE_URL}${meta.path.slice(1)}/`
let out = html.replace(
/<title>[\s\S]*?<\/title>/,
`<title>${escapeAttr(meta.title)}</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(
/(<link\s+rel="canonical"\s+href=")[\s\S]*?(")/,
`$1${escapeAttr(canonical)}$2`,
)
}
const writePage = async (
template: string,
meta: RouteMeta,
outPath: string,
location: string,
): Promise<void> => {
const html = applyMeta(template, meta).replace(
'<div id="root"></div>',
`<div id="root">${render(location)}</div>`,
)
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`)
+45
View File
@@ -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<RoutePath, ReactElement> = {
'/': <Home />,
'/install': <Install />,
'/docs': <Docs />,
'/community': <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>
<Route element={<SiteLayout />}>
{ROUTES.map((route) =>
route.path === '/' ? (
<Route key={route.path} index element={PAGES[route.path]} />
) : (
<Route key={route.path} path={route.path} element={PAGES[route.path]} />
),
)}
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
)
+27
View File
@@ -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 <img>: a hidden <img> 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) => (
<div
role="img"
aria-label="soulseek-rs"
className={cn('logo-mark shrink-0 bg-contain bg-no-repeat', className)}
style={{ height: size, width: size * ASPECT }}
/>
)
+32
View File
@@ -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) => (
<span
className={cn(
'inline-flex items-center whitespace-nowrap font-mono font-medium leading-none tracking-[-0.01em] text-primary',
className,
)}
style={{ fontSize: size }}
>
soulseek-rs
<span
aria-hidden="true"
className={cn(
'ml-[0.06em] inline-block h-[1.02em] w-[0.58em] translate-y-[0.04em] rounded-[1.5px] bg-accent',
blink && 'motion-safe:animate-cursor-blink',
)}
/>
</span>
)
+98
View File
@@ -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('/') ? (
<Link to={href} className={LINK_CLASS}>
{label}
</Link>
) : (
<a href={href} target="_blank" rel="noopener noreferrer" className={LINK_CLASS}>
{label}
</a>
)
export const Footer = () => (
<footer className="border-t border-hairline bg-base pt-11 pb-10">
<Wrap>
<div className="grid grid-cols-1 gap-8 min-[520px]:grid-cols-2 md:grid-cols-[1.6fr_1fr_1fr_1fr]">
<div className="flex max-w-[320px] flex-col gap-3.5">
<Wordmark size={22} />
<p className="text-[13px] leading-[22px] text-secondary">
A Soulseek client and protocol library for Rust.
</p>
<p className="font-forum text-xs text-muted">
Not affiliated with or endorsed by the Soulseek project.
</p>
</div>
{COLUMNS.map((column) => (
<div key={column.heading}>
<h2 className="mb-3.5 text-label uppercase tracking-[var(--tracking-label)] text-secondary">
{column.heading}
</h2>
<ul>
{column.links.map((link) => (
<li key={link.label} className="mb-2.5">
<FooterAnchor {...link} />
</li>
))}
</ul>
</div>
))}
</div>
<div className="mt-11 flex flex-wrap items-center gap-x-6 gap-y-2 border-t border-hairline pt-5.5 text-[12.5px] text-muted">
<span>MIT · © 2026 Michel de Graaf</span>
<span className="text-success">No telemetry. Ever.</span>
<span className="ml-auto">{VERSION}</span>
</div>
</Wrap>
</footer>
)
+63
View File
@@ -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 = () => (
<header className="sticky top-0 z-50 border-b border-hairline bg-[color-mix(in_srgb,var(--bg-base)_88%,transparent)] backdrop-blur-[8px]">
<div className="mx-auto flex max-w-[1140px] flex-wrap items-center gap-x-4 gap-y-3 px-[18px] pt-2.5 sm:h-[60px] sm:flex-nowrap sm:gap-6 sm:px-7 sm:pt-0">
<NavLink to="/" aria-label="soulseek-rs home" className="mr-auto flex items-center">
<Wordmark size={19} blink />
</NavLink>
<nav
aria-label="Main"
className="order-3 flex w-full items-center gap-0.5 overflow-x-auto border-t border-hairline py-1 [-ms-overflow-style:none] [scrollbar-width:none] sm:order-none sm:w-auto sm:border-t-0 sm:py-0 [&::-webkit-scrollbar]:hidden"
>
{ITEMS.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) =>
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}
</NavLink>
))}
</nav>
<div className="flex items-center gap-2">
<a
href={LINKS.gh}
target="_blank"
rel="noopener noreferrer"
className="inline-flex h-[38px] items-center gap-1.5 rounded-md border border-hairline px-2.5 font-mono text-xs text-secondary hover:border-line hover:bg-[var(--bg-selection)] hover:text-primary sm:h-8"
>
GitHub <span aria-hidden="true">↗</span>
</a>
<ThemeToggle />
</div>
</div>
</header>
)
@@ -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 <ScrollRestoration>, 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 (
<>
<a
href="#main"
className="sr-only rounded-md border border-line bg-panel px-4 py-2 focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-100"
>
Skip to content
</a>
<Nav />
<main id="main" tabIndex={-1} className="outline-none">
<Outlet />
</main>
<Footer />
</>
)
}
@@ -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<Theme, Theme> = {
system: 'light',
light: 'dark',
dark: 'system',
}
const LABEL: Record<Theme, string> = {
system: 'system',
light: 'light',
dark: 'dark',
}
export const ThemeToggle = () => {
const { theme, resolvedTheme, setTheme } = useTheme()
return (
<button
type="button"
onClick={() => {
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"
>
<span aria-hidden="true">{resolvedTheme === 'light' ? '☀' : '☾'}</span>
{theme === 'system' && (
<span aria-hidden="true" className="text-[10px] opacity-70">
auto
</span>
)}
</button>
)
}
+135
View File
@@ -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) => (
<div
className={cn(
'relative border bg-base',
active ? 'border-[var(--color-phosphor)]' : 'border-[color-mix(in_srgb,var(--color-dust)_45%,transparent)]',
className,
)}
>
<span
className={cn(
'absolute -top-[9px] left-3 bg-base px-1.5 text-[12.5px] whitespace-nowrap',
active ? 'text-[var(--color-phosphor)]' : 'text-[var(--color-dust)]',
)}
>
{num !== undefined && <span className="text-[var(--color-signal)]">[{num}] </span>}
{title}
</span>
<div className={bodyClassName}>{children}</div>
</div>
)
/** The macOS window bezel the whole demo sits in. */
export const MacWindow = ({ children }: { children: React.ReactNode }) => (
<div
className="w-[1360px] overflow-hidden rounded-xl border bg-base font-mono tabular-nums"
style={{ borderColor: 'var(--tui-bezel)' }}
>
{children}
</div>
)
export const TmuxBar = () => (
<div className="flex h-[26px] items-center border-b border-[color-mix(in_srgb,var(--color-dust)_25%,transparent)] bg-[var(--tmux-bg)] text-[12.5px] text-[var(--color-phosphor)]">
<span className="flex h-full items-center bg-[var(--color-phosphor)] px-2.5 font-bold text-[var(--tmux-bg)]">
{SESSION}
</span>
{WINDOWS.map((w) => (
<span
key={w.n}
className={cn(
'flex h-full items-center gap-1.5 px-3',
w.active === true
? 'bg-[var(--tmux-hl)] font-bold text-[var(--tmux-hl-fg)]'
: 'text-[var(--color-phosphor)]',
)}
>
{w.n} <span className="opacity-55">›</span> {w.name}
</span>
))}
<span className="ml-auto flex gap-4 px-3 text-[var(--color-paper)]">
<span className="bg-[var(--tmux-hl)] px-2 font-semibold text-[var(--tmux-hl-fg)]">
{DATE} · {CLOCK}
</span>
<span className="font-bold">{HOST}</span>
</span>
</div>
)
export interface StatusCounts {
active: number
done: number
fail: number
queued: number
}
interface StatusLineProps {
counts: StatusCounts
pct: number
}
export const StatusLine = ({ counts, pct }: StatusLineProps) => (
<TuiPane title="Status" bodyClassName="px-3 py-2">
<div className="flex items-center gap-3.5 text-[13px] whitespace-nowrap text-[var(--color-paper)]">
<span>
<b className="font-semibold">soulseek-rs</b> 🦀 {VERSION}
</span>
<span className="text-[var(--color-dust)]">
Downloads: <span className="text-[var(--color-oxide)]">{counts.active} active</span>
, <span className="text-[var(--color-phosphor)]">{counts.done} completed</span>,{' '}
<span className="text-[var(--color-alarm)]">{counts.fail} failed</span>,{' '}
<span className="text-[var(--color-tape)]">{counts.queued} queued</span>, 0 paused
</span>
<span className="ml-auto flex items-center gap-2.5 text-[var(--color-paper)]">
67.7/205.2 MB · 33.38 MB/s
<span className="relative inline-block h-[13px] w-[340px] border border-[var(--tm-bd)] bg-[var(--tui-track)]">
<span
className="absolute inset-y-0 left-0 bg-[var(--color-signal)] opacity-55 transition-[width] duration-300"
style={{ width: `${String(pct)}%` }}
/>
</span>
<b>{pct}%</b>
</span>
</div>
</TuiPane>
)
export const ShortcutBar = ({ share }: { share: string }) => (
<TuiPane title="Shortcuts · Sharing" className="mt-2.5" bodyClassName="px-3 py-2">
<div className="mb-1.5 text-xs text-[var(--color-dust)]">
Sharing: <span className="text-[var(--color-phosphor)]">{share}</span>
</div>
<div className="flex flex-wrap gap-3.5 text-[12.5px] text-[var(--color-dust)]">
{SHORTCUTS.map(([key, action]) => (
<span key={key}>
[<span className="text-[var(--color-signal)]">{key}</span> → {action}]
</span>
))}
</div>
</TuiPane>
)
+216
View File
@@ -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<Record<string, readonly ResultRow[]>> = {
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'],
]
@@ -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<readonly SearchRow[]>(SEARCHES)
const [results, setResults] =
useState<Readonly<Record<string, readonly ResultRow[]>>>(RESULTS)
const [activeId, setActiveId] = useState('s1')
const [transfers, setTransfers] = useState<readonly TransferRow[]>(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<Phase>(() =>
prefersReducedMotion() ? 'interactive' : 'searching',
)
const [selected, setSelected] = useState(() =>
prefersReducedMotion() ? RESTING_ROW : 0,
)
const shellRef = useRef<HTMLDivElement>(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<StatusCounts>(() => {
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<HTMLDivElement>) => {
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<typeof setTimeout>[] = []
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 (
<div
ref={wrapRef}
className="w-full overflow-x-auto overflow-y-hidden"
style={ready ? { height } : undefined}
>
<div style={ready ? { width: DESIGN_WIDTH * scale, height } : undefined}>
<div
ref={innerRef}
className="origin-top-left"
style={{
width: DESIGN_WIDTH,
transform: ready ? `scale(${String(scale)})` : undefined,
visibility: ready ? 'visible' : 'hidden',
}}
>
<MacWindow>
<TmuxBar />
{/*
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.
*/}
<div
ref={shellRef}
tabIndex={0}
role="application"
aria-label="Interactive soulseek-rs terminal demo"
onKeyDown={onKeyDown}
className="tui-content p-2.5 outline-none"
>
<StatusLine counts={counts} pct={pct} />
<div className="mt-3 grid gap-2.5" style={{ gridTemplateColumns: '300px 1fr' }}>
<div className="min-h-[498px]">
<SearchesPane
searches={shownSearches}
activeId={activeId}
active={focus === 1}
onPick={(id) => {
setActiveId(id)
setSelected(0)
setFocus(1)
setPhase('interactive')
}}
onSubmit={submitSearch}
/>
</div>
<div className="grid min-h-0 gap-2.5" style={{ gridTemplateRows: '1fr 300px' }}>
<div className="relative min-h-[250px] overflow-hidden">
<ResultsPane
query={activeSearch?.query ?? ''}
rows={shownRows}
selected={selected}
active={focus === 2}
onSelect={(i) => {
setSelected(i)
setFocus(2)
}}
onQueue={(i) => {
queueIndexes([i])
setFocus(3)
}}
/>
{phase === 'searching' && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-[color-mix(in_srgb,var(--color-sleeve)_35%,transparent)] text-[13.5px] tracking-[0.02em] text-[var(--color-dust)]">
searching the network
<span className="ml-1.5 inline-block h-[15px] w-2 translate-y-[2px] rounded-[1.5px] bg-[var(--color-oxide)] motion-safe:animate-cursor-blink" />
</div>
)}
</div>
<div
className="grid min-h-0 gap-2.5"
style={{ gridTemplateColumns: '1fr 340px' }}
>
<div className="min-h-0 min-w-0 overflow-hidden">
<TransfersPane rows={transfers.slice(0, 7)} active={focus === 3} />
</div>
<div className="min-h-0 min-w-0 overflow-hidden">
<InfoPane row={phase === 'searching' ? undefined : rows[selected]} />
</div>
</div>
</div>
</div>
<ShortcutBar share={SHARE} />
</div>
</MacWindow>
</div>
</div>
</div>
)
}
+286
View File
@@ -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<HTMLFormElement, SubmitEvent>) => {
event.preventDefault()
const trimmed = query.trim()
if (trimmed === '') return
onSubmit(trimmed)
setQuery('')
}
return (
<TuiPane
title="Searches"
num={1}
active={active}
className="h-full"
bodyClassName="px-3 pt-4 pb-3"
>
<form
onSubmit={submit}
className="mb-3 flex items-center gap-1.5 border-b border-[color-mix(in_srgb,var(--color-dust)_25%,transparent)] pb-2.5"
>
<span className="text-[13px] text-[var(--color-oxide)]">/</span>
<input
value={query}
onChange={(event) => {
setQuery(event.target.value)
}}
placeholder="search the network…"
aria-label="Search the network"
className="grow border-0 bg-transparent font-mono text-[13px] text-[var(--color-paper)] outline-none placeholder:text-[var(--color-dust)]"
/>
<span className="h-[15px] w-2 rounded-[1.5px] bg-[var(--color-oxide)] motion-safe:animate-cursor-blink" />
</form>
<div
className="grid px-1 pb-1.5 text-xs text-[var(--color-dust)]"
style={{ gridTemplateColumns: '56px 1fr 58px' }}
>
<span>Status</span>
<span>Query</span>
<span className="text-right">Results</span>
</div>
{searches.map((search) => (
<button
type="button"
key={search.id}
onClick={() => {
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' }}
>
<span className="text-[var(--color-phosphor)]">
{search.id === activeId ? '→' : ' '}
{search.status}
</span>
<span className="overflow-hidden text-ellipsis whitespace-nowrap">
{search.query}
</span>
<span className="text-right text-[var(--color-dust)]">{search.results}</span>
</button>
))}
</TuiPane>
)
}
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) => (
<TuiPane
title={`Results: ${query}`}
num={2}
active={active}
className="flex h-full flex-col"
bodyClassName="flex min-h-0 flex-1 flex-col pt-4"
>
<div
className="grid gap-2 border-b border-[color-mix(in_srgb,var(--color-dust)_25%,transparent)] px-3.5 pb-2 text-xs text-[var(--color-dust)]"
style={{ gridTemplateColumns: RESULT_COLS }}
>
<span>✓</span>
<span>Filename</span>
<span>Size</span>
<span>User</span>
<span>Bitrate</span>
<span>Speed</span>
<span>Slots</span>
</div>
<div className="flex-1 overflow-y-auto">
{rows.map((row, i) => {
const { glyph, tone } = resultGlyph(row)
const isSelected = selected === i
return (
<button
type="button"
key={row.name}
onClick={() => {
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 }}
>
<span className={cn('whitespace-nowrap', tone)}>{glyph}</span>
<span
className={cn(
'overflow-hidden text-ellipsis whitespace-nowrap',
isSelected ? 'text-[var(--color-paper)]' : 'text-[var(--color-paper-dim)]',
)}
>
{row.name}
</span>
<span className="text-right text-[var(--color-tape)]">{row.size}</span>
<span className="text-[var(--color-signal)]">{row.user}</span>
<span className="text-[var(--color-dust)]">{row.bitrate}</span>
<span className="text-[var(--color-dust)]">{row.speed}</span>
<span className="text-center text-[var(--color-dust)]">{row.slots}</span>
</button>
)
})}
</div>
</TuiPane>
)
const TRANSFER_GLYPH: Record<TransferStatus, readonly [string, string]> = {
queued: ['⋯', 'text-[var(--color-tape)]'],
done: ['✓', 'text-[var(--color-phosphor)]'],
active: ['↯', 'text-[var(--color-oxide)]'],
fail: ['✗', 'text-[var(--color-alarm)]'],
}
const PROGRESS_TONE: Record<TransferStatus, string> = {
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) => (
<TuiPane
title="Downloads/Uploads"
num={3}
active={active}
className="flex h-full flex-col"
bodyClassName="flex min-h-0 flex-1 flex-col pt-4"
>
<div
className="grid gap-2 border-b border-[color-mix(in_srgb,var(--color-dust)_25%,transparent)] px-3.5 pb-2 text-xs text-[var(--color-dust)]"
style={{ gridTemplateColumns: TRANSFER_COLS }}
>
<span>Status</span>
<span>Filename</span>
<span>User</span>
<span>Progress</span>
<span>Speed</span>
</div>
<div className="flex-1 overflow-y-auto">
{rows.map((row) => {
const [glyph, tone] = TRANSFER_GLYPH[row.status]
return (
<div
key={row.id}
className="grid gap-2 px-3.5 py-[3px] text-[12.5px] text-[var(--color-paper-dim)]"
style={{ gridTemplateColumns: TRANSFER_COLS }}
>
<span className={tone}>{glyph}</span>
<span className="overflow-hidden text-ellipsis whitespace-nowrap">
{row.name}
</span>
<span className="text-[var(--color-signal)]">{row.user}</span>
<span className={PROGRESS_TONE[row.status]}>{row.progress}</span>
<span className="text-[var(--color-tape)]">{row.speed}</span>
</div>
)
})}
</div>
</TuiPane>
)
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 (
<TuiPane title="Info" active className="h-full" bodyClassName="px-3.5 pt-4 pb-3.5">
<div className="mb-1 text-[13.5px] break-all text-[var(--color-oxide)]">{file}</div>
<div className="mb-4 text-xs break-all text-[var(--color-dust)]">
{path === '' ? '—' : path}
</div>
<div
className="grid gap-y-[7px] text-[12.5px]"
style={{ gridTemplateColumns: '88px 1fr' }}
>
{fields.map(([key, value, tone]) => (
<div key={key} className="contents">
<span className="text-[var(--color-dust)]">{key}</span>
<span className={cn('break-all', tone)}>{value}</span>
</div>
))}
<span className="mt-2.5 text-[var(--color-dust)]">Queue position</span>
<span className="mt-2.5 text-[var(--color-paper)]">{queued ? '3' : 'unknown'}</span>
<span className="text-[var(--color-dust)]">Free slots</span>
<span className="text-[var(--color-phosphor)]">
{row !== undefined && row.slots > 0 ? `${String(row.slots)} available` : '0'}
</span>
</div>
</TuiPane>
)
}
@@ -0,0 +1,64 @@
import { useEffect, useRef, useState, type RefObject } from 'react'
interface FitScale {
wrapRef: RefObject<HTMLDivElement | null>
innerRef: RefObject<HTMLDivElement | null>
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<HTMLDivElement>(null)
const innerRef = useRef<HTMLDivElement>(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 }
}
+57
View File
@@ -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<typeof buttonVariants>
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 `<button>`. 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 (
<a href={href} target="_blank" rel="noopener noreferrer" className={classes}>
{children}
</a>
)
return (
<Link to={href} className={classes}>
{children}
</Link>
)
}
@@ -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 (
<button
type="button"
title="Copy command"
onClick={() => {
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,
)}
>
<span aria-hidden="true" className="text-accent">
$
</span>
<code className="font-mono">{text}</code>
<span
aria-hidden="true"
className={cn(
'border-l border-hairline pl-2.5 text-[11px]',
copied ? 'text-success' : 'text-secondary',
)}
>
{copied ? '✓ copied' : '⧉ copy'}
</span>
<span className="sr-only" aria-live="polite">
{copied ? 'Copied to clipboard' : ''}
</span>
</button>
)
}
+32
View File
@@ -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 (
<button
type="button"
onClick={() => {
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,
)}
>
<span aria-hidden="true">{copied ? '✓' : '⧉'}</span>
{copied ? 'copied' : 'copy'}
</button>
)
}
@@ -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 (
<button
type="button"
title="Copy command"
onClick={() => {
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)]',
)}
>
<span aria-hidden="true" className="shrink-0 text-[var(--tm-pr)]">
$
</span>
<code className="grow font-mono break-words whitespace-pre-wrap">{text}</code>
<span
aria-hidden="true"
className={cn('shrink-0', copied ? 'text-[var(--tm-ok)]' : 'text-[var(--tm-dim)]')}
>
{copied ? '✓' : '⧉'}
</span>
</button>
)
}
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) => (
<div
className={cn(
'flex flex-col gap-2.5 rounded-md border border-hairline bg-panel p-[18px] transition-[border-color,background] duration-[120ms] hover:border-line hover:bg-raised sm:p-[22px]',
className,
)}
>
<div className="flex items-center gap-3">
{glyph !== undefined && (
<div
aria-hidden="true"
className="flex size-[34px] shrink-0 items-center justify-center rounded-md border border-hairline text-xl leading-none"
style={color !== undefined ? { color } : undefined}
>
{glyph}
</div>
)}
<div className="text-[15px] font-medium text-primary">{title}</div>
</div>
<div className="text-[13px] leading-[21px] text-pretty text-secondary [&_code]:font-mono [&_code]:text-primary">
{children}
</div>
{cmd !== undefined && <FeatureCommand text={cmd} />}
</div>
)
+18
View File
@@ -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) => (
<code
className={cn(
'whitespace-nowrap rounded-md border border-hairline bg-raised px-1.5 py-px font-mono text-[0.92em] text-primary',
className,
)}
>
{children}
</code>
)
+136
View File
@@ -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) => (
<div className={cn('mx-auto w-full max-w-[1140px] px-[18px] sm:px-7', className)}>
{children}
</div>
)
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) => (
<section
id={id}
className={cn(
'py-6 pb-10 sm:pt-[30px] sm:pb-13',
band
? 'border-y border-hairline bg-panel'
: !flush && 'border-t border-hairline',
className,
)}
>
<Wrap>{children}</Wrap>
</section>
)
interface EyebrowProps {
children: React.ReactNode
className?: string
}
export const Eyebrow = ({ children, className }: EyebrowProps) => (
<span
className={cn(
'text-label uppercase tracking-[var(--tracking-label)] text-accent',
className,
)}
>
{children}
</span>
)
interface SectionHeadProps {
eyebrow?: string
title: React.ReactNode
children?: React.ReactNode
className?: string
}
export const SectionHead = ({
eyebrow,
title,
children,
className,
}: SectionHeadProps) => (
<div className={cn('mb-11 flex max-w-[660px] flex-col gap-3.5', className)}>
{eyebrow !== undefined && <Eyebrow>{eyebrow}</Eyebrow>}
<h2 className="text-[26px] leading-[34px] font-medium tracking-[-0.005em] text-balance sm:text-title sm:leading-[var(--text-title--line-height)]">
{title}
</h2>
{children !== undefined && (
<p className="text-base leading-[26px] text-pretty text-secondary">{children}</p>
)}
</div>
)
interface PageHeadProps {
eyebrow: string
title: string
children: React.ReactNode
}
/** The h1 block every subpage opens with. */
export const PageHead = ({ eyebrow, title, children }: PageHeadProps) => (
<Section flush className="pt-10">
<div className="flex max-w-[660px] flex-col gap-3.5">
<Eyebrow>{eyebrow}</Eyebrow>
<h1 className="text-[26px] leading-[34px] font-medium tracking-[-0.005em] text-balance sm:text-title sm:leading-[var(--text-title--line-height)]">
{title}
</h1>
<p className="text-base leading-[26px] text-pretty text-secondary">{children}</p>
</div>
</Section>
)
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) => (
<div
className={cn(
'grid grid-cols-1 gap-7 md:grid-cols-2 md:gap-10',
start === true && 'items-start',
center === true && 'items-center',
className,
)}
>
{children}
</div>
)
interface ProseProps {
children: React.ReactNode
className?: string
}
/** Body copy: secondary colour, generous leading, `+p` spacing. */
export const Prose = ({ children, className }: ProseProps) => (
<div
className={cn(
'[&>p+p]:mt-4 [&>p]:leading-[26px] [&>p]:text-pretty [&>p]:text-secondary',
className,
)}
>
{children}
</div>
)
+22
View File
@@ -0,0 +1,22 @@
export type OsName = 'macOS' | 'Linux' | 'Windows'
const PATHS: Record<Exclude<OsName, 'Windows'>, 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 }) => (
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className="block size-[22px] shrink-0 text-primary"
>
<path fill="currentColor" d={name === 'Windows' ? WINDOWS_PATH : PATHS[name]} />
</svg>
)
+62
View File
@@ -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) => (
<div
className={cn(
'relative rounded-md border border-hairline bg-panel px-[18px] py-[22px] sm:px-6 sm:py-[26px]',
className,
)}
>
<span className="absolute -top-2.5 left-4 bg-base px-2 text-label uppercase tracking-[var(--tracking-label)] text-secondary">
{/* --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 && <span className="text-info">[{num}] </span>}
{title}
</span>
{children}
</div>
)
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) => (
<div
className={cn(
'rounded-md border border-hairline border-l-[3px] bg-panel px-[18px] py-5 sm:px-[22px]',
tone === 'warn' ? 'border-l-warning' : 'border-l-accent',
'[&>p]:leading-[25px] [&>p]:text-secondary',
className,
)}
>
{title !== undefined && (
<div
className={cn(
'mb-2 text-label uppercase tracking-[var(--tracking-label)]',
tone === 'warn' ? 'text-warning' : 'text-accent',
)}
>
{title}
</div>
)}
{children}
</div>
)
+71
View File
@@ -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<TermLineKind, string> = {
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 (
<div
className={cn(
'overflow-hidden rounded-md border border-[var(--tm-bd)] bg-[var(--tm-bg)] font-mono tabular-nums',
className,
)}
>
<div className="flex items-center justify-between border-b border-[var(--tm-bd2)] bg-[var(--tm-hd)] px-3 py-2">
<span className="text-label uppercase tracking-[var(--tracking-label)] text-[var(--tm-dim)]">
{label ?? 'terminal'}
</span>
<CopyButton text={copyText} />
</div>
<div className="overflow-x-auto px-4 py-4 text-[12.5px] leading-[1.75] text-[var(--tm-fg)] sm:text-[13.5px]">
{lines.map((line, i) => (
<span
// Lines are static content, never reordered — index is stable here.
key={i}
className={cn('block whitespace-pre', LINE_TONE[line.t])}
>
{line.t === 'cmd' && (
<>
<span className="text-[var(--tm-pr)]">$</span>{' '}
</>
)}
{line.text}
</span>
))}
</div>
</div>
)
}
+23
View File
@@ -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(
<StrictMode>
<ThemeProvider>
<StaticRouter location={location}>
<App />
</StaticRouter>
</ThemeProvider>
</StrictMode>,
)
+40
View File
@@ -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<ReturnType<typeof setTimeout> | 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 }
}
+23
View File
@@ -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])
}
+285
View File
@@ -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 <html>, 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 <img> 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;
}
}
}
+22
View File
@@ -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/'
+61
View File
@@ -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
}
+4
View File
@@ -0,0 +1,4 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export const cn = (...inputs: ClassValue[]): string => twMerge(clsx(inputs))
+31
View File
@@ -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 = (
<StrictMode>
<ThemeProvider>
{/* Must match `base` in vite.config.ts — the site is served from a
subpath, so the router has to strip it before matching. */}
<BrowserRouter basename={import.meta.env.BASE_URL}>
<App />
</BrowserRouter>
</ThemeProvider>
</StrictMode>
)
// 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)
}
+264
View File
@@ -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&rsquo;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 <code>--shared-dir</code> 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&rsquo;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 <code>portmap</code> 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&rsquo;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 (
<>
<PageHead
eyebrow="community"
title="Help out, or go where you're better served."
>
It&rsquo;s mostly one person who&rsquo;s been on Soulseek since the early 2000s.
Issues and pull requests are welcome, and if another client fits you better, use it.
</PageHead>
<Section>
<SectionHead eyebrow="contributing" title="How to help.">
File an issue, open a pull request, or just say what&rsquo;s missing. No
contributor agreement, no template gymnastics.
</SectionHead>
<Cols start>
<Prose>
<p>
The project is a Cargo workspace: <Code>soulseek-rs-lib</Code> (the protocol)
and <Code>soulseek-rs</Code> (the client). The library stays lean on
dependencies; the client takes them freely.
</p>
<p>
Format and Clippy run clean on every push, and unit tests plus an end-to-end
suite against{' '}
<a
href={LINKS.soulfind}
target="_blank"
rel="noopener noreferrer"
className="text-link hover:text-link-hover"
>
soulfind
</a>{' '}
run in CI, so a green checkmark means it genuinely built and talked to a
server.
</p>
<p>
That server is{' '}
<a
href={LINKS.soulfind}
target="_blank"
rel="noopener noreferrer"
className="text-link hover:text-link-hover"
>
soulfind-dev/soulfind
</a>
, an open-source Soulseek server. Run it locally and you can develop and test
against a real protocol implementation without touching the public network.
</p>
<div className="mt-1 flex flex-wrap gap-2 sm:gap-3">
<Button href={LINKS.issues} variant="accent">
Open an issue ↗
</Button>
<Button href={LINKS.gh}>GitHub ↗</Button>
</div>
</Prose>
<Terminal
label="develop"
lines={[
{ t: 'cmd', text: 'RUST_LOG=trace cargo run' },
{ t: 'cm', text: '# run with debug + trace output' },
{ t: 'cmd', text: 'cargo test' },
{ t: 'cmd', text: 'cargo clippy' },
{ t: 'cmd', text: 'cargo fmt' },
]}
/>
</Cols>
</Section>
<Section>
<SectionHead
eyebrow="being a good member"
title="The network is other people's hard drives."
>
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.
</SectionHead>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{CITIZENSHIP.map((item) => (
<FeatureCard
key={item.title}
glyph={item.glyph}
color={item.color}
title={item.title}
>
{item.body}
</FeatureCard>
))}
</div>
</Section>
<Section>
<SectionHead
eyebrow="what this is not"
title="Three good clients to point you elsewhere."
>
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.
</SectionHead>
<div className="grid grid-cols-1 gap-3.5">
{ALTERNATIVES.map((alt) => (
<div
key={alt.question}
className="grid grid-cols-1 items-center gap-4 rounded-md border border-hairline bg-panel p-[18px] sm:grid-cols-[1fr_auto] sm:gap-x-6 sm:p-[22px] lg:grid-cols-1"
>
<div>
<div className="mb-1.5 text-[15px] font-medium text-primary">
{alt.question}
</div>
<div className="text-[13.5px] leading-[22px] text-secondary">
{alt.answer}
</div>
</div>
<div className="flex flex-wrap gap-2 sm:gap-3">
{alt.links.map((link) => (
<Button key={link.label} href={link.href}>
{link.label} ↗
</Button>
))}
</div>
</div>
))}
</div>
</Section>
<Section band>
<Cols center className="gap-8">
<div className="flex flex-col gap-3.5">
<Eyebrow>license &amp; promises</Eyebrow>
<h2 className="text-[26px] leading-[34px] font-medium sm:text-title sm:leading-[var(--text-title--line-height)]">
MIT, and no strings.
</h2>
<p className="text-secondary">
Free to use, copy, modify, and distribute. Copyright © 2026 Michel de Graaf.
</p>
<div className="mt-1 flex flex-wrap gap-2 sm:gap-3">
<Button href={LINKS.license} variant="accent">
Read the license ↗
</Button>
</div>
</div>
<Callout title="two things that stay true">
<p className="mb-1.5 !text-success">No telemetry. Ever.</p>
<p className="font-forum text-xs !text-muted">
Not affiliated with or endorsed by the Soulseek project.
</p>
</Callout>
</Cols>
</Section>
</>
)
}
+219
View File
@@ -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 }) => (
<div className="grid grid-cols-[120px_1fr] items-baseline gap-3 border-b border-hairline py-[7px]">
<span>
<span className="inline-block min-w-5 rounded-md border border-line border-b-2 px-[7px] text-center text-xs text-primary">
{combo}
</span>
</span>
<span className="text-[13px] text-secondary">{description}</span>
</div>
)
export const Docs = () => {
return (
<>
<PageHead
eyebrow="docs · quick start"
title="Install it, run it, drive it from the keyboard."
>
This is the short version. The full reference (every flag, every message) lives in
the README and on docs.rs.
</PageHead>
<Section>
<SectionHead eyebrow="1 · first run" title="Two commands to a running client.">
You need a Rust toolchain. After <Code>cargo install</Code>, the binary is on your
PATH.
</SectionHead>
<Cols start>
<Terminal
lines={[
{ t: 'cmd', text: 'cargo install soulseek-rs' },
{ t: 'cmd', text: 'soulseek-rs' },
{ t: 'cm', text: '# the TUI opens; log in or register on first run' },
]}
/>
<Prose>
<p>
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.
</p>
<p>
Downloads land in a conventional folder by default, and your TUI state
(searches, downloads, rooms) is saved and restored across restarts.
</p>
</Prose>
</Cols>
</Section>
<Section>
<SectionHead eyebrow="2 · the layout" title="Four panes.">
The whole client is these four boxes. Press <Code>1</Code>–<Code>3</Code> to focus
one; the focused pane&rsquo;s legend turns green.
</SectionHead>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{PANES.map((pane) => (
<Panel key={pane.title} num={pane.num} title={pane.title}>
<p className="text-[13px] leading-[22px] text-secondary">{pane.body}</p>
</Panel>
))}
</div>
</Section>
<Section>
<SectionHead eyebrow="3 · keys" title="It's all keyboard.">
The bottom bar always shows the keys for where you are. The essentials:
</SectionHead>
<Cols>
<div>
<Eyebrow className="mb-4 block">anywhere</Eyebrow>
<div className="flex flex-col">
{GLOBAL_KEYS.map(([combo, description]) => (
<KeyRow key={combo} combo={combo} description={description} />
))}
</div>
</div>
<div>
<Eyebrow className="mb-4 block">
in the chat-rooms popup <span className="text-muted">(press c)</span>
</Eyebrow>
<div className="flex flex-col">
{ROOM_KEYS.map(([combo, description]) => (
<KeyRow key={combo} combo={combo} description={description} />
))}
</div>
</div>
</Cols>
</Section>
<Section>
<SectionHead
eyebrow="4 · from the shell"
title="Every command also runs as a one-off."
>
Skip the TUI when you&rsquo;re scripting or mid-task.
</SectionHead>
<Cols start>
<Terminal
label="shell"
lines={[
{ t: 'cmd', text: 'soulseek-rs "public domain field recordings"' },
{ t: 'cm', text: '# open the TUI straight onto a search' },
{ t: 'cmd', text: 'soulseek-rs message <username> "hello there"' },
{ t: 'cmd', text: 'soulseek-rs rooms' },
{ t: 'cm', text: '# list public rooms, busiest first' },
{ t: 'cmd', text: 'soulseek-rs chat <room> "hello room"' },
{ t: 'cmd', text: 'soulseek-rs portmap' },
]}
/>
<Prose>
<p>
The subcommands mirror the TUI: <Code>search</Code>, <Code>message</Code>,{' '}
<Code>browse</Code>, <Code>rooms</Code>, <Code>chat</Code>, and{' '}
<Code>portmap</Code>. Pass a room and a message to <Code>chat</Code> to say
one thing and exit.
</p>
<p>
<Code>portmap</Code> tests whether your router will let peers connect back,
worth running once.{' '}
<Link to="/install" className="text-link hover:text-link-hover">
More on being reachable →
</Link>
</p>
</Prose>
</Cols>
<div className="mt-6">
<Callout tone="warn" title="the one limit worth knowing up front">
<p>
If both you and a peer are behind routers with no forwarded port, browsing
that peer can&rsquo;t work, that&rsquo;s a fundamental Soulseek/peer-to-peer
limitation, not a bug.
</p>
</Callout>
</div>
</Section>
<Section band>
<Panel title="the rest">
<Cols center className="gap-6">
<Prose>
<p>
Deeper docs live where they stay current: the full README on GitHub, and the
generated API reference for the library on docs.rs.
</p>
</Prose>
<div className="flex flex-wrap gap-2 sm:gap-3">
<Button href={LINKS.gh} variant="accent">
README ↗
</Button>
<Button href={LINKS.docsrs}>docs.rs ↗</Button>
<Button href={LINKS.changelog}>Changelog ↗</Button>
</div>
</Cols>
</Panel>
</Section>
</>
)
}
+261
View File
@@ -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 <code>--shared-dir</code> 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 <user>',
body: <>List any user&rsquo;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 <user> "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&rsquo;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 <code>portmap</code>{' '}
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 (
<>
<section className="pt-5 pb-8 sm:pt-7 sm:pb-10">
<Wrap>
<div className="flex flex-col gap-[22px]">
<Eyebrow>Soulseek client · terminal · Rust</Eyebrow>
<div className="flex items-start gap-3.5 sm:gap-6 md:gap-11">
<Logo size={172} className="mt-2 shrink-0 max-md:!h-28 max-sm:!h-16" />
<h1 className="text-[29px] leading-[37px] font-bold tracking-[-0.01em] text-balance sm:text-[34px] sm:leading-[42px] md:text-display md:leading-[var(--text-display--line-height)]">
A Soulseek client for people who live in the terminal.
</h1>
</div>
<p className="text-lg leading-[29px] text-pretty text-secondary">
Search the network, share your files, browse someone&rsquo;s collection, join
a room. It runs over ssh on the machine where your music already lives, with
no window and no mouse.
</p>
<div className="flex flex-wrap items-center justify-between gap-x-5 gap-y-3 sm:gap-y-4">
<div className="flex flex-wrap items-center gap-2 max-sm:w-full sm:gap-3">
<Button href="/install" variant="accent">
Install
</Button>
<Button href="/docs">Read the docs</Button>
<Button href={LINKS.gh}>GitHub ↗</Button>
</div>
<CommandPill text="cargo install soulseek-rs" />
</div>
</div>
<div className="mt-3.5 overflow-hidden">
<HeroTerminal />
</div>
</Wrap>
</section>
<section className="border-t border-hairline py-6">
<Wrap>
<div className="flex flex-wrap items-center gap-3.5 sm:gap-8">
<Eyebrow>Runs on</Eyebrow>
<ul className="flex w-full flex-wrap sm:w-auto">
{PLATFORMS.map((platform, i) => (
<li
key={platform.name}
className={
i === 0
? 'flex w-full sm:w-auto sm:pr-6.5'
: 'flex w-full border-hairline sm:w-auto sm:border-l sm:px-6.5'
}
>
<Link
to={`/install#${platform.anchor}`}
className="group flex w-full items-center gap-2.5 border-t border-hairline py-2.5 first:border-t-0 sm:w-auto sm:border-t-0 sm:py-0"
>
<OsIcon name={platform.name} />
<span className="flex flex-col leading-[1.35]">
<b className="text-[13.5px] font-medium text-primary transition-colors group-hover:text-accent">
{platform.name}
</b>
<span className="text-[11.5px] text-secondary">{platform.note}</span>
</span>
</Link>
</li>
))}
</ul>
</div>
</Wrap>
</section>
<Section id="about">
<SectionHead
eyebrow="what it is"
title="A client for a network that runs on people."
>
No charts, no recommendations. You search, you find the person who has it, you
download it from them.
</SectionHead>
<Cols>
<Prose>
<p>
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&rsquo;s
folder and nowhere else.
</p>
<p>
soulseek-rs speaks that protocol. It&rsquo;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.
</p>
</Prose>
<div className="flex flex-col gap-4">
<Callout title="build your own">
<p>
Reach for <Code>soulseek-rs-lib</Code> to build a custom client or an agent
on the protocol directly.{' '}
<Link to="/install" className="text-link hover:text-link-hover">
Use the library →
</Link>
</p>
</Callout>
<Callout title="share back">
<p>
Point <Code>--shared-dir</Code> 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.
</p>
</Callout>
</div>
</Cols>
</Section>
<Section id="features">
<SectionHead eyebrow="features" title="What it does.">
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.
</SectionHead>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{FEATURES.map((feature) => (
<FeatureCard
key={feature.title}
glyph={feature.glyph}
color={feature.color}
title={feature.title}
cmd={feature.cmd}
>
{feature.body}
</FeatureCard>
))}
</div>
</Section>
<Section band>
<Cols center>
<div className="flex flex-col gap-3.5">
<Eyebrow>get started</Eyebrow>
<h2 className="text-[26px] leading-[34px] font-medium text-balance sm:text-title sm:leading-[var(--text-title--line-height)]">
Install it, then press <span className="text-accent">/</span> to search.
</h2>
<p className="text-secondary">
Needs a Rust toolchain. From there it&rsquo;s one command, then the TUI opens.
</p>
<div className="mt-1.5 flex flex-wrap gap-2 sm:gap-3">
<Button href="/docs" variant="accent">
Read the docs
</Button>
<Button href="/install">Install options</Button>
</div>
</div>
<Terminal
label="quick start"
lines={[
{ t: 'cmd', text: 'cargo install soulseek-rs' },
{ t: 'cmd', text: 'soulseek-rs' },
]}
/>
</Cols>
</Section>
</>
)
}
+347
View File
@@ -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<dyn std::error::Error>> {
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) => (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[auto_1fr] sm:gap-5">
<div className="flex size-[34px] items-center justify-center rounded-md border border-line text-sm text-accent">
{n}
</div>
<div className="flex min-w-0 flex-col gap-3">
<h2 className="text-heading leading-[var(--text-heading--line-height)] font-medium">
{title}
</h2>
{children}
</div>
</div>
)
export const Install = () => {
return (
<>
<PageHead eyebrow="install" title="Three ways in.">
One command if you have Rust. Build from source if you&rsquo;d rather. Or depend on
the library and write your own client.
</PageHead>
<Section>
<div className="flex flex-col gap-9">
<Step n={1} title="Install the client">
<p className="text-secondary">
Needs a Rust toolchain, available from{' '}
<a
href={LINKS.rustup}
target="_blank"
rel="noopener noreferrer"
className="text-link hover:text-link-hover"
>
rustup.rs
</a>
. Then:
</p>
<Terminal
lines={[
{ t: 'cmd', text: 'cargo install soulseek-rs' },
{ t: 'cmd', text: 'soulseek-rs' },
]}
/>
<p className="text-[13px] text-muted">
Cargo puts the binary on your PATH. Run <Code>soulseek-rs</Code> and the TUI
opens.
</p>
<div className="flex flex-wrap gap-2 sm:gap-3">
<Button href={LINKS.releases}>Prebuilt binaries ↗</Button>
</div>
<p className="text-[13px] text-muted">
No Rust toolchain? Grab a prebuilt archive for your platform from the{' '}
<a
href={LINKS.releases}
target="_blank"
rel="noopener noreferrer"
className="text-link hover:text-link-hover"
>
releases page
</a>
, unpack it, and put the binary somewhere on your PATH.
</p>
</Step>
<Step n={2} title="Build from source">
<p className="text-secondary">
Clone the workspace and build a release binary.
</p>
<Terminal
lines={[
{ t: 'cmd', text: 'git clone https://github.com/michel/soulseek-rs.git' },
{ t: 'cmd', text: 'cd soulseek-rs' },
{ t: 'cmd', text: 'cargo build --release' },
]}
/>
<p className="text-[13px] text-muted">
You&rsquo;ll find the binary at <Code>target/release/soulseek-rs</Code>.
Prebuilt archives for tagged releases are on the{' '}
<a
href={LINKS.releases}
target="_blank"
rel="noopener noreferrer"
className="text-link hover:text-link-hover"
>
releases page
</a>
.
</p>
</Step>
<Step n={3} title="Build on the library">
<p className="text-secondary">
Write your own client or bot on <Code>soulseek-rs-lib</Code>: the protocol
implementation, separate from the TUI.
</p>
<Terminal
label="Cargo.toml"
lines={[
{ t: 'cm', text: '[dependencies]' },
{ t: 'code', text: 'soulseek-rs-lib = "6.0.0"' },
]}
copy={'[dependencies]\nsoulseek-rs-lib = "6.0.0"'}
/>
<Terminal label="src/main.rs" lines={LIB_LINES} copy={LIB_SRC} />
<p className="text-[13px] text-muted">
Full API reference on{' '}
<a
href={LINKS.docsrs}
target="_blank"
rel="noopener noreferrer"
className="text-link hover:text-link-hover"
>
docs.rs
</a>
. Note: v6 moved sharing from one directory to a list. See the{' '}
<a
href={LINKS.changelog}
target="_blank"
rel="noopener noreferrer"
className="text-link hover:text-link-hover"
>
changelog
</a>{' '}
if you&rsquo;re upgrading from 5.x.
</p>
</Step>
</div>
</Section>
<Section>
<SectionHead eyebrow="being reachable" title="Let peers connect back.">
Browsing and downloading are peer-to-peer, so at least one side has to accept an
incoming connection.
</SectionHead>
<Cols start>
<Prose>
<p>
With the listener on (the default), soulseek-rs tries to open its listen port
on your router via{' '}
<b className="font-medium text-primary">UPnP-IGD</b> and{' '}
<b className="font-medium text-primary">NAT-PMP</b>. It&rsquo;s best-effort:
if your router has those disabled it&rsquo;s a no-op, and you forward the port
yourself.
</p>
<p>
The port is your <Code>--listener-port</Code> (env <Code>LISTENER_PORT</Code>,
default <Code>2234</Code>); it&rsquo;s renewed automatically and removed on
exit. Pass <Code>--disable-listener</Code> to turn it off. Check your own
network without launching the client:
</p>
<div className="mt-4">
<Terminal lines={[{ t: 'cmd', text: 'soulseek-rs portmap' }]} />
</div>
</Prose>
<Callout tone="warn" title="honest about limits">
<p>
If both you and a peer are behind routers with no forwarded port, browsing
that peer can&rsquo;t work, that&rsquo;s a fundamental Soulseek/peer-to-peer
limitation, not a bug. Forward <Code>--listener-port</Code>, or let{' '}
<Code>portmap</Code> try UPnP.
</p>
</Callout>
</Cols>
</Section>
<Section id="platforms">
<SectionHead eyebrow="platform notes" title="Where things live.">
Sensible defaults per OS, all overridable. Set <Code>SOULSEEK_CONFIG_DIR</Code> or{' '}
<Code>SOULSEEK_STATE_DIR</Code> to relocate either one, which also makes a
portable install possible.
</SectionHead>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
{PLATFORMS.map((platform) => (
<div
key={platform.id}
id={platform.id}
className="scroll-mt-20 rounded-md border border-hairline bg-panel p-[18px] sm:p-[22px]"
>
<h3 className="mb-4 text-heading leading-[var(--text-heading--line-height)] font-medium">
{platform.name}
</h3>
<dl className="grid grid-cols-1 gap-x-3.5 gap-y-0.5 sm:grid-cols-[auto_1fr] sm:gap-y-2">
{(
[
['Password', platform.credentialStore, false],
['Config', platform.config, true],
['State', platform.state, true],
['Downloads', platform.downloads, true],
] as const
).map(([label, value, mono]) => (
<div key={label} className="contents">
<dt className="pt-2 text-[11px] uppercase tracking-[var(--tracking-label)] text-secondary sm:pt-0.5">
{label}
</dt>
<dd className="min-w-0 text-[12.5px] leading-[19px] text-primary">
{mono ? (
<code className="font-mono text-xs break-all">{value}</code>
) : (
value
)}
</dd>
</div>
))}
</dl>
<p className="mt-4 border-t border-hairline pt-3.5 text-[12.5px] leading-5 text-secondary">
{platform.note}
</p>
</div>
))}
</div>
<Cols start className="mt-7">
<FeatureCard title="Secrets">
Your password goes to the OS keychain, never a plain-text file. If you would
rather it never be stored at all, have <code>password_cmd</code> shell out to
your own password manager and print it on demand.
</FeatureCard>
<FeatureCard title="Settings and state">
<code>config.toml</code> is layered: flags beat environment variables, which
beat the file, which beats the defaults. State (searches, downloads, rooms) is
versioned JSON and restores on restart; anything unreadable is set aside as{' '}
<code>.bak</code> instead of being overwritten.
</FeatureCard>
</Cols>
</Section>
<Section band>
<SectionHead eyebrow="uninstall" title="Removing it completely.">
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.
</SectionHead>
<Cols start>
<Terminal
label="macOS · Linux"
lines={[
{ t: 'cmd', text: 'cargo uninstall soulseek-rs' },
{ t: 'cm', text: '# config, state and any cached shares' },
{
t: 'cmd',
text: 'rm -rf ~/.config/soulseek-rs ~/.local/share/soulseek-rs',
},
{ t: 'cm', text: '# Linux: drop the saved password' },
{ t: 'cmd', text: 'secret-tool clear service soulseek-rs' },
]}
/>
<Terminal
label="Windows · PowerShell"
lines={[
{ t: 'cmd', text: 'cargo uninstall soulseek-rs' },
{
t: 'cmd',
text: 'Remove-Item -Recurse -Force $env:APPDATA\\soulseek-rs',
},
{ t: 'cm', text: '# then remove the soulseek-rs entry from' },
{ t: 'cm', text: '# Credential Manager > Windows Credentials' },
]}
/>
</Cols>
<div className="mt-5">
<Callout title="two things it will not touch">
<p>
Your downloads and your shared directories are yours: they stay exactly where
they are. On macOS, delete the keychain entry for <code>soulseek-rs</code> in
Keychain Access if you saved a password.
</p>
</Callout>
</div>
</Section>
</>
)
}
+34
View File
@@ -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 (
<section className="py-16">
<Wrap>
<div className="flex max-w-[560px] flex-col gap-3.5">
<Eyebrow>404</Eyebrow>
<h1 className="text-[26px] leading-[34px] font-medium sm:text-title sm:leading-[var(--text-title--line-height)]">
No such path.
</h1>
<p className="text-secondary">
That page doesn&rsquo;t exist. The four that do are in the nav above.
</p>
<Terminal
lines={[
{ t: 'cmd', text: 'soulseek-rs --help' },
{ t: 'cm', text: '# no such page: check the nav, or start here' },
]}
copy="soulseek-rs --help"
/>
<div className="mt-1.5 flex flex-wrap gap-2 sm:gap-3">
<Button href="/" variant="accent">
Home
</Button>
<Button href="/docs">Read the docs</Button>
</div>
</div>
</Wrap>
</section>
)
}
+25
View File
@@ -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<Theme, 'system'>
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<ThemeContextValue | null>(null)
/** Matches the key the design's own inline boot script reads. */
export const THEME_STORAGE_KEY = 'ssrs-theme'
+43
View File
@@ -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<ThemeContextValue>(
() => ({ theme, resolvedTheme, setTheme }),
[theme, resolvedTheme, setTheme],
)
return <ThemeContext value={value}>{children}</ThemeContext>
}
+64
View File
@@ -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
* (`<html data-theme="dark">`). 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()
}
+10
View File
@@ -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
}
+4
View File
@@ -0,0 +1,4 @@
/// <reference types="vite/client" />
/** Injected by `define` in vite.config.ts from the workspace Cargo.toml. */
declare const __APP_VERSION__: string
+40
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+37
View File
@@ -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"
]
}
+14
View File
@@ -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
}
}
+44
View File
@@ -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://<user>.github.io/<repo>/, 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,
},
})