From b8d3061aec9fbfe3db9c7149fa08cfedebff1794 Mon Sep 17 00:00:00 2001 From: spacedevin Date: Wed, 20 May 2026 11:25:32 -0700 Subject: [PATCH 1/3] port it all to tish --- .env.local.example | 7 +- .github/workflows/ci.yml | 82 + .github/workflows/deploy-jukebox.yml | 27 + .github/workflows/npm-release.yml | 33 + .gitignore | 8 +- README.md | 92 +- app/box/page.tsx | 4 - app/callback/page.tsx | 25 - app/dev/page.tsx | 9 - app/layout.tsx | 146 - app/logout/page.tsx | 24 - app/page.tsx | 93 - assets/spin20.html | 566 -- assets/spin7.html | 920 --- biome.json | 88 - docs/CARD_ART.md | 163 +- docs/TISH_TOOLING.md | 61 + justfile | 75 + lib/debug-tracks.ts | 47 - lib/preferences.ts | 58 - lib/spotify-client.ts | 417 - middleware.ts | 18 - next-env.d.ts | 6 - next.config.mjs | 11 - package-lock.json | 6697 ++++++++++++++--- package.json | 36 +- packages/bridges/README.md | 63 + packages/bridges/build.mjs | 20 + packages/bridges/package-lock.json | 558 ++ packages/bridges/package.json | 18 + .../bridges/src/three-scene.js | 1132 +-- packages/bridges/src/tone-audio.js | 54 + packages/bridges/src/vendor-entry.js | 5 + packages/juke-cards/.releaserc.json | 9 + packages/juke-cards/README.md | 165 + packages/juke-cards/demo/index.html | 56 + packages/juke-cards/package.json | 49 + .../juke-cards/scripts/append-exports.mjs | 27 + .../juke-cards/scripts/compare-ts-tish.mjs | 191 + packages/juke-cards/scripts/xmur3-native.js | 22 + packages/juke-cards/src/backgrounds.tish | 2 + packages/juke-cards/src/borders.tish | 1 + packages/juke-cards/src/decor.tish | 1 + .../juke-cards/src/generate.tish | 312 +- packages/juke-cards/src/index.tish | 17 + packages/juke-cards/src/layouts.tish | 1 + packages/juke-cards/src/palettes.tish | 60 + packages/juke-cards/src/xmur3.tish | 23 + packages/juke-cards/test/run-tests.mjs | 84 + packages/jukebox/deps/lattish/Lattish.tish | 1 + packages/jukebox/deps/lattish/package.json | 14 + packages/jukebox/dev-server.tish | 58 + packages/jukebox/package-lock.json | 29 + packages/jukebox/package.json | 16 + packages/jukebox/public/base.css | 534 ++ packages/jukebox/public/boot.js | 11 + packages/jukebox/public/icon.svg | 1 + packages/jukebox/public/index.html | 29 + packages/jukebox/src/App.tish | 64 + packages/jukebox/src/components/Jukebox.tish | 463 ++ .../src/components/PlaylistPicker.tish | 60 + .../jukebox/src/components/SettingsPanel.tish | 182 + packages/jukebox/src/lib/debug-tracks.tish | 51 + packages/jukebox/src/lib/preferences.tish | 67 + packages/jukebox/src/lib/spotify.tish | 564 ++ packages/jukebox/src/lib/utils.tish | 8 + packages/jukebox/src/main.tish | 45 + packages/jukebox/src/pages/Box.tish | 5 + packages/jukebox/src/pages/Callback.tish | 43 + packages/jukebox/src/pages/Dev.tish | 5 + packages/jukebox/src/pages/Landing.tish | 76 + packages/jukebox/src/pages/Logout.tish | 14 + packages/jukebox/src/router.tish | 68 + packages/jukebox/vercel.json | 49 + scripts/build-jukebox.mjs | 37 + scripts/convert-card-art.mjs | 135 + scripts/load-env.mjs | 37 + tsconfig.json | 21 - 78 files changed, 10560 insertions(+), 4710 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/deploy-jukebox.yml create mode 100644 .github/workflows/npm-release.yml delete mode 100644 app/box/page.tsx delete mode 100644 app/callback/page.tsx delete mode 100644 app/dev/page.tsx delete mode 100644 app/layout.tsx delete mode 100644 app/logout/page.tsx delete mode 100644 app/page.tsx delete mode 100644 assets/spin20.html delete mode 100644 assets/spin7.html delete mode 100644 biome.json create mode 100644 docs/TISH_TOOLING.md create mode 100644 justfile delete mode 100644 lib/debug-tracks.ts delete mode 100644 lib/preferences.ts delete mode 100644 lib/spotify-client.ts delete mode 100644 middleware.ts delete mode 100644 next-env.d.ts delete mode 100644 next.config.mjs create mode 100644 packages/bridges/README.md create mode 100644 packages/bridges/build.mjs create mode 100644 packages/bridges/package-lock.json create mode 100644 packages/bridges/package.json rename app/jukebox.tsx => packages/bridges/src/three-scene.js (63%) create mode 100644 packages/bridges/src/tone-audio.js create mode 100644 packages/bridges/src/vendor-entry.js create mode 100644 packages/juke-cards/.releaserc.json create mode 100644 packages/juke-cards/README.md create mode 100644 packages/juke-cards/demo/index.html create mode 100644 packages/juke-cards/package.json create mode 100644 packages/juke-cards/scripts/append-exports.mjs create mode 100644 packages/juke-cards/scripts/compare-ts-tish.mjs create mode 100644 packages/juke-cards/scripts/xmur3-native.js create mode 100644 packages/juke-cards/src/backgrounds.tish create mode 100644 packages/juke-cards/src/borders.tish create mode 100644 packages/juke-cards/src/decor.tish rename lib/card-art.ts => packages/juke-cards/src/generate.tish (73%) create mode 100644 packages/juke-cards/src/index.tish create mode 100644 packages/juke-cards/src/layouts.tish create mode 100644 packages/juke-cards/src/palettes.tish create mode 100644 packages/juke-cards/src/xmur3.tish create mode 100644 packages/juke-cards/test/run-tests.mjs create mode 120000 packages/jukebox/deps/lattish/Lattish.tish create mode 100644 packages/jukebox/deps/lattish/package.json create mode 100644 packages/jukebox/dev-server.tish create mode 100644 packages/jukebox/package-lock.json create mode 100644 packages/jukebox/package.json create mode 100644 packages/jukebox/public/base.css create mode 100644 packages/jukebox/public/boot.js create mode 100644 packages/jukebox/public/icon.svg create mode 100644 packages/jukebox/public/index.html create mode 100644 packages/jukebox/src/App.tish create mode 100644 packages/jukebox/src/components/Jukebox.tish create mode 100644 packages/jukebox/src/components/PlaylistPicker.tish create mode 100644 packages/jukebox/src/components/SettingsPanel.tish create mode 100644 packages/jukebox/src/lib/debug-tracks.tish create mode 100644 packages/jukebox/src/lib/preferences.tish create mode 100644 packages/jukebox/src/lib/spotify.tish create mode 100644 packages/jukebox/src/lib/utils.tish create mode 100644 packages/jukebox/src/main.tish create mode 100644 packages/jukebox/src/pages/Box.tish create mode 100644 packages/jukebox/src/pages/Callback.tish create mode 100644 packages/jukebox/src/pages/Dev.tish create mode 100644 packages/jukebox/src/pages/Landing.tish create mode 100644 packages/jukebox/src/pages/Logout.tish create mode 100644 packages/jukebox/src/router.tish create mode 100644 packages/jukebox/vercel.json create mode 100644 scripts/build-jukebox.mjs create mode 100644 scripts/convert-card-art.mjs create mode 100644 scripts/load-env.mjs delete mode 100644 tsconfig.json diff --git a/.env.local.example b/.env.local.example index 8692c6b..af10d4b 100644 --- a/.env.local.example +++ b/.env.local.example @@ -1 +1,6 @@ -NEXT_PUBLIC_SPOTIFY_CLIENT_ID=your_client_id +# Copy this file to .env.local and paste your Spotify app's Client ID. +# Get one at https://developer.spotify.com/dashboard +# +# Either variable name works. NEXT_PUBLIC_ matches the old Next.js setup; +# SPOTIFY_CLIENT_ID is used on Vercel and other hosts. +NEXT_PUBLIC_SPOTIFY_CLIENT_ID= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1f52ebf --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,82 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + test-and-build: + name: fmt, lint, test, build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Install just + run: curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to ~/.local/bin + + - name: Install npm dependencies + run: npm ci + + - name: Install Tish (npm) + run: npm install -g @tishlang/tish@^1.10.0 + + - name: fmt-check (optional — skip if tish-fmt unavailable) + run: | + if command -v tish-fmt >/dev/null 2>&1; then just fmt-check; else echo "skip fmt-check"; fi + + - name: lint (optional — skip if tish-lint unavailable) + run: | + if command -v tish-lint >/dev/null 2>&1; then just lint; else echo "skip lint"; fi + + - name: Test juke-cards + run: just test-cards + + - name: Build jukebox + run: just build + + - name: Upload static site artifact + uses: actions/upload-artifact@v4 + with: + name: jukebox-public + path: packages/jukebox/public + + juke-cards-prerelease: + name: juke-cards prerelease tarball + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + needs: test-and-build + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + + - run: npm ci + + - name: Build juke-cards + run: npm run build --workspace=@spacedevin/juke-cards + + - name: Pack tarball + working-directory: packages/juke-cards + run: npm pack && mv *.tgz juke-cards-npm-package.tgz + + - name: Upload prerelease asset + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION=$(node -p "require('./packages/juke-cards/package.json').version") + TAG="v${VERSION}" + gh release upload "$TAG" packages/juke-cards/juke-cards-npm-package.tgz --clobber 2>/dev/null || \ + gh release create "$TAG" packages/juke-cards/juke-cards-npm-package.tgz --prerelease --title "$TAG" --generate-notes diff --git a/.github/workflows/deploy-jukebox.yml b/.github/workflows/deploy-jukebox.yml new file mode 100644 index 0000000..42917a3 --- /dev/null +++ b/.github/workflows/deploy-jukebox.yml @@ -0,0 +1,27 @@ +name: Deploy jukebox + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + deploy: + name: Vercel static deploy + runs-on: ubuntu-latest + if: vars.VERCEL_DEPLOY == 'true' + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + - run: npm ci && npm install -g @tishlang/tish@^1.10.0 + - run: just build + - name: Deploy to Vercel + uses: amondnet/vercel-action@v25 + with: + vercel-token: ${{ secrets.VERCEL_TOKEN }} + vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} + vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }} + working-directory: packages/jukebox + vercel-args: "--prod" diff --git a/.github/workflows/npm-release.yml b/.github/workflows/npm-release.yml new file mode 100644 index 0000000..325e8de --- /dev/null +++ b/.github/workflows/npm-release.yml @@ -0,0 +1,33 @@ +name: NPM release (juke-cards) + +on: + release: + types: [published] + +jobs: + publish: + name: Publish @spacedevin/juke-cards + if: github.event.release.prerelease == false + runs-on: ubuntu-latest + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + steps: + - uses: actions/checkout@v4 + with: + ref: refs/tags/${{ github.event.release.tag_name }} + + - name: Download tarball from release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + ASSETS='${{ toJSON(github.event.release.assets) }}' + URL=$(echo "$ASSETS" | jq -r '.[] | select(.name == "juke-cards-npm-package.tgz") | .url') + test -n "$URL" && test "$URL" != "null" + curl -sL -H "Authorization: Bearer $GITHUB_TOKEN" -H "Accept: application/octet-stream" "$URL" -o juke-cards-npm-package.tgz + + - uses: actions/setup-node@v4 + with: + node-version: "22" + registry-url: https://registry.npmjs.org + + - run: npm publish juke-cards-npm-package.tgz --access public diff --git a/.gitignore b/.gitignore index f1ef38e..54d5011 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,10 @@ node_modules *.log .DS_Store .claude -tsconfig.tsbuildinfo \ No newline at end of file +tsconfig.tsbuildinfo +.dry-run +packages/*/dist +packages/juke-cards/lib +packages/jukebox/public/dist +target +Cargo.lock diff --git a/README.md b/README.md index c369add..dd9bc51 100644 --- a/README.md +++ b/README.md @@ -2,58 +2,80 @@ ![Loaded jukebox](docs/screenshots/demo.gif) +3D Spotify jukebox modeled after a chrome-and-neon 50s diner record machine. Three.js scene, full procedural card art ([`@spacedevin/juke-cards`](packages/juke-cards)), real Spotify integration — your playlists become the records on the drum. -3D Spotify jukebox modeled after a chrome-and-neon 50s diner record machine. Three.js scene, full procedural card art, real Spotify integration — your playlists become the records on the drum. +Built as a **Tish + Lattish** static SPA (no Next.js server). Uses PKCE auth — each user authenticates with their own Spotify account and all API calls go directly from their browser to Spotify. -Uses PKCE auth — each user authenticates with their own Spotify account and all API calls go directly from their browser to Spotify (no shared rate limit on a server proxy). +## Monorepo + +``` +packages/ + juke-cards/ @spacedevin/juke-cards — procedural canvas card art (npm) + jukebox/ Lattish SPA served from public/ + bridges/ Three.js + Tone.js vendor bundle (JS shim) +``` ## Setup 1. Create an app at https://developer.spotify.com/dashboard -2. Add redirect URIs (one per environment you'll run on): - - `https://juke.sh/callback` (juke server) +2. Add redirect URIs: + - `https://juke.sh/callback` - `http://127.0.0.1:3000/callback` (local dev) -3. Copy `.env.local.example` to `.env.local`, fill in `NEXT_PUBLIC_SPOTIFY_CLIENT_ID` -4. `npm install && npm run dev` -5. Visit `http://127.0.0.1:3000` → click **LOGIN WITH SPOTIFY** -6. On the playlist picker, choose one or more playlists to load into the deck +3. `npm install` +4. Copy `.env.local.example` to `.env.local` and set `NEXT_PUBLIC_SPOTIFY_CLIENT_ID` +5. `just dev` — serves on http://127.0.0.1:3000 +6. Visit `/` → **LOGIN WITH SPOTIFY** (Client ID is pre-filled from `.env.local`) +7. On `/box`, pick playlists to load into the deck -Playback requires an active Spotify device — open Spotify somewhere on your account (desktop app, phone, web player, etc.) so the API has something to send `play` / `pause` / `next` to. +No client secret needed — PKCE auth is browser-only. The Client ID is public and is baked into `public/dist/config.js` at build time (same as the old `NEXT_PUBLIC_` Next.js flow). -## Routes +For production (Vercel, etc.), set `NEXT_PUBLIC_SPOTIFY_CLIENT_ID` or `SPOTIFY_CLIENT_ID` in the host's environment variables at build time. You can still paste a Client ID on `/` instead — it is stored in `localStorage` and overrides the baked default. + +Playback requires an active Spotify device (desktop app, phone, web player, etc.). -- `/` — landing page. Auto-redirects to `/box` if you're already signed in. -- `/box` — the jukebox app. Loads tokens from `localStorage`; if missing, shows just the login button (no full landing graphics). -- `/dev` — login-less visual playground with procedural fake tracks. Works in production too. -- `/callback` — OAuth redirect target. +## Commands -Arrow keys nudge the auto-rotation (←/→) and row count (↑/↓); `C` toggles category labels. Default is no auto-spin. +| Command | Description | +|---------|-------------| +| `just dev` | Build + Tish dev server (`:3000`, SPA fallback) | +| `just build` | Cards + vendor + Lattish bundles → `packages/jukebox/public/dist/` | +| `just test` | juke-cards smoke tests | +| `just fmt` / `just lint` | Tish format/lint (requires `TISH_ROOT` cargo binaries) | -Routes are case-insensitive via middleware (`/BOX`, `/Box`, etc. all 308 → `/box`). +See [docs/TISH_TOOLING.md](docs/TISH_TOOLING.md) for compiler install and CI notes. -## Controls +## Local development -- **Tap a card** — play that track on your active Spotify device. -- **Tap an already-playing card** — deselect. -- **Drag left/right** — spin the jukebox. Rotation speed auto-scales with the size of the deck so a 1000-track jukebox doesn't whip past you. -- **Drag up/down** — continuously blend between Roller-Rink (up) and Classic Diner (down) lighting. -- **Scroll wheel / pinch** — continuously zoom between fit-to-screen and tight-on-cards. -- **Double-tap empty chrome** — snap zoom between min and max. -- The currently playing track illuminates with edge LEDs + four tracker LEDs that converge along the top rim from opposite sides so you can always find your way home. +From the repo root: -## Caching +```bash +npm install +cp .env.local.example .env.local # add your Spotify Client ID +just dev # http://127.0.0.1:3000 — register this callback URL in Spotify +``` + +To test on a phone over the network, use a tunnel (e.g. `ngrok http 3000`) and add `https://YOUR-TUNNEL/callback` to your Spotify app's redirect URIs. The dev server logs OAuth hits on `/callback`. + +## Routes -- Selected playlists are saved to `localStorage` (`jukebox_selected_playlists`). -- Their assembled track lists are cached for 1 hour, keyed by the sorted set of selected IDs (`jukebox_tracks_cache`). -- The picker modal always shows on refresh with previous picks pre-selected; selecting the same set hits the cache, selecting a different set re-fetches. -- A green `● CACHED` chip appears in the picker next to any playlist whose tracks are currently cached. Click it to flush and re-fetch on next load. +- `/` — landing (Client ID + login) +- `/box` — jukebox app +- `/dev` — procedural debug tracks (no Spotify) +- `/callback` — OAuth redirect +- `/logout` — clear session -## Notes +Arrow keys: spin (←/→), rows (↑/↓). `C` category labels, `M` audio, `S` shuffle, `Z`/`X` zoom tight, `V` telephoto flatness. -- No client secret needed — PKCE auth is browser-only. -- Tokens stored in `localStorage`; access token refresh handled automatically. -- The `NEXT_PUBLIC_` prefix on the client ID means it ships in the JS bundle, which is fine — Spotify client IDs are public; only the secret would be sensitive (and PKCE doesn't use one). -- Spotify-owned algorithmic playlists (IDs starting with `37i9...` — Daily Mix, Discover Weekly, editorial picks) were locked down by Spotify in late 2024 and now return 404 to the Web API. The picker only shows playlists you own or follow, which is the correct accessible set. +## Deploy (Vercel) + +Root directory: `packages/jukebox`. Output: `public/`. Config in `vercel.json`. + +Set `NEXT_PUBLIC_SPOTIFY_CLIENT_ID` or `SPOTIFY_CLIENT_ID` in the Vercel project's environment variables (build time). The build writes it to `public/dist/config.js`. + +## Card art package + +Procedural 256×100 slot cards live in [`@spacedevin/juke-cards`](packages/juke-cards). Demo grid: `packages/juke-cards/demo/index.html` (after `npm run build --workspace=@spacedevin/juke-cards`). ## License -PIF \ No newline at end of file + +PIF diff --git a/app/box/page.tsx b/app/box/page.tsx deleted file mode 100644 index 336d477..0000000 --- a/app/box/page.tsx +++ /dev/null @@ -1,4 +0,0 @@ -import Jukebox from '../jukebox'; -export default function Box() { - return ; -} diff --git a/app/callback/page.tsx b/app/callback/page.tsx deleted file mode 100644 index cfcc1e3..0000000 --- a/app/callback/page.tsx +++ /dev/null @@ -1,25 +0,0 @@ -'use client'; -import { useEffect, useState } from 'react'; -import { exchangeCode, consumeState } from '@/lib/spotify-client'; - -export default function Callback() { - const [msg, setMsg] = useState('Completing login…'); - useEffect(() => { - const url = new URL(window.location.href); - const code = url.searchParams.get('code'); - const state = url.searchParams.get('state'); - const err = url.searchParams.get('error'); - if (err) { setMsg(`Auth error: ${err}`); return; } - if (!code) { setMsg('Missing code'); return; } - // CSRF guard — value must match the state we generated in login(). - const expected = consumeState(); - if (!expected || expected !== state) { - setMsg('State mismatch — login may have been tampered with. Please retry.'); - return; - } - exchangeCode(code) - .then(() => { window.location.replace('/box'); }) - .catch((e) => setMsg(String(e.message || e))); - }, []); - return

{msg}

; -} diff --git a/app/dev/page.tsx b/app/dev/page.tsx deleted file mode 100644 index 3d94054..0000000 --- a/app/dev/page.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import Jukebox from '../jukebox'; - -// Visual playground — skips Spotify auth and loads procedural debug tracks. -// Works everywhere, including production. No login required, useful for -// recordings / screenshots / sharing the look. Auto-rotation defaults to 0; -// press the left/right arrow keys to start a spin. -export default function Dev() { - return ; -} diff --git a/app/layout.tsx b/app/layout.tsx deleted file mode 100644 index dbf7637..0000000 --- a/app/layout.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { Github, Music2 } from 'lucide-react'; - -export const metadata = { title: 'juke.sh - Spotify WebGL Jukebox' }; - -export default function RootLayout({ children }: { children: React.ReactNode }) { - return ( - - - - - - - - - - - - - - - {children} - - - - ); -} diff --git a/app/logout/page.tsx b/app/logout/page.tsx deleted file mode 100644 index 0c9dc45..0000000 --- a/app/logout/page.tsx +++ /dev/null @@ -1,24 +0,0 @@ -'use client'; -import { useEffect } from 'react'; -import { useRouter } from 'next/navigation'; -import { logout } from '@/lib/spotify-client'; - -// /logout — clears the Spotify session and bounces back to the landing page. -// Kept as a dedicated route so it can be linked to from anywhere (settings, -// landing page, footer, etc.) and bookmarked. -// -// Note: this only clears OUR tokens locally. To fully revoke juke.sh's access -// from the Spotify side, the user must remove the app at -// https://www.spotify.com/account/apps/ — we surface that link below. -export default function Logout() { - const router = useRouter(); - useEffect(() => { - logout(); - router.replace('/'); - }, [router]); - return ( -
-
Logging out…
-
- ); -} diff --git a/app/page.tsx b/app/page.tsx deleted file mode 100644 index 701f42b..0000000 --- a/app/page.tsx +++ /dev/null @@ -1,93 +0,0 @@ -'use client'; -import { useEffect, useState } from 'react'; -import { isLoggedIn, login, getStoredClientId, setStoredClientId } from '@/lib/spotify-client'; - -export default function Landing() { - const [clientId, setClientId] = useState(''); - // Lets us render different UI for logged-in vs logged-out without - // auto-redirecting. The landing page is now a hub — pick your destination - // (enter the jukebox, log out, demo mode) instead of getting bounced. - const [loggedIn, setLoggedIn] = useState(false); - useEffect(() => { - // On localhost, render the logged-in state regardless of actual auth so - // we can preview the logout button + post-login layout without going - // through the full Spotify OAuth round-trip every time we tweak CSS. - const onLocalhost = - window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; - setLoggedIn(isLoggedIn() || onLocalhost); - setClientId(getStoredClientId()); - }, []); - - const onSubmit = (e: React.FormEvent) => { - e.preventDefault(); - setStoredClientId(clientId); - login(); - }; - - return ( -
-
juke.sh/BOX
-
Spotify WebGL Jukebox
-
- {loggedIn ? ( -
- ENTER THE JUKEBOX - LOG OUT -
- You're signed in to Spotify. -




- Or take it for a test spin → -
-
- ) : ( -
- setClientId(e.target.value)} - /> - - -
- Create a Spotify app to get a Client ID
(free, takes ~1 minute). - {' '} - - How → - -




- - Take it for a test spin without an ID → - -
- - -
- )} - - -
- ); -} diff --git a/assets/spin20.html b/assets/spin20.html deleted file mode 100644 index ec9c4c5..0000000 --- a/assets/spin20.html +++ /dev/null @@ -1,566 +0,0 @@ - - - - - - Jukebox Card Art Generator - - - - - - - -
-
-
-

- Jukebox Card Art Library -

-

WCAG (strict) on solid flat cards only; patterns and illustrations use shadows.

-
- -
- - -
-
-
- -
-
-
- - - - - - \ No newline at end of file diff --git a/assets/spin7.html b/assets/spin7.html deleted file mode 100644 index 0b75de1..0000000 --- a/assets/spin7.html +++ /dev/null @@ -1,920 +0,0 @@ - - - - - - 3D 50s Diner Jukebox - Roller Rink & Mechanics - - - - - - - - - - - -
-
-
DRAG TO SPIN • TAP TO PLAY
-
- Loading Fonts & 432 Records... -
-
-
- - -
- - - - \ No newline at end of file diff --git a/biome.json b/biome.json deleted file mode 100644 index 18d0d91..0000000 --- a/biome.json +++ /dev/null @@ -1,88 +0,0 @@ -{ - "$schema": "https://biomejs.dev/schemas/2.4.15/schema.json", - "vcs": { - "enabled": true, - "clientKind": "git", - "useIgnoreFile": true - }, - "files": { - "includes": [ - "**/*.ts", - "**/*.tsx", - "**/*.js", - "**/*.jsx", - "**/*.json", - "!.next", - "!node_modules", - "!public", - "!next-env.d.ts", - "!.claude" - ] - }, - "formatter": { - "enabled": true, - "indentStyle": "space", - "indentWidth": 2, - "lineWidth": 120, - "lineEnding": "lf" - }, - "javascript": { - "formatter": { - "quoteStyle": "single", - "jsxQuoteStyle": "double", - "semicolons": "always", - "trailingCommas": "all", - "arrowParentheses": "always", - "bracketSameLine": false - } - }, - "json": { - "formatter": { - "indentWidth": 2, - "trailingCommas": "none" - } - }, - "linter": { - "enabled": true, - "rules": { - "recommended": true, - "correctness": { - "noUnusedVariables": "warn", - "noUnusedImports": "warn", - "noUnusedPrivateClassMembers": "warn", - "useExhaustiveDependencies": "off" - }, - "suspicious": { - "noExplicitAny": "off", - "noArrayIndexKey": "off", - "noAssignInExpressions": "off" - }, - "style": { - "noNonNullAssertion": "off", - "useImportType": "warn", - "useTemplate": "off", - "useNumberNamespace": "off" - }, - "complexity": { - "noForEach": "off", - "useLiteralKeys": "off" - }, - "a11y": { - "useKeyWithClickEvents": "off", - "useButtonType": "off", - "noStaticElementInteractions": "off" - }, - "performance": { - "noDelete": "off", - "noImgElement": "off" - } - } - }, - "assist": { - "actions": { - "source": { - "organizeImports": "on" - } - } - } -} diff --git a/docs/CARD_ART.md b/docs/CARD_ART.md index a67295c..b077e63 100644 --- a/docs/CARD_ART.md +++ b/docs/CARD_ART.md @@ -1,11 +1,17 @@ # Card Art Generator -`lib/card-art.ts` is a standalone procedural-graphics module that renders **256×100 px "jukebox slot card"** artwork to an `HTMLCanvasElement`. It powers every card on the juke.sh drum, but it has no dependency on Three.js, React, or anything else in the app — drop it into any browser project that has a `` API. +Procedural jukebox slot card art now lives in the npm package **[`@spacedevin/juke-cards`](../packages/juke-cards/README.md)**. -## TL;DR +See that README for API docs, deterministic seeding, font preloading, and the visual demo grid. -```ts -import { generateCardArt } from './card-art'; +Quick start: + +```bash +npm install @spacedevin/juke-cards +``` + +```js +import { generateCardArt } from '@spacedevin/juke-cards'; const { canvas, accent, bg, alt } = generateCardArt({ titleA: 'JOHNNY B', @@ -13,153 +19,6 @@ const { canvas, accent, bg, alt } = generateCardArt({ artist: 'Chuck Berry', code: 'A1', }); - -document.body.appendChild(canvas); ``` -That's it. The same `(titleA + artist + code)` input always produces the same card — the generator is fully deterministic via a 32-bit seeded RNG. - -## API - -### `generateCardArt(song: CardSong): CardArt` - -```ts -interface CardSong { - titleA: string; // headline title (printed largest in most layouts) - titleB: string; // secondary title (the "B-side") - artist: string; - code: string; // short slot code, e.g. "A1", "K12" - source?: 'now' | 'queue' | 'playlist'; - // optional left-edge stripe colour: - // 'now' → green stripe (currently playing) - // 'queue' → amber stripe (in the user's queue) - // any other / undefined → no stripe -} - -interface CardArt { - canvas: HTMLCanvasElement; // 256×100, ready to use - accent: string; // palette[2] — primary accent hex - bg: string; // palette[0] — card background hex - alt: string; // palette[3] — alt accent hex -} -``` - -### `xmur3(seed: string): () => number` - -The same Mulberry-style 32-bit hash the generator uses internally. Exposed so callers can chain reproducible randomness in their own code (juke.sh uses it to seed the env-map texture). - -### `getContrast(hex: string): string` - -Given a `#rrggbb` colour, returns `'#000000'` or `'#ffffff'` — whichever has the higher contrast (using the standard 299/587/114 luma weights). - -### Mutable exports - -```ts -export const palettes: string[][]; // 35 × 4 colours -export const displayFonts: string[]; // 19 headline faces -export const scriptFonts: string[]; // 10 script faces -export const codeFonts: string[]; // 5 monospaced-feel faces (slot code) -export const infoFonts: string[]; // 9 body / fine-print faces -``` - -These are deliberately `let`-equivalent (exported arrays you can mutate in place). Drop in your own palette or font list before calling `generateCardArt`: - -```ts -import { palettes, generateCardArt } from './card-art'; - -palettes.length = 0; -palettes.push( - ['#1b1b1b', '#e1e1e1', '#ff4500', '#999999'], // monochrome with one accent - ['#0a0a0a', '#fafafa', '#00d4ff', '#cccccc'], -); - -const { canvas } = generateCardArt({ titleA: 'TEST', titleB: 'TONE', artist: '—', code: 'Z1' }); -``` - -## How the generator works - -Each call picks four independent random style indices from the seeded RNG and stamps the card in four layers: - -| Layer | Range | What it draws | -| ----- | ----- | ------------- | -| **Background** | `0–45` | Solid fills and patterns — split, stripes, checks, rays, chevron, wavy 70s bands, scanlines, etc. | -| **Decor** | `0–45` | Overlay graphic — giant star, watermark code, vinyl, mic, UFO, mountains, divider lines, etc. | -| **Border** | `0–14` | Frame around the central typography area. Some borders also place an inset background, which the typo layer respects. | -| **Layout** | `0–46` | Typography arrangement — 47 distinct compositions for titleA / titleB / artist / code. | - -Plus per-card random selections from `palettes`, `displayFonts`, `scriptFonts`, `codeFonts`, `infoFonts`. - -Typography is drawn on a separate offscreen canvas and composited onto the card. **Solid flat cards** (simple background + no icons/illustrations + plain border + standard layout) get a pixel-level WCAG contrast pass that flips low-contrast text to black or white. **Patterned or illustrated cards** use hard text shadows instead. - -After the four layers, an optional **left-edge stripe** is painted to encode `song.source`, and a **1500-pixel paper-grain noise overlay** is dithered on top to break up the flat fills. - -Total combinatorial space: `46 × 46 × 15 × 47 × 35 palettes × (19 × 10 × 5 × 9 fonts) ≈ 450 billion` distinct cards, so collisions are vanishingly unlikely. - -## Determinism - -The seed is `titleA + artist + code`. **Change any of those and you get a different card**; leave them stable and every call returns the same artwork. Useful for: - -- snapshot tests (compare canvas data URLs) -- server-side rendering (use [`canvas`](https://www.npmjs.com/package/canvas) to polyfill `document.createElement('canvas')` in Node) -- skipping regeneration in caches - -`titleB` and `source` do **not** contribute to the seed, so changing the B-side or moving a track between queue/playlist won't reshuffle the layout — only repaint the relevant text + stripe. - -## Fonts - -Layouts reference Google Font families by name (`'Limelight'`, `'Anton'`, `'Bebas Neue'`, etc.). The generator never loads them — you're responsible for loading whatever set you want in your host page, e.g.: - -```html - -``` - -If a face isn't loaded the canvas falls back to the browser's default (typically serif), which looks wrong. juke.sh calls `preloadCardFonts()` before the first render — recommended for any caller that cares about pixel-perfect output on desktop browsers. - -## Three.js integration - -The juke.sh app wraps the canvas in a `CanvasTexture`: - -```ts -import * as THREE from 'three'; -import { generateCardArt } from '@/lib/card-art'; - -function generateCardTexture(song) { - const { canvas, accent, bg, alt } = generateCardArt(song); - const texture = new THREE.CanvasTexture(canvas); - texture.anisotropy = renderer.capabilities.getMaxAnisotropy(); - texture.minFilter = THREE.LinearFilter; - return { texture, accent, bg, alt }; -} -``` - -That's the entire integration. Everything else (placement, lighting, the LED-edge highlight) lives in `app/jukebox.tsx`. - -## Standalone smoke test - -Drop a `` page in front of it without any framework: - -```html - - -card-art preview - - - -``` - -## Reference - -- Source: [`lib/card-art.ts`](../lib/card-art.ts) -- Lineage: adapted from [`assets/spin20.html`](../assets/spin20.html) (was spin7) +Source: `packages/juke-cards/src/*.tish` (ported from the original `lib/card-art.ts`). diff --git a/docs/TISH_TOOLING.md b/docs/TISH_TOOLING.md new file mode 100644 index 0000000..6abedfc --- /dev/null +++ b/docs/TISH_TOOLING.md @@ -0,0 +1,61 @@ +# Tish tooling (juke.sh monorepo) + +This repo uses the [Tish](https://tishlang.com) compiler and Lattish JSX runtime. Local development follows patterns from [tishlang/learn](https://github.com/tishlang/learn). + +## Install + +```bash +npm install +# Tish CLI via npm (@tishlang/tish) or cargo install from ~/Projects/tish +``` + +Pin versions: `@tishlang/tish@^1.10.0`, `lattish@^1.1.3` (npm alias `@tishlang/lattish`). + +## Commands (justfile) + +| Target | Description | +|--------|-------------| +| `just build` | Build cards + vendor + Lattish SPA bundles | +| `just dev` | Build then serve on `:3000` (Spotify callback URL) | +| `just fmt` / `just fmt-check` | Format Tish sources (`tish-fmt` from `TISH_ROOT`) | +| `just lint` | Lint Tish sources (`tish-lint`) | +| `just test` | Run juke-cards smoke tests | + +### Environment + +- `TISH_ROOT` — path to tish cargo repo (default `../tish`). Used for `tish-fmt` / `tish-lint` binaries. +- `TISH` — tish CLI override (default `tish`) +- `PORT` — dev server port (default `3000`) + +If npm `@tishlang/tish` lacks `fs`/`http`/`process` features, build from source: + +```bash +cd "$TISH_ROOT" && cargo install --path crates/tish +``` + +## Remote / tunnel dev + +The Tish dev server binds to port **3000** by default (`PORT` override supported). For OAuth on a non-localhost device: + +1. Run `just dev` locally. +2. Tunnel port 3000 (ngrok, Cloudflare Tunnel, etc.). +3. Add `https://YOUR-TUNNEL-HOST/callback` to your Spotify app redirect URIs. +4. Open the tunnel URL on your device. + +The dev server logs `/callback` requests to help debug PKCE round-trips. + +## Package layout + +- `packages/juke-cards` — `@spacedevin/juke-cards` npm library (Tish → JS) +- `packages/jukebox` — Lattish SPA (`tish build --target js`, no Node server in prod) +- `packages/bridges` — Three.js + Tone.js vendor bundle (plain JS, out of tish-lint scope) + +## Official docs + +- [Tish language](https://tishlang.com/docs) +- [Modules](https://tishlang.com/docs/language/modules) +- [Lattish](https://lattish.com/docs) + +## Upstream note + +`tish-fmt` and `tish-lint` are not yet published as standalone npm packages; this repo invokes cargo-built binaries from `TISH_ROOT`. Consider upstream packaging if CI without Rust becomes a requirement. diff --git a/justfile b/justfile new file mode 100644 index 0000000..e128b95 --- /dev/null +++ b/justfile @@ -0,0 +1,75 @@ +# juke.sh monorepo — Tish + Lattish build pipeline +# +# Artifacts land in packages/jukebox/public/dist: +# juke-cards.js (@spacedevin/juke-cards browser bundle) +# vendor.js (Three.js + Tone.js + scene bridge) +# jukebox.js (Lattish SPA + inlined Lattish runtime) + +export CARGO_TARGET_DIR := justfile_directory() + "/target" +TISH_ROOT := env_var_or_default("TISH_ROOT", justfile_directory() + "/../tish") +TISH := env_var_or_default("TISH", "tish") +TISH_FMT := env_var_or_default("TISH_FMT", TISH_ROOT + "/target/release/tish-fmt") +TISH_LINT := env_var_or_default("TISH_LINT", TISH_ROOT + "/target/release/tish-lint") + +default: + @just --list + +# --- juke-cards --- + +build-cards: + npm run build --workspace=@spacedevin/juke-cards + +test-cards: + npm test --workspace=@spacedevin/juke-cards + +# --- bridges vendor bundle --- + +build-vendor: + npm run build --workspace=@spacedevin/juke-bridges + mkdir -p "{{ justfile_directory() }}/packages/jukebox/public/dist" + cp "{{ justfile_directory() }}/packages/bridges/dist/vendor.js" \ + "{{ justfile_directory() }}/packages/jukebox/public/dist/vendor.js" + +# Optional: prebuilt Lattish.js (not loaded by boot.js — runtime is inlined in jukebox.js) +build-lattish-runtime: + mkdir -p "{{ justfile_directory() }}/packages/jukebox/public/dist" + cp "{{ justfile_directory() }}/node_modules/@tishlang/lattish/dist/Lattish.js" \ + "{{ justfile_directory() }}/packages/jukebox/public/dist/lattish-runtime.js" + +# --- jukebox SPA --- + +build-app: + npm run build --workspace=@spacedevin/jukebox + +build: build-app + +quick: build-app + +dev: build + cd "{{ justfile_directory() }}/packages/jukebox" && \ + {{ TISH }} run --feature fs --feature http --feature process dev-server.tish + +serve: dev + +test: test-cards + +# Optional: rebuild on .tish changes (requires fswatch or similar) +dev-watch: + #!/usr/bin/env bash + set -euo pipefail + while true; do + just quick || true + fswatch -1 -r packages/jukebox/src packages/juke-cards/src + done + +fmt: + {{ TISH_FMT }} packages/juke-cards/src packages/jukebox/src + +fmt-check: + {{ TISH_FMT }} --check packages/juke-cards/src packages/jukebox/src + +lint: + {{ TISH_LINT }} packages/juke-cards/src packages/jukebox/src + +clean: + rm -rf packages/jukebox/public/dist packages/juke-cards/dist packages/juke-cards/lib packages/bridges/dist target diff --git a/lib/debug-tracks.ts b/lib/debug-tracks.ts deleted file mode 100644 index 9f78c8c..0000000 --- a/lib/debug-tracks.ts +++ /dev/null @@ -1,47 +0,0 @@ -// Procedural fake tracks for visual debugging — adapted from the spin7.html -// prototype. Pure data + a deterministic generator; no network, no Spotify. -// Used by the /dev route in both dev and production builds. - -import type { JukeTrack } from './spotify-client'; - -const artists = [ - 'Elvis Presley', 'Chuck Berry', 'Little Richard', 'Fats Domino', 'Buddy Holly', - 'Jerry Lee Lewis', 'Gene Vincent', 'Roy Orbison', 'Everly Brothers', 'Ray Charles', - 'Sam Cooke', 'Jackie Wilson', 'The Drifters', 'The Coasters', 'Dion', 'Patsy Cline', - 'Brenda Lee', 'Bill Haley', 'Carl Perkins', 'Ricky Nelson', 'Wanda Jackson', - 'Ritchie Valens', 'Frank Sinatra', 'Dean Martin', 'Paul Anka', 'Johnny Cash', - 'The Platters', 'Etta James', -]; -const adjectives = [ - 'Blue', 'Crazy', 'Sweet', "Rockin'", 'Wild', 'Lonely', 'Shake', "Rollin'", 'Magic', - 'Jive', 'Good', 'Bad', 'Fast', 'Slow', 'Heartbreak', 'Midnight', 'Silver', 'Golden', - 'Happy', 'Sad', "Twistin'", 'Atomic', 'Neon', 'Plastic', 'Radio', -]; -const nouns = [ - 'Shoes', 'Love', 'Baby', 'Night', 'Heart', 'Star', 'Moon', 'Cat', 'Dog', 'Train', - 'Tears', 'Dreams', 'Doll', 'Angel', 'Boogie', 'Twist', 'Beat', 'Town', 'River', 'Road', - 'Dance', 'Rocket', 'Juke', 'Diner', 'Flame', -]; -const albums = [ - 'Greatest Hits', "The Best Of", 'Live at the Hop', 'Sock Hop', 'On The Radio', - 'Diner Days', 'Drive-In Nights', 'The Original Sessions', 'Sun Records', - '45s & B-Sides', 'Highway Songs', 'Last Call', -]; - -export function generateDebugTracks(count = 120): JukeTrack[] { - const out: JukeTrack[] = []; - for (let i = 0; i < count; i++) { - const artist = artists[i % artists.length]; - const name = `${adjectives[(i * 3) % adjectives.length]} ${nouns[(i * 7) % nouns.length]}`; - const album = albums[(i * 5) % albums.length]; - out.push({ - uri: `spotify:track:debug-${i.toString(36)}`, - name, - artist, - album, - source: 'playlist', - }); - } - return out; -} - diff --git a/lib/preferences.ts b/lib/preferences.ts deleted file mode 100644 index 4be0dce..0000000 --- a/lib/preferences.ts +++ /dev/null @@ -1,58 +0,0 @@ -// View preferences persisted to localStorage: -// - spin ←/→ arrow-key auto-rotation rate (rad/sec) -// - rows ↑/↓ arrow-key row count (4..20) -// - lighting vertical-drag blend (0 diner ↔ 1 rink) -// - showCategories 'C' key toggle for bottom-rim labels -// -// Saves are debounced so continuous inputs (lighting drag) don't hammer -// localStorage on every pointermove sample. - -const KEY = 'jukebox_prefs'; -const SAVE_DEBOUNCE_MS = 250; - -export type Prefs = { - spin?: number; - rows?: number; - lighting?: number; - showCategories?: boolean; - zoomTight?: number; // tightDist multiplier (0.4..1.2, default 0.95) - zoomFlat?: number; // telephoto-flatness at zoom=1 (0..1, default 0) - audioEnabled?: boolean; // 'M' key + settings toggle for click + hum; default true - shuffle?: boolean; // 'S' key + settings toggle; default true — applied via - // Spotify Web API after the first play of each session -}; - -export function loadPrefs(): Prefs { - if (typeof window === 'undefined') return {}; - try { - const raw = localStorage.getItem(KEY); - return raw ? JSON.parse(raw) : {}; - } catch { - return {}; - } -} - -let pending: Prefs = {}; -let saveTimer: ReturnType | null = null; - -function flush() { - saveTimer = null; - try { - const existing = loadPrefs(); - localStorage.setItem(KEY, JSON.stringify({ ...existing, ...pending })); - } catch {} - pending = {}; -} - -export function savePrefs(patch: Prefs) { - pending = { ...pending, ...patch }; - if (saveTimer) clearTimeout(saveTimer); - saveTimer = setTimeout(flush, SAVE_DEBOUNCE_MS); -} - -// Force a synchronous flush — call from unload/visibility-hidden handlers so -// in-flight debounced writes don't get lost. -export function flushPrefs() { - if (saveTimer) clearTimeout(saveTimer); - if (Object.keys(pending).length) flush(); -} diff --git a/lib/spotify-client.ts b/lib/spotify-client.ts deleted file mode 100644 index a7f0056..0000000 --- a/lib/spotify-client.ts +++ /dev/null @@ -1,417 +0,0 @@ -// Browser-only Spotify PKCE client. -// Tokens live in localStorage; no server proxy. - -// Client ID resolution: user-supplied (localStorage) takes precedence over the -// env-baked default. Lets people drop in their own Spotify app without -// redeploying. -const CLIENT_ID_KEY = 'sp_client_id'; -function clientId(): string { - if (typeof window !== 'undefined') { - const stored = localStorage.getItem(CLIENT_ID_KEY); - if (stored) return stored; - } - return process.env.NEXT_PUBLIC_SPOTIFY_CLIENT_ID || ''; -} -export function getStoredClientId(): string { - if (typeof window === 'undefined') return ''; - return localStorage.getItem(CLIENT_ID_KEY) || ''; -} -export function setStoredClientId(id: string) { - const v = (id || '').trim(); - if (v) localStorage.setItem(CLIENT_ID_KEY, v); - else localStorage.removeItem(CLIENT_ID_KEY); -} -const SCOPES = [ - 'user-read-playback-state', - 'user-modify-playback-state', - 'user-read-currently-playing', - 'playlist-read-private', - 'playlist-read-collaborative', -].join(' '); - -const LS_ACCESS = 'sp_access'; -const LS_REFRESH = 'sp_refresh'; -const LS_EXPIRES = 'sp_expires'; -const SS_VERIFIER = 'sp_verifier'; -const SS_STATE = 'sp_state'; - -function redirectUri() { - return `${window.location.origin}/callback`; -} - -function b64url(bytes: Uint8Array) { - let str = ''; - for (const b of bytes) str += String.fromCharCode(b); - return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); -} - -function randomVerifier(len = 64) { - const arr = new Uint8Array(len); - crypto.getRandomValues(arr); - return b64url(arr); -} - -async function challenge(verifier: string) { - const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)); - return b64url(new Uint8Array(hash)); -} - -export async function login() { - // PKCE: verifier + code_challenge protects the code exchange. - // state: random nonce stored in sessionStorage, echoed back by Spotify on - // the redirect; verified in /callback. Closes the CSRF window that PKCE - // alone doesn't cover (the flow *initiation*). - const verifier = randomVerifier(); - const state = randomVerifier(16); - sessionStorage.setItem(SS_VERIFIER, verifier); - sessionStorage.setItem(SS_STATE, state); - const code_challenge = await challenge(verifier); - const params = new URLSearchParams({ - client_id: clientId(), - response_type: 'code', - redirect_uri: redirectUri(), - code_challenge_method: 'S256', - code_challenge, - state, - scope: SCOPES, - }); - window.location.href = `https://accounts.spotify.com/authorize?${params}`; -} - -export function consumeState(): string | null { - const s = sessionStorage.getItem(SS_STATE); - sessionStorage.removeItem(SS_STATE); - return s; -} - -// Clears the Spotify session (tokens) and any session-scoped data. Keeps the -// stored Client ID + selected playlists so the user doesn't have to redo -// onboarding when they log back in. -export function logout() { - localStorage.removeItem(LS_ACCESS); - localStorage.removeItem(LS_REFRESH); - localStorage.removeItem(LS_EXPIRES); - clearTracksCache(); -} - -export async function exchangeCode(code: string) { - const verifier = sessionStorage.getItem(SS_VERIFIER); - if (!verifier) throw new Error('missing PKCE verifier'); - const res = await fetch('https://accounts.spotify.com/api/token', { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - client_id: clientId(), - grant_type: 'authorization_code', - code, - redirect_uri: redirectUri(), - code_verifier: verifier, - }), - }); - if (!res.ok) throw new Error(`token exchange failed: ${await res.text()}`); - const data = await res.json(); - storeTokens(data); - sessionStorage.removeItem(SS_VERIFIER); -} - -function storeTokens(data: any) { - localStorage.setItem(LS_ACCESS, data.access_token); - if (data.refresh_token) localStorage.setItem(LS_REFRESH, data.refresh_token); - localStorage.setItem(LS_EXPIRES, String(Date.now() + (data.expires_in - 30) * 1000)); -} - -async function refresh(): Promise { - const refresh_token = localStorage.getItem(LS_REFRESH); - if (!refresh_token) return null; - const res = await fetch('https://accounts.spotify.com/api/token', { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - client_id: clientId(), - grant_type: 'refresh_token', - refresh_token, - }), - }); - if (!res.ok) { - logout(); - return null; - } - const data = await res.json(); - storeTokens(data); - return data.access_token; -} - -async function getToken(): Promise { - const access = localStorage.getItem(LS_ACCESS); - const expires = Number(localStorage.getItem(LS_EXPIRES) || 0); - if (access && Date.now() < expires) return access; - return refresh(); -} - -export function isLoggedIn() { - return !!localStorage.getItem(LS_REFRESH); -} - -export async function spotify(path: string, init: RequestInit = {}, _retry = 0): Promise { - const token = await getToken(); - if (!token) throw new Error('not authenticated'); - const res = await fetch(`https://api.spotify.com/v1${path}`, { - ...init, - headers: { - ...(init.headers || {}), - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - }); - if (res.status === 204) return null; - if (res.status === 401 && _retry === 0) { - // Token may have been revoked or expired; refresh once and retry. - // _retry guard ensures we never loop on a persistently-bad token. - const t = await refresh(); - if (!t) throw new Error('not authenticated'); - return spotify(path, init, _retry + 1); - } - const text = await res.text(); - if (!res.ok) throw new Error(`${res.status}: ${text}`); - try { return text ? JSON.parse(text) : null; } catch { return text; } -} - -export type JukeTrack = { - uri: string; - name: string; - artist: string; - album: string; - source: 'now' | 'queue' | 'playlist'; - // Spotify context URI (`spotify:playlist:...` / `spotify:album:...`) the track - // was discovered in. When set, play() uses it as context_uri + offset so - // Spotify keeps playing the next track in the playlist after this one ends, - // instead of stopping cold. - contextUri?: string; -}; - -function toTrack(t: any, source: JukeTrack['source'], contextUri?: string): JukeTrack | null { - if (!t?.uri) return null; - return { - uri: t.uri, - name: t.name ?? '', - artist: (t.artists ?? []).map((a: any) => a.name).join(', '), - album: t.album?.name ?? '', - source, - contextUri, - }; -} - -export async function loadAllTracks(playlistIds: string[]) { - const out: JukeTrack[] = []; - const seen = new Set(); - const add = (t: JukeTrack | null) => { - if (!t || seen.has(t.uri)) return; - seen.add(t.uri); - out.push(t); - }; - - let nowItem: any = null; - try { - const now = await spotify('/me/player/currently-playing'); - if (now?.item) { - // now.context?.uri tells us where playback was started from — usually a - // spotify:playlist:... or spotify:album:... URI. Carrying it forward - // means tapping the now-playing card later plays it back IN that - // context (rather than starting a single-track stop-after-this play). - add(toTrack(now.item, 'now', now.context?.uri)); - nowItem = now.item; - } else console.info('[jukebox] now-playing: nothing active'); - } catch (e) { console.error('[jukebox] now-playing failed:', e); } - - try { - const q = await spotify('/me/player/queue'); - if (Array.isArray(q?.queue)) { - console.info(`[jukebox] queue: ${q.queue.length} tracks`); - // Queue items don't carry their original playlist context, so play - // falls back to single-URI play for these. - for (const t of q.queue) add(toTrack(t, 'queue')); - } else { - console.info('[jukebox] queue: empty (no active device?)'); - } - } catch (e) { console.error('[jukebox] queue failed:', e); } - - for (const id of playlistIds) { - try { - // Use /playlists/{id} (not /playlists/{id}/tracks — that sub-endpoint - // 403s for many accounts). The container can show up as either - // `tracks` or `items`, and each row's track as `track` or `item`, - // depending on whether Spotify served the legacy or additional-types - // shape — accept both. - const p: any = await spotify(`/playlists/${id}`); - const container = p?.items ?? p?.tracks; - let items: any[] = container?.items ?? []; - let next: string | null = container?.next ?? null; - while (next) { - const path = next.replace('https://api.spotify.com/v1', ''); - const page: any = await spotify(path); - items = items.concat(page?.items ?? []); - next = page?.next ?? null; - } - const contextUri = `spotify:playlist:${id}`; - for (const it of items) add(toTrack(it.item ?? it.track, 'playlist', contextUri)); - console.info(`[jukebox] playlist ${id}: ${items.length} tracks`); - } catch (e) { - console.error(`[jukebox] playlist ${id} failed:`, e); - } - } - console.info(`[jukebox] total tracks: ${out.length}`); - - return { tracks: out, nowItem }; -} - -export type UserPlaylist = { id: string; name: string; image: string | null; tracks: number }; -export async function getUserPlaylists(): Promise { - const out: UserPlaylist[] = []; - let url: string | null = '/me/playlists?limit=50'; - while (url) { - const p: any = await spotify(url); - if (Array.isArray(p?.items)) { - for (const it of p.items) { - // Spotify returns count under `tracks.total` in the legacy shape and - // `items.total` in the newer additional_types-aware shape. - const total = it?.tracks?.total ?? it?.items?.total ?? 0; - out.push({ - id: it.id, - name: it.name, - image: it.images?.[0]?.url ?? null, - tracks: total, - }); - } - } - url = p?.next ? p.next.replace('https://api.spotify.com/v1', '') : null; - } - return out; -} - -// --- localStorage cache for selected playlists + their fetched tracks --- -const SEL_KEY = 'jukebox_selected_playlists'; -// Bumped to v2 when JukeTrack gained `contextUri` (for playlist-context play). -// Caches written under the old key are silently abandoned — the user picks up -// fresh data on next load and gets context-aware playback automatically. -const TRACKS_KEY = 'jukebox_tracks_cache_v2'; -const TRACKS_TTL_MS = 60 * 60 * 1000; // 1 hour - -export function getSelectedPlaylists(): string[] { - try { return JSON.parse(localStorage.getItem(SEL_KEY) || '[]'); } catch { return []; } -} -export function saveSelectedPlaylists(ids: string[]) { - localStorage.setItem(SEL_KEY, JSON.stringify(ids)); -} -function cacheKey(ids: string[]) { return [...ids].sort().join(','); } -export function getCachedTracks(ids: string[]): { tracks: JukeTrack[]; nowItem: any } | null { - try { - const raw = localStorage.getItem(TRACKS_KEY); - if (!raw) return null; - const obj = JSON.parse(raw); - if (obj.idsKey !== cacheKey(ids)) return null; - if (Date.now() - obj.timestamp > TRACKS_TTL_MS) return null; - // Schema guard: every playlist-sourced track must have a contextUri so - // play() can use it for the context_uri + offset request. If even one - // is missing the cache predates the contextUri rollout — discard the - // whole entry and let loadAllTracks repopulate it. - const tracks: JukeTrack[] = obj.tracks ?? []; - if (tracks.some((t) => t.source === 'playlist' && !t.contextUri)) return null; - return { tracks, nowItem: obj.nowItem }; - } catch { return null; } -} -export function setCachedTracks(ids: string[], tracks: JukeTrack[], nowItem: any) { - try { - localStorage.setItem(TRACKS_KEY, JSON.stringify({ - idsKey: cacheKey(ids), timestamp: Date.now(), tracks, nowItem, - })); - } catch {} -} -export function clearTracksCache() { localStorage.removeItem(TRACKS_KEY); } -// IDs that are currently part of a live (non-expired) tracks cache. Used by -// the playlist picker to show a "cached" badge per row. -export function getCachedPlaylistIds(): string[] { - try { - const raw = localStorage.getItem(TRACKS_KEY); - if (!raw) return []; - const obj = JSON.parse(raw); - if (Date.now() - obj.timestamp > TRACKS_TTL_MS) return []; - return String(obj.idsKey || '').split(',').filter(Boolean); - } catch { return []; } -} - -// Toggle Spotify's shuffle state for the user's playback. 204 No Content on -// success; 404 if there's no active device — we ignore that and let the -// caller no-op, since setting shuffle without playback is meaningless. -export async function setShuffle(state: boolean) { - return spotify(`/me/player/shuffle?state=${state}`, { method: 'PUT' }); -} - -// Append a track to the Spotify playback queue. 204 on success, 404 if no -// active device. Caller should ensure there's an active device first (we -// surface this via the "tap a card to start" UX before queueing is useful). -export async function addToQueue(uri: string) { - return spotify(`/me/player/queue?uri=${encodeURIComponent(uri)}`, { method: 'POST' }); -} - -// List Spotify devices the user's account knows about (active or not). -// Used to recover from NO_ACTIVE_DEVICE by transferring playback to one of -// them before re-issuing the play command. -export async function getDevices(): Promise<{ devices: Array<{ id: string; name: string; is_active: boolean; type: string }> }> { - return spotify('/me/player/devices'); -} - -// Move playback to a specific device. With `play: false`, just hands the -// session over without starting; the subsequent play() call provides the -// actual content. Spotify needs a brief moment after this returns before -// /me/player/play stops 404ing on the same device. -export async function transferPlayback(deviceId: string, play = false) { - return spotify('/me/player', { - method: 'PUT', - body: JSON.stringify({ device_ids: [deviceId], play }), - }); -} - -// Play a track. If `contextUri` is supplied (typically the playlist or album -// the track was discovered in), Spotify plays the track within that context -// and auto-continues to the next track when this one ends — so the user gets -// a continuous listening session instead of "song plays, silence". -// -// Without `contextUri`, single-URI play is used (the legacy stop-after-this -// behavior), or — if `uri` is a context URI itself — that context is played -// from the start. -// -// Auto-recovery: Spotify's play endpoint 404s with NO_ACTIVE_DEVICE when no -// device is currently active for the account. If we see that, we look up the -// account's known devices, transfer playback to whichever one Spotify lists -// first (typically the most-recently-used), wait a beat for the handover to -// settle, then retry. If the account has zero known devices, we re-throw -// with a clearer error so the UI can tell the user to open Spotify somewhere. -export async function play(uri?: string, contextUri?: string) { - let body: any; - if (uri && contextUri) { - body = { context_uri: contextUri, offset: { uri } }; - } else if (uri) { - body = uri.startsWith('spotify:track:') ? { uris: [uri] } : { context_uri: uri }; - } - const send = () => spotify('/me/player/play', { - method: 'PUT', - body: body ? JSON.stringify(body) : undefined, - }); - try { - return await send(); - } catch (err: any) { - const msg = String(err?.message ?? ''); - if (!/404|NO_ACTIVE_DEVICE/.test(msg)) throw err; - const list = await getDevices().catch(() => null); - const device = list?.devices?.[0]; - if (!device) { - throw new Error('No Spotify devices found. Open Spotify on a phone, desktop, or web player and try again.'); - } - await transferPlayback(device.id, false); - // Spotify takes ~150-300ms to register the new active device; the play - // endpoint will keep 404ing until then. - await new Promise((r) => setTimeout(r, 350)); - return send(); - } -} -export const nowPlaying = () => spotify('/me/player/currently-playing'); diff --git a/middleware.ts b/middleware.ts deleted file mode 100644 index f914b17..0000000 --- a/middleware.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { type NextRequest, NextResponse } from 'next/server'; - -// Make app routes case-insensitive. Any request with uppercase letters in the -// pathname is 308-redirected to the lowercase version. clone() preserves the -// search params automatically — we only mutate `pathname` below — and hashes -// never reach the server. -export function middleware(req: NextRequest) { - const { pathname } = req.nextUrl; - if (pathname === pathname.toLowerCase()) return NextResponse.next(); - const url = req.nextUrl.clone(); - url.pathname = pathname.toLowerCase(); - return NextResponse.redirect(url, 308); -} - -export const config = { - // Skip static/internal assets so we don't redirect e.g. /Image.png unnecessarily. - matcher: ['/((?!_next/|api/|.*\\..*).*)'], -}; diff --git a/next-env.d.ts b/next-env.d.ts deleted file mode 100644 index 830fb59..0000000 --- a/next-env.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/// -/// -/// - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/next.config.mjs b/next.config.mjs deleted file mode 100644 index e442fd5..0000000 --- a/next.config.mjs +++ /dev/null @@ -1,11 +0,0 @@ -/** @type {import('next').NextConfig} */ -const nextConfig = { - allowedDevOrigins: [ - '*.devtunnels.ms', - '*.pinggy.link', - '*.pinggy-free.link', - '*.run.pinggy-free.link', - '*.run.pinggy.link', - ], -}; -export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 3d0508e..6cf3f06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,28 +1,94 @@ { "name": "juke.sh", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "juke.sh", - "version": "0.1.0", + "version": "0.2.0", "license": "PIF", + "workspaces": [ + "packages/juke-cards", + "packages/jukebox", + "packages/bridges" + ], + "devDependencies": { + "@tishlang/tish": "^1.10.0" + } + }, + "node_modules/@actions/core": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.1.tgz", + "integrity": "sha512-a6d/Nwahm9fliVGRhdhofo40HjHQasUPusmc7vBfyky+7Z+P2A1J68zyFVaNcEclc/Se+eO595oAr5nwEIoIUA==", + "dev": true, + "license": "MIT", "dependencies": { - "lucide-react": "^0.297.0", - "next": "^15.0.0", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "three": "^0.184.0", - "tone": "^15.1.22" + "@actions/exec": "^3.0.0", + "@actions/http-client": "^4.0.0" + } + }, + "node_modules/@actions/exec": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@actions/exec/-/exec-3.0.0.tgz", + "integrity": "sha512-6xH/puSoNBXb72VPlZVm7vQ+svQpFyA96qdDBvhB8eNZOE8LtPf9L4oAsfzK/crCL8YZ+19fKYVnM63Sl+Xzlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/io": "^3.0.2" + } + }, + "node_modules/@actions/http-client": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-4.0.1.tgz", + "integrity": "sha512-+Nvd1ImaOZBSoPbsUtEhv+1z99H12xzncCkz0a3RuehINE81FZSe2QTj3uvAPTcJX/SCzUQHQ0D1GrPMbrPitg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tunnel": "^0.0.6", + "undici": "^6.23.0" + } + }, + "node_modules/@actions/http-client/node_modules/undici": { + "version": "6.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz", + "integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/@actions/io": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@actions/io/-/io-3.0.2.tgz", + "integrity": "sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" }, - "devDependencies": { - "@biomejs/biome": "2.4.15", - "@types/node": "^22", - "@types/react": "^19", - "@types/react-dom": "^19", - "@types/three": "^0.184.1", - "typescript": "^5" + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" } }, "node_modules/@babel/runtime": { @@ -34,1214 +100,6157 @@ "node": ">=6.9.0" } }, - "node_modules/@biomejs/biome": { - "version": "2.4.15", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.15.tgz", - "integrity": "sha512-j5VH3a/h/HXTKBM50MDMxRCzkeLv9S2XJcW2WgnZT1+xyisi+0bISrXR82gCX+8S9lvK0skEvHJRN+3Ktr2hlw==", + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", "dev": true, - "license": "MIT OR Apache-2.0", - "bin": { - "biome": "bin/biome" - }, + "license": "MIT", + "optional": true, "engines": { - "node": ">=14.21.3" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/biome" - }, - "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "2.4.15", - "@biomejs/cli-darwin-x64": "2.4.15", - "@biomejs/cli-linux-arm64": "2.4.15", - "@biomejs/cli-linux-arm64-musl": "2.4.15", - "@biomejs/cli-linux-x64": "2.4.15", - "@biomejs/cli-linux-x64-musl": "2.4.15", - "@biomejs/cli-win32-arm64": "2.4.15", - "@biomejs/cli-win32-x64": "2.4.15" - } - }, - "node_modules/@biomejs/cli-darwin-arm64": { - "version": "2.4.15", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.15.tgz", - "integrity": "sha512-rF3PPqLq1yoST79zaQbDjVJwsuIeci/O+9bgNmC5QpgOqz6aqYuzA4abyAGx+mgyiDXn4A049xAN8gijbuR1Qg==", + "node": ">=0.1.90" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", "cpu": [ - "arm64" + "ppc64" ], "dev": true, - "license": "MIT OR Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "darwin" + "aix" ], "engines": { - "node": ">=14.21.3" + "node": ">=18" } }, - "node_modules/@biomejs/cli-darwin-x64": { - "version": "2.4.15", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.15.tgz", - "integrity": "sha512-/5KHXYMfSJs1fNXiX30xFtI8JcCFV6zaVVLxOa0M2sfqBKHkpQhRTv94yxQWxeTY2lzo2OuTlNvPC+hDQt2wcQ==", + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", "cpu": [ - "x64" + "arm" ], "dev": true, - "license": "MIT OR Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "darwin" + "android" ], "engines": { - "node": ">=14.21.3" + "node": ">=18" } }, - "node_modules/@biomejs/cli-linux-arm64": { - "version": "2.4.15", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.15.tgz", - "integrity": "sha512-owaAMZD/T4LrD0ELNCk0Km3qrRHuM0X6EAyVE1FSqGY0rbLoiDLrO4Us2tllm6cAeB2Ioa9C2C08NZPdr8+0Ug==", + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT OR Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "linux" + "android" ], "engines": { - "node": ">=14.21.3" + "node": ">=18" } }, - "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "2.4.15", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.15.tgz", - "integrity": "sha512-ZPcxznxm0pogHBLZhYntyR3sR+MrZjqJIKEr7ZqVen0Rl+P/4upVmfYXjftizi9RoqZntg33fv/1fbdhbYXpEQ==", + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", "cpu": [ - "arm64" + "x64" ], "dev": true, - "license": "MIT OR Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "linux" + "android" ], "engines": { - "node": ">=14.21.3" + "node": ">=18" } }, - "node_modules/@biomejs/cli-linux-x64": { - "version": "2.4.15", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.15.tgz", - "integrity": "sha512-0jj7THz12GbUOLmMibktK6DZjqz2zV64KFxyBtcFTKPiiOIY0a7vns1elpO1dERvxpsZ5ik0oFfz0oGwFde1+g==", + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", "cpu": [ - "x64" + "arm64" ], "dev": true, - "license": "MIT OR Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "linux" + "darwin" ], "engines": { - "node": ">=14.21.3" + "node": ">=18" } }, - "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "2.4.15", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.15.tgz", - "integrity": "sha512-CNq/9W38SYSH023lfcQ4KKU8K0YX8T//FZUhcgtMMRABDojx5XsMV7jlweAvGSl389wJQB29Qo6Zb/a+jdvt+w==", + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", "cpu": [ "x64" ], "dev": true, - "license": "MIT OR Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "linux" + "darwin" ], "engines": { - "node": ">=14.21.3" + "node": ">=18" } }, - "node_modules/@biomejs/cli-win32-arm64": { - "version": "2.4.15", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.15.tgz", - "integrity": "sha512-ouhkYdlhp/1GghEJPdWwD/Vi3gQ1nFxuSpMolWsbq3Lsq3QUR4jl6UdhhscdCugKU5vOEuMiJhvKj66O0OCq+w==", + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT OR Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "win32" + "freebsd" ], "engines": { - "node": ">=14.21.3" + "node": ">=18" } }, - "node_modules/@biomejs/cli-win32-x64": { - "version": "2.4.15", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.15.tgz", - "integrity": "sha512-zBrGq5mx5wwpnow4+2BxUvleDM+GNd4sLbPaMapsSLQLD0NGRCquqPBTgN+7XkUteHvj7M+BstuI8tmnV7+HgQ==", + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", "cpu": [ "x64" ], "dev": true, - "license": "MIT OR Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "win32" + "freebsd" ], "engines": { - "node": ">=14.21.3" + "node": ">=18" } }, - "node_modules/@dimforge/rapier3d-compat": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", - "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], "dev": true, - "license": "Apache-2.0" - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@img/colour": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", - "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "license": "MIT", "optional": true, + "os": [ + "linux" + ], "engines": { "node": ">=18" } }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", "cpu": [ "arm64" ], - "license": "Apache-2.0", + "dev": true, + "license": "MIT", "optional": true, "os": [ - "darwin" + "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" + "node": ">=18" } }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", "cpu": [ - "x64" + "ia32" ], - "license": "Apache-2.0", + "dev": true, + "license": "MIT", "optional": true, "os": [ - "darwin" + "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" + "node": ">=18" } }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", "cpu": [ - "arm64" + "loong64" ], - "license": "LGPL-3.0-or-later", + "dev": true, + "license": "MIT", "optional": true, "os": [ - "darwin" + "linux" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=18" } }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", "cpu": [ - "x64" + "mips64el" ], - "license": "LGPL-3.0-or-later", + "dev": true, + "license": "MIT", "optional": true, "os": [ - "darwin" + "linux" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=18" } }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", "cpu": [ - "arm" + "ppc64" ], - "license": "LGPL-3.0-or-later", + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=18" } }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", "cpu": [ - "arm64" + "riscv64" ], - "license": "LGPL-3.0-or-later", + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=18" } }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", "cpu": [ - "ppc64" + "s390x" ], - "license": "LGPL-3.0-or-later", + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=18" } }, - "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", "cpu": [ - "riscv64" + "x64" ], - "license": "LGPL-3.0-or-later", + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=18" } }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", "cpu": [ - "s390x" + "arm64" ], - "license": "LGPL-3.0-or-later", + "dev": true, + "license": "MIT", "optional": true, "os": [ - "linux" + "netbsd" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=18" } }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", "cpu": [ "x64" ], - "license": "LGPL-3.0-or-later", + "dev": true, + "license": "MIT", "optional": true, "os": [ - "linux" + "netbsd" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=18" } }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", "cpu": [ "arm64" ], - "license": "LGPL-3.0-or-later", + "dev": true, + "license": "MIT", "optional": true, "os": [ - "linux" + "openbsd" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=18" } }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", "cpu": [ "x64" ], - "license": "LGPL-3.0-or-later", + "dev": true, + "license": "MIT", "optional": true, "os": [ - "linux" + "openbsd" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=18" } }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", "cpu": [ - "arm" + "arm64" ], - "license": "Apache-2.0", + "dev": true, + "license": "MIT", "optional": true, "os": [ - "linux" + "openharmony" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" + "node": ">=18" } }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", "cpu": [ - "arm64" + "x64" ], - "license": "Apache-2.0", + "dev": true, + "license": "MIT", "optional": true, "os": [ - "linux" + "sunos" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" + "node": ">=18" } }, - "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", "cpu": [ - "ppc64" + "arm64" ], - "license": "Apache-2.0", + "dev": true, + "license": "MIT", "optional": true, "os": [ - "linux" + "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" + "node": ">=18" } }, - "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", "cpu": [ - "riscv64" + "ia32" ], - "license": "Apache-2.0", + "dev": true, + "license": "MIT", "optional": true, "os": [ - "linux" + "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" + "node": ">=18" } }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", "cpu": [ - "s390x" + "x64" ], - "license": "Apache-2.0", + "dev": true, + "license": "MIT", "optional": true, "os": [ - "linux" + "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=18" + } + }, + "node_modules/@octokit/auth-token": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", + "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", + "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^6.0.0", + "@octokit/graphql": "^9.0.3", + "@octokit/request": "^10.0.6", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "before-after-hook": "^4.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/endpoint": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.3.tgz", + "integrity": "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/graphql": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz", + "integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/request": "^10.0.6", + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", + "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz", + "integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-retry": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-retry/-/plugin-retry-8.1.0.tgz", + "integrity": "sha512-O1FZgXeiGb2sowEr/hYTr6YunGdSAFWnr2fyW39Ah85H8O33ELASQxcvOFF5LE6Tjekcyu2ms4qAzJVhSaJxTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "bottleneck": "^2.15.3" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=7" + } + }, + "node_modules/@octokit/plugin-throttling": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@octokit/plugin-throttling/-/plugin-throttling-11.0.3.tgz", + "integrity": "sha512-34eE0RkFCKycLl2D2kq7W+LovheM/ex3AwZCYN8udpi6bxsyjZidb2McXs69hZhLmJlDqTSP8cH+jSRpiaijBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0", + "bottleneck": "^2.15.3" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": "^7.0.0" + } + }, + "node_modules/@octokit/request": { + "version": "10.0.9", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.9.tgz", + "integrity": "sha512-o8Bi3f608eyM+7BmBiUWxFsdjLb3/ym1cQek5LZOv9KkZcxRrHCPhhRzm6xjO6HVZ85ItD6+sTsjxo821SVa/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^11.0.3", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "content-type": "^2.0.0", + "fast-content-type-parse": "^3.0.0", + "json-with-bigint": "^3.5.3", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/request-error": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz", + "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/types": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", + "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^27.0.0" + } + }, + "node_modules/@pnpm/config.env-replace": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", + "integrity": "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.22.0" + } + }, + "node_modules/@pnpm/network.ca-file": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@pnpm/network.ca-file/-/network.ca-file-1.0.2.tgz", + "integrity": "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "4.2.10" + }, + "engines": { + "node": ">=12.22.0" + } + }, + "node_modules/@pnpm/network.ca-file/node_modules/graceful-fs": { + "version": "4.2.10", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", + "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", + "dev": true, + "license": "ISC" + }, + "node_modules/@pnpm/npm-conf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-3.0.2.tgz", + "integrity": "sha512-h104Kh26rR8tm+a3Qkc5S4VLYint3FE48as7+/5oCEcKR2idC/pF1G6AhIXKI+eHPJa/3J9i5z0Al47IeGHPkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pnpm/config.env-replace": "^1.1.0", + "@pnpm/network.ca-file": "^1.0.1", + "config-chain": "^1.1.11" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@semantic-release/commit-analyzer": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/@semantic-release/commit-analyzer/-/commit-analyzer-13.0.1.tgz", + "integrity": "sha512-wdnBPHKkr9HhNhXOhZD5a2LNl91+hs8CC2vsAVYxtZH3y0dV3wKn+uZSN61rdJQZ8EGxzWB3inWocBHV9+u/CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "conventional-changelog-angular": "^8.0.0", + "conventional-changelog-writer": "^8.0.0", + "conventional-commits-filter": "^5.0.0", + "conventional-commits-parser": "^6.0.0", + "debug": "^4.0.0", + "import-from-esm": "^2.0.0", + "lodash-es": "^4.17.21", + "micromatch": "^4.0.2" + }, + "engines": { + "node": ">=20.8.1" + }, + "peerDependencies": { + "semantic-release": ">=20.1.0" + } + }, + "node_modules/@semantic-release/error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", + "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@semantic-release/github": { + "version": "12.0.8", + "resolved": "https://registry.npmjs.org/@semantic-release/github/-/github-12.0.8.tgz", + "integrity": "sha512-tej5AAgK5X9wHRoDmYhecMXEHEkFeGOY1XsEblKxu8pIQwahzf1STYyr7iPU6Lpbg6C5I3N2w/ocXrBo+L7jhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/core": "^7.0.0", + "@octokit/plugin-paginate-rest": "^14.0.0", + "@octokit/plugin-retry": "^8.0.0", + "@octokit/plugin-throttling": "^11.0.0", + "@semantic-release/error": "^4.0.0", + "aggregate-error": "^5.0.0", + "debug": "^4.3.4", + "dir-glob": "^3.0.1", + "http-proxy-agent": "^9.0.0", + "https-proxy-agent": "^9.0.0", + "issue-parser": "^7.0.0", + "lodash-es": "^4.17.21", + "mime": "^4.0.0", + "p-filter": "^4.0.0", + "tinyglobby": "^0.2.14", + "undici": "^7.0.0", + "url-join": "^5.0.0" + }, + "engines": { + "node": "^22.14.0 || >= 24.10.0" + }, + "peerDependencies": { + "semantic-release": ">=24.1.0" + } + }, + "node_modules/@semantic-release/npm": { + "version": "13.1.5", + "resolved": "https://registry.npmjs.org/@semantic-release/npm/-/npm-13.1.5.tgz", + "integrity": "sha512-Hq5UxzoatN3LHiq2rTsWS54nCdqJHlsssGERCo8WlvdfFA9LoN0vO+OuKVSjtNapIc/S8C2LBj206wKLHg62mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/core": "^3.0.0", + "@semantic-release/error": "^4.0.0", + "aggregate-error": "^5.0.0", + "env-ci": "^11.2.0", + "execa": "^9.0.0", + "fs-extra": "^11.0.0", + "lodash-es": "^4.17.21", + "nerf-dart": "^1.0.0", + "normalize-url": "^9.0.0", + "npm": "^11.6.2", + "rc": "^1.2.8", + "read-pkg": "^10.0.0", + "registry-auth-token": "^5.0.0", + "semver": "^7.1.2", + "tempy": "^3.0.0" + }, + "engines": { + "node": "^22.14.0 || >= 24.10.0" + }, + "peerDependencies": { + "semantic-release": ">=20.1.0" + } + }, + "node_modules/@semantic-release/release-notes-generator": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/@semantic-release/release-notes-generator/-/release-notes-generator-14.1.1.tgz", + "integrity": "sha512-Pbd2e2XRMUD0OxehHpgd5/YghsE76cddkRHSoDvKLK+OCy4Ewxn49rWR631MEUU01lgwF/uyVXvbnVuu6+Z6VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "conventional-changelog-angular": "^8.0.0", + "conventional-changelog-writer": "^8.0.0", + "conventional-commits-filter": "^5.0.0", + "conventional-commits-parser": "^6.0.0", + "debug": "^4.0.0", + "import-from-esm": "^2.0.0", + "lodash-es": "^4.17.21", + "read-package-up": "^11.0.0" + }, + "engines": { + "node": ">=20.8.1" + }, + "peerDependencies": { + "semantic-release": ">=20.1.0" + } + }, + "node_modules/@simple-libs/stream-utils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz", + "integrity": "sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://ko-fi.com/dangreen" + } + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@spacedevin/juke-bridges": { + "resolved": "packages/bridges", + "link": true + }, + "node_modules/@spacedevin/juke-cards": { + "resolved": "packages/juke-cards", + "link": true + }, + "node_modules/@spacedevin/jukebox": { + "resolved": "packages/jukebox", + "link": true + }, + "node_modules/@tishlang/lattish": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@tishlang/lattish/-/lattish-1.1.3.tgz", + "integrity": "sha512-G8Jw73IgMs+7dOSJRiT4Ljbu0ORG5YtFZ2Q8JNmNkDJhCwu/k8uD+lpqodTEApcVyDauhiVwnEd5L1ZJHLdFmA==", + "license": "PIF" + }, + "node_modules/@tishlang/tish": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@tishlang/tish/-/tish-1.10.0.tgz", + "integrity": "sha512-gYukHG9pYsnSHSVB4iIRX86xSg7zg31UvJYjWY6gm+CNgfwV7mj0Vwo/qOH2WbXrGp+RNhAd0tayvuoai31SHg==", + "dev": true, + "hasInstallScript": true, + "license": "PIF", + "bin": { + "tish": "bin/tish" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", + "dev": true, + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/aggregate-error": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-5.0.0.tgz", + "integrity": "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "clean-stack": "^5.2.0", + "indent-string": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/argv-formatter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/argv-formatter/-/argv-formatter-1.0.0.tgz", + "integrity": "sha512-F2+Hkm9xFaRg+GkaNnbwXNDV5O6pnCFEmqyhvfC/Ic5LbgOWjJh3L+mN/s91rxVL3znE7DYVpW0GJFT+4YBgWw==", + "dev": true, + "license": "MIT" + }, + "node_modules/array-ify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", + "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==", + "dev": true, + "license": "MIT" + }, + "node_modules/automation-events": { + "version": "7.1.19", + "resolved": "https://registry.npmjs.org/automation-events/-/automation-events-7.1.19.tgz", + "integrity": "sha512-cD+TLhJTI0q4AI3ktd353lrGZiVa9AchowSDzQzzGjSoYe22js4vlS32VUtWuaulghi1Yq0KYNWKk9wWuGymPA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18.2.0" + } + }, + "node_modules/before-after-hook": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", + "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/bottleneck": { + "version": "2.19.5", + "resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz", + "integrity": "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/clean-stack": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-5.3.0.tgz", + "integrity": "sha512-9ngPTOhYGQqNVSfeJkYXHmF7AGWp4/nN5D/QqNQs3Dvxd1Kk/WpjHfNujKHYUQ/5CoGyOyFNoWSPk5afzP0QVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "5.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-highlight": { + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/cli-highlight/-/cli-highlight-2.1.11.tgz", + "integrity": "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==", + "dev": true, + "license": "ISC", + "dependencies": { + "chalk": "^4.0.0", + "highlight.js": "^10.7.1", + "mz": "^2.4.0", + "parse5": "^5.1.1", + "parse5-htmlparser2-tree-adapter": "^6.0.0", + "yargs": "^16.0.0" + }, + "bin": { + "highlight": "bin/highlight" + }, + "engines": { + "node": ">=8.0.0", + "npm": ">=5.0.0" + } + }, + "node_modules/cli-highlight/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cli-highlight/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-highlight/node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/cli-highlight/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/cli-highlight/node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cli-highlight/node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/cli-table3": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", + "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/compare-func": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", + "integrity": "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-ify": "^1.0.0", + "dot-prop": "^5.1.0" + } + }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/conventional-changelog-angular": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.3.1.tgz", + "integrity": "sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg==", + "dev": true, + "license": "ISC", + "dependencies": { + "compare-func": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/conventional-changelog-writer": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-writer/-/conventional-changelog-writer-8.4.0.tgz", + "integrity": "sha512-HHBFkk1EECxxmCi4CTu091iuDpQv5/OavuCUAuZmrkWpmYfyD816nom1CvtfXJ/uYfAAjavgHvXHX291tSLK8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@simple-libs/stream-utils": "^1.2.0", + "conventional-commits-filter": "^5.0.0", + "handlebars": "^4.7.7", + "meow": "^13.0.0", + "semver": "^7.5.2" + }, + "bin": { + "conventional-changelog-writer": "dist/cli/index.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/conventional-commits-filter": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/conventional-commits-filter/-/conventional-commits-filter-5.0.0.tgz", + "integrity": "sha512-tQMagCOC59EVgNZcC5zl7XqO30Wki9i9J3acbUvkaosCT6JX3EeFwJD7Qqp4MCikRnzS18WXV3BLIQ66ytu6+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/conventional-commits-parser": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.4.0.tgz", + "integrity": "sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@simple-libs/stream-utils": "^1.2.0", + "meow": "^13.0.0" + }, + "bin": { + "conventional-commits-parser": "dist/cli/index.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-hrtime": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/convert-hrtime/-/convert-hrtime-5.0.0.tgz", + "integrity": "sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cosmiconfig": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", + "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cosmiconfig/node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/crypto-random-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-4.0.0.tgz", + "integrity": "sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^1.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/crypto-random-string/node_modules/type-fest": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", + "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dot-prop": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", + "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-obj": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/emojilib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/emojilib/-/emojilib-2.4.0.tgz", + "integrity": "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/env-ci": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/env-ci/-/env-ci-11.2.0.tgz", + "integrity": "sha512-D5kWfzkmaOQDioPmiviWAVtKmpPT4/iJmMVQxWxMPJTFyTkdc5JQUfc5iXEeWxcOdsYTKSAiA/Age4NUOqKsRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^8.0.0", + "java-properties": "^1.0.2" + }, + "engines": { + "node": "^18.17 || >=20.6.1" + } + }, + "node_modules/env-ci/node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/env-ci/node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/env-ci/node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/env-ci/node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/env-ci/node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/env-ci/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/env-ci/node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/execa": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/fast-content-type-parse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", + "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", + "integrity": "sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/find-up-simple": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz", + "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-versions": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/find-versions/-/find-versions-6.0.0.tgz", + "integrity": "sha512-2kCCtc+JvcZ86IGAz3Z2Y0A1baIz9fL31pH/0S1IqZr9Iwnjq8izfPtrCyQKO6TLMPELLsQMre7VDqeIKCsHkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver-regex": "^4.0.5", + "super-regex": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fs-extra": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", + "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/function-timeout": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/function-timeout/-/function-timeout-1.0.2.tgz", + "integrity": "sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/git-log-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/git-log-parser/-/git-log-parser-1.2.1.tgz", + "integrity": "sha512-PI+sPDvHXNPl5WNOErAK05s3j0lgwUzMN6o8cyQrDaKfT3qd7TmNJKeXX+SknI5I0QhG5fVPAEwSY4tRGDtYoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "argv-formatter": "~1.0.0", + "spawn-error-forwarder": "~1.0.0", + "split2": "~1.0.0", + "stream-combiner2": "~1.1.1", + "through2": "~2.0.0", + "traverse": "0.6.8" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/hook-std": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hook-std/-/hook-std-4.0.0.tgz", + "integrity": "sha512-IHI4bEVOt3vRUDJ+bFA9VUJlo7SzvFARPNLw75pqSmAOP2HmTWfFJtPvLBrDrlgjEYXY9zs7SFdHPQaJShkSCQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.0.0.tgz", + "integrity": "sha512-FcF8VhXYLQcxWCnt/cCpT2apKsRDUGeVEeMqGu4HSTu29U8Yw0TLOjdYIlDsYk3IkUh+taX4IDWpPcCqKDhCjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/https-proxy-agent": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.0.0.tgz", + "integrity": "sha512-/MVmHp58WkOypgFhCLk4fzpPcFQvTJ/e6LBI7irpIO2HfxUbpmYoHF+KzipzJpxxzJu7aJNWQ0xojJ/dzV2G5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/import-from-esm": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/import-from-esm/-/import-from-esm-2.0.0.tgz", + "integrity": "sha512-YVt14UZCgsX1vZQ3gKjkWVdBdHQ6eu3MPU1TBgL1H5orXe2+jWD006WCPPtOuwlQm10NuzOW5WawiF1Q9veW8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "import-meta-resolve": "^4.0.0" + }, + "engines": { + "node": ">=18.20" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/issue-parser": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/issue-parser/-/issue-parser-7.0.2.tgz", + "integrity": "sha512-7atWPjhGEIX3JEtMrOYd8TKzboYlq+5sNbdl9POiLYOI14G5HZiQbZP0Xj5EZdrufQVXfJlpTV0hys0CuxwxZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash.capitalize": "^4.2.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.uniqby": "^4.7.0" + }, + "engines": { + "node": "^18.17 || >=20.6.1" + } + }, + "node_modules/java-properties": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/java-properties/-/java-properties-1.0.2.tgz", + "integrity": "sha512-qjdpeo2yKlYTH7nFdK0vbZWuTCesk4o63v5iVOlhMQPfuIZQfW/HI35SjfhA+4qpg36rnFSvUK5b1m+ckIblQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-with-bigint": { + "version": "3.5.8", + "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.8.tgz", + "integrity": "sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/load-json-file/node_modules/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/locate-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", + "integrity": "sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^2.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.capitalize": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/lodash.capitalize/-/lodash.capitalize-4.2.1.tgz", + "integrity": "sha512-kZzYOKspf8XVX5AvmQF94gQW0lejFVgb80G85bU4ZWzoJ6C03PQg3coYAUpSTpQWelrZELd3XWgHzw4Ck5kaIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.uniqby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz", + "integrity": "sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/make-asynchronous": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/make-asynchronous/-/make-asynchronous-1.1.0.tgz", + "integrity": "sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-event": "^6.0.0", + "type-fest": "^4.6.0", + "web-worker": "^1.5.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/marked-terminal": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/marked-terminal/-/marked-terminal-7.3.0.tgz", + "integrity": "sha512-t4rBvPsHc57uE/2nJOLmMbZCQ4tgAccAED3ngXQqW6g+TxA488JzJ+FK3lQkzBQOI1mRV/r/Kq+1ZlJ4D0owQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "ansi-regex": "^6.1.0", + "chalk": "^5.4.1", + "cli-highlight": "^2.1.11", + "cli-table3": "^0.6.5", + "node-emoji": "^2.2.0", + "supports-hyperlinks": "^3.1.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "marked": ">=1 <16" + } + }, + "node_modules/meow": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", + "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz", + "integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa" + ], + "license": "MIT", + "bin": { + "mime": "bin/cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nerf-dart": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/nerf-dart/-/nerf-dart-1.0.0.tgz", + "integrity": "sha512-EZSPZB70jiVsivaBLYDCyntd5eH8NTSMOn3rB+HxwdmKThGELLdYv8qVIMWvZEFy9w8ZZpW9h9OB32l1rGtj7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-emoji": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-2.2.0.tgz", + "integrity": "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.6.0", + "char-regex": "^1.0.2", + "emojilib": "^2.4.0", + "skin-tone": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-package-data": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-8.0.0.tgz", + "integrity": "sha512-RWk+PI433eESQ7ounYxIp67CYuVsS1uYSonX3kA6ps/3LWfjVQa/ptEg6Y3T6uAMq1mWpX9PQ+qx+QaHpsc7gQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^9.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/normalize-url": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-9.0.1.tgz", + "integrity": "sha512-ARftfC5HdUNu9jJeL8pHj8debUIHA2b91FizCoMzY4lG6dDX13jdvTK0TBe24IBDRf2HvJSzzwEPvmbkQWHRSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm": { + "version": "11.14.1", + "resolved": "https://registry.npmjs.org/npm/-/npm-11.14.1.tgz", + "integrity": "sha512-aopNZ0eEl6LbxoFcrXLmTEPzNBNxfiQnVgR9RmJBqzm+5h5pFoOmRljpRJbsXxocBeSl7GLcx3MoDf2UlEOjZw==", + "bundleDependencies": [ + "@isaacs/string-locale-compare", + "@npmcli/arborist", + "@npmcli/config", + "@npmcli/fs", + "@npmcli/map-workspaces", + "@npmcli/metavuln-calculator", + "@npmcli/package-json", + "@npmcli/promise-spawn", + "@npmcli/redact", + "@npmcli/run-script", + "@sigstore/tuf", + "abbrev", + "archy", + "cacache", + "chalk", + "ci-info", + "fastest-levenshtein", + "fs-minipass", + "glob", + "graceful-fs", + "hosted-git-info", + "ini", + "init-package-json", + "is-cidr", + "json-parse-even-better-errors", + "libnpmaccess", + "libnpmdiff", + "libnpmexec", + "libnpmfund", + "libnpmorg", + "libnpmpack", + "libnpmpublish", + "libnpmsearch", + "libnpmteam", + "libnpmversion", + "make-fetch-happen", + "minimatch", + "minipass", + "minipass-pipeline", + "ms", + "node-gyp", + "nopt", + "npm-audit-report", + "npm-install-checks", + "npm-package-arg", + "npm-pick-manifest", + "npm-profile", + "npm-registry-fetch", + "npm-user-validate", + "p-map", + "pacote", + "parse-conflict-json", + "proc-log", + "qrcode-terminal", + "read", + "semver", + "spdx-expression-parse", + "ssri", + "supports-color", + "tar", + "text-table", + "tiny-relative-date", + "treeverse", + "validate-npm-package-name", + "which" + ], + "dev": true, + "license": "Artistic-2.0", + "workspaces": [ + "docs", + "smoke-tests", + "mock-globals", + "mock-registry", + "workspaces/*" + ], + "dependencies": { + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/arborist": "^9.5.0", + "@npmcli/config": "^10.9.0", + "@npmcli/fs": "^5.0.0", + "@npmcli/map-workspaces": "^5.0.3", + "@npmcli/metavuln-calculator": "^9.0.3", + "@npmcli/package-json": "^7.0.5", + "@npmcli/promise-spawn": "^9.0.1", + "@npmcli/redact": "^4.0.0", + "@npmcli/run-script": "^10.0.4", + "@sigstore/tuf": "^4.0.2", + "abbrev": "^4.0.0", + "archy": "~1.0.0", + "cacache": "^20.0.4", + "chalk": "^5.6.2", + "ci-info": "^4.4.0", + "fastest-levenshtein": "^1.0.16", + "fs-minipass": "^3.0.3", + "glob": "^13.0.6", + "graceful-fs": "^4.2.11", + "hosted-git-info": "^9.0.2", + "ini": "^6.0.0", + "init-package-json": "^8.2.5", + "is-cidr": "^6.0.4", + "json-parse-even-better-errors": "^5.0.0", + "libnpmaccess": "^10.0.3", + "libnpmdiff": "^8.1.7", + "libnpmexec": "^10.2.7", + "libnpmfund": "^7.0.21", + "libnpmorg": "^8.0.1", + "libnpmpack": "^9.1.7", + "libnpmpublish": "^11.1.3", + "libnpmsearch": "^9.0.1", + "libnpmteam": "^8.0.2", + "libnpmversion": "^8.0.3", + "make-fetch-happen": "^15.0.5", + "minimatch": "^10.2.5", + "minipass": "^7.1.3", + "minipass-pipeline": "^1.2.4", + "ms": "^2.1.2", + "node-gyp": "^12.3.0", + "nopt": "^9.0.0", + "npm-audit-report": "^7.0.0", + "npm-install-checks": "^8.0.0", + "npm-package-arg": "^13.0.2", + "npm-pick-manifest": "^11.0.3", + "npm-profile": "^12.0.1", + "npm-registry-fetch": "^19.1.1", + "npm-user-validate": "^4.0.0", + "p-map": "^7.0.4", + "pacote": "^21.5.0", + "parse-conflict-json": "^5.0.1", + "proc-log": "^6.1.0", + "qrcode-terminal": "^0.12.0", + "read": "^5.0.1", + "semver": "^7.7.4", + "spdx-expression-parse": "^4.0.0", + "ssri": "^13.0.1", + "supports-color": "^10.2.2", + "tar": "^7.5.13", + "text-table": "~0.2.0", + "tiny-relative-date": "^2.0.2", + "treeverse": "^3.0.0", + "validate-npm-package-name": "^7.0.2", + "which": "^6.0.1" + }, + "bin": { + "npm": "bin/npm-cli.js", + "npx": "bin/npx-cli.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/@gar/promise-retry": { + "version": "1.0.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/npm/node_modules/@isaacs/string-locale-compare": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/@npmcli/agent": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^11.2.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@npmcli/arborist": { + "version": "9.5.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/fs": "^5.0.0", + "@npmcli/installed-package-contents": "^4.0.0", + "@npmcli/map-workspaces": "^5.0.0", + "@npmcli/metavuln-calculator": "^9.0.2", + "@npmcli/name-from-folder": "^4.0.0", + "@npmcli/node-gyp": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/query": "^5.0.0", + "@npmcli/redact": "^4.0.0", + "@npmcli/run-script": "^10.0.0", + "bin-links": "^6.0.0", + "cacache": "^20.0.1", + "common-ancestor-path": "^2.0.0", + "hosted-git-info": "^9.0.0", + "json-stringify-nice": "^1.1.4", + "lru-cache": "^11.2.1", + "minimatch": "^10.0.3", + "nopt": "^9.0.0", + "npm-install-checks": "^8.0.0", + "npm-package-arg": "^13.0.0", + "npm-pick-manifest": "^11.0.1", + "npm-registry-fetch": "^19.0.0", + "pacote": "^21.0.2", + "parse-conflict-json": "^5.0.1", + "proc-log": "^6.0.0", + "proggy": "^4.0.0", + "promise-all-reject-late": "^1.0.0", + "promise-call-limit": "^3.0.1", + "semver": "^7.3.7", + "ssri": "^13.0.0", + "treeverse": "^3.0.0", + "walk-up-path": "^4.0.0" + }, + "bin": { + "arborist": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@npmcli/config": { + "version": "10.9.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/map-workspaces": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "ci-info": "^4.0.0", + "ini": "^6.0.0", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "walk-up-path": "^4.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@npmcli/fs": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@npmcli/git": { + "version": "7.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "ini": "^6.0.0", + "lru-cache": "^11.2.1", + "npm-pick-manifest": "^11.0.1", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "which": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@npmcli/installed-package-contents": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-bundled": "^5.0.0", + "npm-normalize-package-bin": "^5.0.0" + }, + "bin": { + "installed-package-contents": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@npmcli/map-workspaces": { + "version": "5.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/name-from-folder": "^4.0.0", + "@npmcli/package-json": "^7.0.0", + "glob": "^13.0.0", + "minimatch": "^10.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@npmcli/metavuln-calculator": { + "version": "9.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "cacache": "^20.0.0", + "json-parse-even-better-errors": "^5.0.0", + "pacote": "^21.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@npmcli/name-from-folder": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@npmcli/node-gyp": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@npmcli/package-json": { + "version": "7.0.5", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^7.0.0", + "glob": "^13.0.0", + "hosted-git-info": "^9.0.0", + "json-parse-even-better-errors": "^5.0.0", + "proc-log": "^6.0.0", + "semver": "^7.5.3", + "spdx-expression-parse": "^4.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@npmcli/promise-spawn": { + "version": "9.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "which": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@npmcli/query": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@npmcli/redact": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@npmcli/run-script": { + "version": "10.0.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/node-gyp": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "node-gyp": "^12.1.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@sigstore/bundle": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.5.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@sigstore/core": { + "version": "3.2.0", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@sigstore/protobuf-specs": { + "version": "0.5.1", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@sigstore/sign": { + "version": "4.1.1", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@gar/promise-retry": "^1.0.2", + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.2.0", + "@sigstore/protobuf-specs": "^0.5.0", + "make-fetch-happen": "^15.0.4", + "proc-log": "^6.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@sigstore/tuf": { + "version": "4.0.2", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.5.0", + "tuf-js": "^4.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@sigstore/verify": { + "version": "3.1.0", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.1.0", + "@sigstore/protobuf-specs": "^0.5.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@tufjs/canonical-json": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@tufjs/models": { + "version": "4.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "@tufjs/canonical-json": "2.0.0", + "minimatch": "^10.1.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/abbrev": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/agent-base": { + "version": "7.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/npm/node_modules/aproba": { + "version": "2.1.0", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/archy": { + "version": "1.0.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/balanced-match": { + "version": "4.0.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/npm/node_modules/bin-links": { + "version": "6.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "cmd-shim": "^8.0.0", + "npm-normalize-package-bin": "^5.0.0", + "proc-log": "^6.0.0", + "read-cmd-shim": "^6.0.0", + "write-file-atomic": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/binary-extensions": { + "version": "3.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/brace-expansion": { + "version": "5.0.5", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/npm/node_modules/cacache": { + "version": "20.0.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^5.0.0", + "fs-minipass": "^3.0.0", + "glob": "^13.0.0", + "lru-cache": "^11.1.0", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^13.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/chalk": { + "version": "5.6.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/npm/node_modules/chownr": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/npm/node_modules/ci-info": { + "version": "4.4.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/cidr-regex": { + "version": "5.0.5", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/npm/node_modules/cmd-shim": { + "version": "8.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/common-ancestor-path": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">= 18" + } + }, + "node_modules/npm/node_modules/cssesc": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm/node_modules/debug": { + "version": "4.4.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/npm/node_modules/diff": { + "version": "8.0.4", + "dev": true, + "inBundle": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/npm/node_modules/env-paths": { + "version": "2.2.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/npm/node_modules/exponential-backoff": { + "version": "3.1.3", + "dev": true, + "inBundle": true, + "license": "Apache-2.0" + }, + "node_modules/npm/node_modules/fastest-levenshtein": { + "version": "1.0.16", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/npm/node_modules/fs-minipass": { + "version": "3.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/glob": { + "version": "13.0.6", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/graceful-fs": { + "version": "4.2.11", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/hosted-git-info": { + "version": "9.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/http-cache-semantics": { + "version": "4.2.0", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause" + }, + "node_modules/npm/node_modules/http-proxy-agent": { + "version": "7.0.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/npm/node_modules/https-proxy-agent": { + "version": "7.0.6", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/npm/node_modules/iconv-lite": { + "version": "0.7.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/npm/node_modules/ignore-walk": { + "version": "8.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minimatch": "^10.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/ini": { + "version": "6.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/init-package-json": { + "version": "8.2.5", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/package-json": "^7.0.0", + "npm-package-arg": "^13.0.0", + "promzard": "^3.0.1", + "read": "^5.0.1", + "semver": "^7.7.2", + "validate-npm-package-name": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/ip-address": { + "version": "10.1.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/npm/node_modules/is-cidr": { + "version": "6.0.4", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "cidr-regex": "^5.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/npm/node_modules/isexe": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/npm/node_modules/json-parse-even-better-errors": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/json-stringify-nice": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/jsonparse": { + "version": "1.3.1", + "dev": true, + "engines": [ + "node >= 0.2.0" + ], + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/just-diff": { + "version": "6.0.2", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/just-diff-apply": { + "version": "5.5.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/libnpmaccess": { + "version": "10.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-package-arg": "^13.0.0", + "npm-registry-fetch": "^19.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/libnpmdiff": { + "version": "8.1.7", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^9.5.0", + "@npmcli/installed-package-contents": "^4.0.0", + "binary-extensions": "^3.0.0", + "diff": "^8.0.2", + "minimatch": "^10.0.3", + "npm-package-arg": "^13.0.0", + "pacote": "^21.0.2", + "tar": "^7.5.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/libnpmexec": { + "version": "10.2.7", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/arborist": "^9.5.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/run-script": "^10.0.0", + "ci-info": "^4.0.0", + "npm-package-arg": "^13.0.0", + "pacote": "^21.0.2", + "proc-log": "^6.0.0", + "read": "^5.0.1", + "semver": "^7.3.7", + "signal-exit": "^4.1.0", + "walk-up-path": "^4.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/libnpmfund": { + "version": "7.0.21", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^9.5.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/libnpmorg": { + "version": "8.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "aproba": "^2.0.0", + "npm-registry-fetch": "^19.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/libnpmpack": { + "version": "9.1.7", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^9.5.0", + "@npmcli/run-script": "^10.0.0", + "npm-package-arg": "^13.0.0", + "pacote": "^21.0.2" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/libnpmpublish": { + "version": "11.1.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/package-json": "^7.0.0", + "ci-info": "^4.0.0", + "npm-package-arg": "^13.0.0", + "npm-registry-fetch": "^19.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.7", + "sigstore": "^4.0.0", + "ssri": "^13.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/libnpmsearch": { + "version": "9.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-registry-fetch": "^19.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/libnpmteam": { + "version": "8.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "aproba": "^2.0.0", + "npm-registry-fetch": "^19.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/libnpmversion": { + "version": "8.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^7.0.0", + "@npmcli/run-script": "^10.0.0", + "json-parse-even-better-errors": "^5.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.7" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/lru-cache": { + "version": "11.3.5", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/npm/node_modules/make-fetch-happen": { + "version": "15.0.5", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/agent": "^4.0.0", + "@npmcli/redact": "^4.0.0", + "cacache": "^20.0.1", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^5.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^6.0.0", + "ssri": "^13.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/minimatch": { + "version": "10.2.5", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/minipass": { + "version": "7.1.3", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/npm/node_modules/minipass-collect": { + "version": "2.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/npm/node_modules/minipass-fetch": { + "version": "5.0.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^2.0.0", + "minizlib": "^3.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + }, + "optionalDependencies": { + "iconv-lite": "^0.7.2" + } + }, + "node_modules/npm/node_modules/minipass-flush": { + "version": "1.0.6", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minipass": "^7.1.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/npm/node_modules/minipass-pipeline": { + "version": "1.2.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minipass-pipeline/node_modules/yallist": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/minipass-sized": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minizlib": { + "version": "3.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/npm/node_modules/ms": { + "version": "2.1.3", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/mute-stream": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/negotiator": { + "version": "1.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/npm/node_modules/node-gyp": { + "version": "12.3.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/nopt": { + "version": "9.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/npm-audit-report": { + "version": "7.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/npm-bundled": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-normalize-package-bin": "^5.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/npm-install-checks": { + "version": "8.0.0", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "semver": "^7.1.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/npm-normalize-package-bin": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/npm-package-arg": { + "version": "13.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "hosted-git-info": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/npm-packlist": { + "version": "10.0.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "ignore-walk": "^8.0.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/npm-pick-manifest": { + "version": "11.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-install-checks": "^8.0.0", + "npm-normalize-package-bin": "^5.0.0", + "npm-package-arg": "^13.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/npm-profile": { + "version": "12.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-registry-fetch": "^19.0.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/npm-registry-fetch": { + "version": "19.1.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/redact": "^4.0.0", + "jsonparse": "^1.3.1", + "make-fetch-happen": "^15.0.0", + "minipass": "^7.0.2", + "minipass-fetch": "^5.0.0", + "minizlib": "^3.0.1", + "npm-package-arg": "^13.0.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/npm-user-validate": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/p-map": { + "version": "7.0.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/pacote": { + "version": "21.5.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/git": "^7.0.0", + "@npmcli/installed-package-contents": "^4.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "@npmcli/run-script": "^10.0.0", + "cacache": "^20.0.0", + "fs-minipass": "^3.0.0", + "minipass": "^7.0.2", + "npm-package-arg": "^13.0.0", + "npm-packlist": "^10.0.1", + "npm-pick-manifest": "^11.0.1", + "npm-registry-fetch": "^19.0.0", + "proc-log": "^6.0.0", + "sigstore": "^4.0.0", + "ssri": "^13.0.0", + "tar": "^7.4.3" + }, + "bin": { + "pacote": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/parse-conflict-json": { + "version": "5.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "json-parse-even-better-errors": "^5.0.0", + "just-diff": "^6.0.0", + "just-diff-apply": "^5.2.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/path-scurry": { + "version": "2.0.2", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/postcss-selector-parser": { + "version": "7.1.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm/node_modules/proc-log": { + "version": "6.1.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/proggy": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/promise-all-reject-late": { + "version": "1.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/promise-call-limit": { + "version": "3.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/promzard": { + "version": "3.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "read": "^5.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/qrcode-terminal": { + "version": "0.12.0", + "dev": true, + "inBundle": true, + "bin": { + "qrcode-terminal": "bin/qrcode-terminal.js" + } + }, + "node_modules/npm/node_modules/read": { + "version": "5.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "mute-stream": "^3.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/read-cmd-shim": { + "version": "6.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/safer-buffer": { + "version": "2.1.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true + }, + "node_modules/npm/node_modules/semver": { + "version": "7.7.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/signal-exit": { + "version": "4.1.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/sigstore": { + "version": "4.1.0", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.1.0", + "@sigstore/protobuf-specs": "^0.5.0", + "@sigstore/sign": "^4.1.0", + "@sigstore/tuf": "^4.0.1", + "@sigstore/verify": "^3.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/smart-buffer": { + "version": "4.2.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/npm/node_modules/socks": { + "version": "2.8.8", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/npm/node_modules/socks-proxy-agent": { + "version": "8.0.5", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/npm/node_modules/spdx-exceptions": { + "version": "2.5.0", + "dev": true, + "inBundle": true, + "license": "CC-BY-3.0" + }, + "node_modules/npm/node_modules/spdx-expression-parse": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/npm/node_modules/spdx-license-ids": { + "version": "3.0.23", + "dev": true, + "inBundle": true, + "license": "CC0-1.0" + }, + "node_modules/npm/node_modules/ssri": { + "version": "13.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/supports-color": { + "version": "10.2.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/npm/node_modules/tar": { + "version": "7.5.13", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/npm/node_modules/text-table": { + "version": "0.2.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/tiny-relative-date": { + "version": "2.0.2", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/tinyglobby": { + "version": "0.2.16", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/npm/node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/npm/node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/npm/node_modules/treeverse": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/tuf-js": { + "version": "4.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "@tufjs/models": "4.1.0", + "debug": "^4.4.3", + "make-fetch-happen": "^15.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/undici": { + "version": "6.25.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/npm/node_modules/util-deprecate": { + "version": "1.0.2", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/validate-npm-package-name": { + "version": "7.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/walk-up-path": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/npm/node_modules/which": { + "version": "6.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/write-file-atomic": { + "version": "7.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/yallist": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-each-series": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-each-series/-/p-each-series-3.0.0.tgz", + "integrity": "sha512-lastgtAdoH9YaLyDa5i5z64q+kzOcQHsQ5SsZJD3q0VEyI8mq872S3geuNbRUQLVAE9siMfgKrpj7MloKFHruw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-event": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/p-event/-/p-event-6.0.1.tgz", + "integrity": "sha512-Q6Bekk5wpzW5qIyUP4gdMEujObYstZl6DMMOSenwBvV0BlE5LkDwkjs5yHbZmdCEq2o4RJx4tE1vwxFVf2FG1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-timeout": "^6.1.2" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-filter": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-filter/-/p-filter-4.1.0.tgz", + "integrity": "sha512-37/tPdZ3oJwHaS3gNJdenCDB3Tz26i9sjhnguBtvN0vYlRIiDNnvTWkuh+0hETV9rLPdJ3rlL3yVOYPIAnM8rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-map": "^7.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz", + "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/p-locate": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz", + "integrity": "sha512-nQja7m7gSKuewoVRen45CtVfODR3crN3goVQ0DDZ9N3yHxgpkuBhZqsaiotSQRrADUrne346peY7kT3TSACykg==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/p-map": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-reduce": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-reduce/-/p-reduce-3.0.0.tgz", + "integrity": "sha512-xsrIUgI0Kn6iyDYm9StOpOeK29XM1aboGji26+QEortiFST1hGZaUQOLhtEbqHErPpGW/aSz6allwK2qcptp0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.4.tgz", + "integrity": "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", + "integrity": "sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", + "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz", + "integrity": "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^6.0.1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter/node_modules/parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pkg-conf": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pkg-conf/-/pkg-conf-2.1.0.tgz", + "integrity": "sha512-C+VUP+8jis7EsQZIhDYmS5qlNtjv2yP4SNtjXK9AP1ZcTRlnSfuumaTnRfYZnYgUUYVIKqL0fRvmUGDV2fmp6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^2.0.0", + "load-json-file": "^4.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pretty-ms": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", + "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/read-package-up": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-11.0.0.tgz", + "integrity": "sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up-simple": "^1.0.0", + "read-pkg": "^9.0.0", + "type-fest": "^4.6.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-package-up/node_modules/hosted-git-info": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/read-package-up/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/read-package-up/node_modules/normalize-package-data": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", + "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^7.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/read-package-up/node_modules/read-pkg": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz", + "integrity": "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.3", + "normalize-package-data": "^6.0.0", + "parse-json": "^8.0.0", + "type-fest": "^4.6.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-package-up/node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-10.1.0.tgz", + "integrity": "sha512-I8g2lArQiP78ll51UeMZojewtYgIRCKCWqZEgOO8c/uefTI+XDXvCSXu3+YNUaTNvZzobrL5+SqHjBrByRRTdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.4", + "normalize-package-data": "^8.0.0", + "parse-json": "^8.3.0", + "type-fest": "^5.4.4", + "unicorn-magic": "^0.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg/node_modules/type-fest": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", + "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg/node_modules/unicorn-magic": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.0.tgz", + "integrity": "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/registry-auth-token": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-5.1.1.tgz", + "integrity": "sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pnpm/npm-conf": "^3.0.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/semantic-release": { + "version": "25.0.3", + "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-25.0.3.tgz", + "integrity": "sha512-WRgl5GcypwramYX4HV+eQGzUbD7UUbljVmS+5G1uMwX/wLgYuJAxGeerXJDMO2xshng4+FXqCgyB5QfClV6WjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@semantic-release/commit-analyzer": "^13.0.1", + "@semantic-release/error": "^4.0.0", + "@semantic-release/github": "^12.0.0", + "@semantic-release/npm": "^13.1.1", + "@semantic-release/release-notes-generator": "^14.1.0", + "aggregate-error": "^5.0.0", + "cosmiconfig": "^9.0.0", + "debug": "^4.0.0", + "env-ci": "^11.0.0", + "execa": "^9.0.0", + "figures": "^6.0.0", + "find-versions": "^6.0.0", + "get-stream": "^6.0.0", + "git-log-parser": "^1.2.0", + "hook-std": "^4.0.0", + "hosted-git-info": "^9.0.0", + "import-from-esm": "^2.0.0", + "lodash-es": "^4.17.21", + "marked": "^15.0.0", + "marked-terminal": "^7.3.0", + "micromatch": "^4.0.2", + "p-each-series": "^3.0.0", + "p-reduce": "^3.0.0", + "read-package-up": "^12.0.0", + "resolve-from": "^5.0.0", + "semver": "^7.3.2", + "signale": "^1.2.1", + "yargs": "^18.0.0" + }, + "bin": { + "semantic-release": "bin/semantic-release.js" + }, + "engines": { + "node": "^22.14.0 || >= 24.10.0" + } + }, + "node_modules/semantic-release/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/read-package-up": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-12.0.0.tgz", + "integrity": "sha512-Q5hMVBYur/eQNWDdbF4/Wqqr9Bjvtrw2kjGxxBbKLbx8bVCL8gcArjTy8zDUuLGQicftpMuU0riQNcAsbtOVsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up-simple": "^1.0.1", + "read-pkg": "^10.0.0", + "type-fest": "^5.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/type-fest": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", + "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver-regex": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/semver-regex/-/semver-regex-4.0.5.tgz", + "integrity": "sha512-hunMQrEy1T6Jr2uEVjrAIqjwWcQTgOAcIM52C8MY1EZSD3DDNft04XzvYKPqjED65bNVVko0YI38nYeEHCX3yw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/signale": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/signale/-/signale-1.4.0.tgz", + "integrity": "sha512-iuh+gPf28RkltuJC7W5MRi6XAjTDCAPC/prJUpQoG4vIP3MJZ+GTydVnodXA7pwvTKb2cA0m9OFZW/cdWy/I/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^2.3.2", + "figures": "^2.0.0", + "pkg-conf": "^2.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/signale/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/signale/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/signale/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/signale/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/signale/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/signale/node_modules/figures": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz", + "integrity": "sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/signale/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/signale/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/skin-tone": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/skin-tone/-/skin-tone-2.0.0.tgz", + "integrity": "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "unicode-emoji-modifier-base": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/spawn-error-forwarder": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/spawn-error-forwarder/-/spawn-error-forwarder-1.0.0.tgz", + "integrity": "sha512-gRjMgK5uFjbCvdibeGJuy3I5OYz6VLoVdsOJdA6wV0WlfQVLFueoqMxwwYD9RODdgb6oUIvlRlsyFSiQkMKu0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/split2": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-1.0.0.tgz", + "integrity": "sha512-NKywug4u4pX/AZBB1FCPzZ6/7O+Xhz1qMVbzTvvKvikjO99oPN87SkK08mEY9P63/5lWjK+wgOOgApnTg5r6qg==", + "dev": true, + "license": "ISC", + "dependencies": { + "through2": "~2.0.0" + } + }, + "node_modules/standardized-audio-context": { + "version": "25.3.77", + "resolved": "https://registry.npmjs.org/standardized-audio-context/-/standardized-audio-context-25.3.77.tgz", + "integrity": "sha512-Ki9zNz6pKcC5Pi+QPjPyVsD9GwJIJWgryji0XL9cAJXMGyn+dPOf6Qik1AHei0+UNVcc4BOCa0hWLBzlwqsW/A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.6", + "automation-events": "^7.0.9", + "tslib": "^2.7.0" + } + }, + "node_modules/stream-combiner2": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/stream-combiner2/-/stream-combiner2-1.1.1.tgz", + "integrity": "sha512-3PnJbYgS56AeWgtKF5jtJRT6uFJe56Z0Hc5Ngg/6sI6rIt8iiMBTa9cvdyFfpMQjaVHr8dusbNeFGIIonxOvKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "duplexer2": "~0.1.0", + "readable-stream": "^2.0.2" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" + "engines": { + "node": ">=8" } }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], + "node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=4" + } + }, + "node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" }, "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/super-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/super-regex/-/super-regex-1.1.0.tgz", + "integrity": "sha512-WHkws2ZflZe41zj6AolvvmaTrWds/VuyeYr9iPVv/oQeaIoVxMKaushfFWpOGDT+GuBrM/sVqF8KUCYQlSSTdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-timeout": "^1.0.1", + "make-asynchronous": "^1.0.1", + "time-span": "^5.1.0" + }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=18" }, "funding": { - "url": "https://opencollective.com/libvips" + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + "engines": { + "node": ">=8" } }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], + "node_modules/supports-hyperlinks": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", + "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=14.18" }, "funding": { - "url": "https://opencollective.com/libvips" + "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" + } + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", - "cpu": [ - "wasm32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, + "node_modules/temp-dir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-3.0.0.tgz", + "integrity": "sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + } + }, + "node_modules/tempy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tempy/-/tempy-3.2.0.tgz", + "integrity": "sha512-d79HhZya5Djd7am0q+W4RTsSU+D/aJzM+4Y4AGJGuGlgM2L6sx5ZvOYTmZjqPhrDrV6xJTtRSm1JCLj6V6LHLQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@emnapi/runtime": "^1.7.0" + "is-stream": "^3.0.0", + "temp-dir": "^3.0.0", + "type-fest": "^2.12.2", + "unique-string": "^3.0.0" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=14.16" }, "funding": { - "url": "https://opencollective.com/libvips" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], + "node_modules/tempy/node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, "funding": { - "url": "https://opencollective.com/libvips" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", - "cpu": [ - "ia32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], + "node_modules/tempy/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=12.20" }, "funding": { - "url": "https://opencollective.com/libvips" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/three": { + "version": "0.184.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz", + "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==", + "license": "MIT" + }, + "node_modules/through2": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", + "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "~2.3.6", + "xtend": "~4.0.1" + } + }, + "node_modules/time-span": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/time-span/-/time-span-5.1.0.tgz", + "integrity": "sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "convert-hrtime": "^5.0.0" + }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=12" }, "funding": { - "url": "https://opencollective.com/libvips" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@next/env": { - "version": "15.5.18", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.18.tgz", - "integrity": "sha512-hAV85Ckd9QR6RvH04MEKwsfLTksvFpO47j9xwtoIuvuPnlwecpSi+uZTtm8HirVbtlI2Fnz//xpcSTjFdyJk+g==", - "license": "MIT" + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } }, - "node_modules/@next/swc-darwin-arm64": { - "version": "15.5.18", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.18.tgz", - "integrity": "sha512-w0WvQf1n+txiwns/9pwIQteCJpZTbxzO2SE0FLcwuD4v0WEh1JPOjdyxWL21XwJsdpx8cFRjyzxzCS/siP7HcQ==", - "cpu": [ - "arm64" - ], + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], "engines": { - "node": ">= 10" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/@next/swc-darwin-x64": { - "version": "15.5.18", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.18.tgz", - "integrity": "sha512-znn71QmDuxm+BOaglihMZfvyySMnNljkVIY5Z2TCssBmm+WqL6c19VhtH5ktFkHa8EZ2bnTUpcNcmNSQsg67og==", - "cpu": [ - "x64" - ], + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "dependencies": { + "is-number": "^7.0.0" + }, "engines": { - "node": ">= 10" + "node": ">=8.0" } }, - "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.5.18", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.18.tgz", - "integrity": "sha512-yPPe5MNL+igZUa+OsqQJisqSfh6oarIuA1Q0BDxljGJhRQyZeP+WRHh7rs/jZUGMh5aY0YdIjXZG0VohkKkUdw==", - "cpu": [ - "arm64" - ], + "node_modules/tone": { + "version": "15.1.22", + "resolved": "https://registry.npmjs.org/tone/-/tone-15.1.22.tgz", + "integrity": "sha512-TCScAGD4sLsama5DjvTUXlLDXSqPealhL64nsdV1hhr6frPWve0DeSo63AKnSJwgfg55fhvxj0iPPRwPN5o0ag==", + "license": "MIT", + "dependencies": { + "standardized-audio-context": "^25.3.70", + "tslib": "^2.3.1" + } + }, + "node_modules/traverse": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.8.tgz", + "integrity": "sha512-aXJDbk6SnumuaZSANd21XAo15ucCDE38H4fkqiGsc3MhCK+wOlZvLP9cB/TvpHT0mOyWgC4Z8EwRlzqYSUzdsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">= 10" + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" } }, - "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.5.18", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.18.tgz", - "integrity": "sha512-glaCczEWIrHsokFZ3pP08U4BpKxwIdnT+txdOM32OBgpL9Yw4aqx8NejmgtZQZOdstQ5f0L3CasIZudzCuD+nw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", "engines": { - "node": ">= 10" + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.5.18", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.18.tgz", - "integrity": "sha512-oUfg2EgJmU3R0OCOWiokGFUTvZiPfXtriXiuF3YNxRoROCdgvTedHIzYoeKH34gsZxS/V7mHbfq2hpAHwhH1/A==", - "cpu": [ - "x64" - ], - "license": "MIT", + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", "optional": true, - "os": [ - "linux" - ], + "bin": { + "uglifyjs": "bin/uglifyjs" + }, "engines": { - "node": ">= 10" + "node": ">=0.8.0" } }, - "node_modules/@next/swc-linux-x64-musl": { - "version": "15.5.18", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.18.tgz", - "integrity": "sha512-JLxSP3KTd9iu/bvUMQxH7RJo9xKSHf55/6RPE4a6FTSZygGn7uvZbCej0AHXydwkggQGSD9UddSjwv6Xz5ESfA==", - "cpu": [ - "x64" - ], + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">= 10" + "node": ">=20.18.1" } }, - "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.5.18", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.18.tgz", - "integrity": "sha512-ir1v7enP52K2HNz3tQQvwF+x7VNxBk1ciiZ18WBPvxf4C59IqdfmHPJYK3vH7rSxpuCVw/8C712wTXNAtEp+NA==", - "cpu": [ - "arm64" - ], + "node_modules/unicode-emoji-modifier-base": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unicode-emoji-modifier-base/-/unicode-emoji-modifier-base-1.0.0.tgz", + "integrity": "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], "engines": { - "node": ">= 10" + "node": ">=4" } }, - "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.5.18", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.18.tgz", - "integrity": "sha512-LIu5me6QTANCd25E7I5uIEfvgQ06RK7tvHAbYo3zCb3VpxQEPvMcSpd87NwUABDT6MbGPdEGR5VRiK4PPTJhQg==", - "cpu": [ - "x64" - ], + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], "engines": { - "node": ">= 10" - } - }, - "node_modules/@swc/helpers": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.8.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@tweenjs/tween.js": { - "version": "23.1.3", - "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", - "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "22.19.19", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", - "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "node_modules/unique-string": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-3.0.0.tgz", + "integrity": "sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "crypto-random-string": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "dev": true, + "license": "ISC" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" + "engines": { + "node": ">= 10.0.0" } }, - "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "node_modules/url-join": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-5.0.0.tgz", + "integrity": "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==", "dev": true, "license": "MIT", - "peerDependencies": { - "@types/react": "^19.2.0" + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, - "node_modules/@types/stats.js": { - "version": "0.17.4", - "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", - "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true, "license": "MIT" }, - "node_modules/@types/three": { - "version": "0.184.1", - "resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.1.tgz", - "integrity": "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==", + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "@dimforge/rapier3d-compat": "~0.12.0", - "@tweenjs/tween.js": "~23.1.3", - "@types/stats.js": "*", - "@types/webxr": ">=0.5.17", - "fflate": "~0.8.2", - "meshoptimizer": "~1.1.1" + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" } }, - "node_modules/@types/webxr": { - "version": "0.5.24", - "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", - "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "node_modules/web-worker": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", + "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==", "dev": true, - "license": "MIT" + "license": "Apache-2.0" }, - "node_modules/automation-events": { - "version": "7.1.19", - "resolved": "https://registry.npmjs.org/automation-events/-/automation-events-7.1.19.tgz", - "integrity": "sha512-cD+TLhJTI0q4AI3ktd353lrGZiVa9AchowSDzQzzGjSoYe22js4vlS32VUtWuaulghi1Yq0KYNWKk9wWuGymPA==", - "license": "MIT", + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", "dependencies": { - "@babel/runtime": "^7.29.2", - "tslib": "^2.8.1" + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" }, "engines": { - "node": ">=18.2.0" + "node": ">= 8" } }, - "node_modules/caniuse-lite": { - "version": "1.0.30001793", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", - "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/client-only": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", - "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", - "license": "MIT" - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", "dev": true, "license": "MIT" }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "optional": true, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/fflate": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", - "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "dev": true, "license": "MIT" }, - "node_modules/lucide-react": { - "version": "0.297.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.297.0.tgz", - "integrity": "sha512-PA3ddfQ1KXZbY8j4eBfd4gp5mDusacqVh/M1xNjHviQR+O0QVCmy2NazperLGm5zlU51p9amUB/apFqfsYa0zA==", - "license": "ISC", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0" - } - }, - "node_modules/meshoptimizer": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", - "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/next": { - "version": "15.5.18", - "resolved": "https://registry.npmjs.org/next/-/next-15.5.18.tgz", - "integrity": "sha512-eKL8zUJkX9Y5lE+RX/2YJoItVdGlIscyVyboeD9wSpp0PaGqjoA4tTpT2qPqz9ax+5IzGESyLSeZ/RCwbSZ2uQ==", + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, "license": "MIT", "dependencies": { - "@next/env": "15.5.18", - "@swc/helpers": "0.5.15", - "caniuse-lite": "^1.0.30001579", - "postcss": "8.4.31", - "styled-jsx": "5.1.6" - }, - "bin": { - "next": "dist/bin/next" + "ansi-regex": "^6.2.2" }, "engines": { - "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" - }, - "optionalDependencies": { - "@next/swc-darwin-arm64": "15.5.18", - "@next/swc-darwin-x64": "15.5.18", - "@next/swc-linux-arm64-gnu": "15.5.18", - "@next/swc-linux-arm64-musl": "15.5.18", - "@next/swc-linux-x64-gnu": "15.5.18", - "@next/swc-linux-x64-musl": "15.5.18", - "@next/swc-win32-arm64-msvc": "15.5.18", - "@next/swc-win32-x64-msvc": "15.5.18", - "sharp": "^0.34.3" + "node": ">=12" }, - "peerDependencies": { - "@opentelemetry/api": "^1.1.0", - "@playwright/test": "^1.51.1", - "babel-plugin-react-compiler": "*", - "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", - "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", - "sass": "^1.3.0" - }, - "peerDependenciesMeta": { - "@opentelemetry/api": { - "optional": true - }, - "@playwright/test": { - "optional": true - }, - "babel-plugin-react-compiler": { - "optional": true - }, - "sass": { - "optional": true - } + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/postcss": { - "version": "8.4.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", - "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, "license": "MIT", - "dependencies": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - }, "engines": { - "node": "^10 || ^12 || >=14" + "node": ">=0.4" } }, - "node_modules/react": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", - "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", - "license": "MIT", + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", "engines": { - "node": ">=0.10.0" + "node": ">=10" } }, - "node_modules/react-dom": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", - "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, "license": "MIT", "dependencies": { - "scheduler": "^0.27.0" + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" }, - "peerDependencies": { - "react": "^19.2.6" + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" } }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, "license": "ISC", - "optional": true, - "bin": { - "semver": "bin/semver.js" - }, "engines": { - "node": ">=10" + "node": "^20.19.0 || ^22.12.0 || >=23" } }, - "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "hasInstallScript": true, - "license": "Apache-2.0", - "optional": true, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@img/colour": "^1.0.0", - "detect-libc": "^2.1.2", - "semver": "^7.7.3" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=18" }, "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/standardized-audio-context": { - "version": "25.3.77", - "resolved": "https://registry.npmjs.org/standardized-audio-context/-/standardized-audio-context-25.3.77.tgz", - "integrity": "sha512-Ki9zNz6pKcC5Pi+QPjPyVsD9GwJIJWgryji0XL9cAJXMGyn+dPOf6Qik1AHei0+UNVcc4BOCa0hWLBzlwqsW/A==", + "node_modules/yargs/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/runtime": "^7.25.6", - "automation-events": "^7.0.9", - "tslib": "^2.7.0" + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/styled-jsx": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", - "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "dev": true, "license": "MIT", - "dependencies": { - "client-only": "0.0.1" - }, "engines": { - "node": ">= 12.0.0" - }, - "peerDependencies": { - "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + "node": ">=18" }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, - "babel-plugin-macros": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/three": { - "version": "0.184.0", - "resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz", - "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==", - "license": "MIT" - }, - "node_modules/tone": { - "version": "15.1.22", - "resolved": "https://registry.npmjs.org/tone/-/tone-15.1.22.tgz", - "integrity": "sha512-TCScAGD4sLsama5DjvTUXlLDXSqPealhL64nsdV1hhr6frPWve0DeSo63AKnSJwgfg55fhvxj0iPPRwPN5o0ag==", - "license": "MIT", + "packages/bridges": { + "name": "@spacedevin/juke-bridges", + "version": "0.1.0", "dependencies": { - "standardized-audio-context": "^25.3.70", - "tslib": "^2.3.1" + "three": "^0.184.0", + "tone": "^15.1.22" + }, + "devDependencies": { + "esbuild": "^0.25.0" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "packages/juke-cards": { + "name": "@spacedevin/juke-cards", + "version": "0.1.0", + "license": "PIF", + "devDependencies": { + "@semantic-release/commit-analyzer": "^13.0.1", + "@semantic-release/github": "^12.0.6", + "@semantic-release/npm": "^13.1.5", + "@semantic-release/release-notes-generator": "^14.1.0", + "@tishlang/tish": "^1.10.0", + "semantic-release": "^25.0.3" + } }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" + "packages/jukebox": { + "name": "@spacedevin/jukebox", + "version": "0.1.0", + "dependencies": { + "@tishlang/lattish": "^1.1.3", + "lattish": "file:./deps/lattish" } }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" + "packages/jukebox/deps/lattish": { + "version": "0.0.0" + }, + "packages/jukebox/node_modules/lattish": { + "resolved": "packages/jukebox/deps/lattish", + "link": true } } } diff --git a/package.json b/package.json index 4ec9b4d..4ae519e 100644 --- a/package.json +++ b/package.json @@ -1,30 +1,22 @@ { "name": "juke.sh", - "version": "0.1.0", - "license": "PIF", + "version": "0.2.0", "private": true, + "license": "PIF", + "workspaces": [ + "packages/juke-cards", + "packages/jukebox", + "packages/bridges" + ], "scripts": { - "dev": "next dev -H 0.0.0.0", - "build": "next build", - "start": "next start", - "lint": "biome check .", - "lint:fix": "biome check --write .", - "format": "biome format --write ." - }, - "dependencies": { - "lucide-react": "^0.297.0", - "next": "^15.0.0", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "three": "^0.184.0", - "tone": "^15.1.22" + "build": "just build", + "dev": "just dev", + "fmt": "just fmt", + "fmt:check": "just fmt-check", + "lint": "just lint", + "test": "just test" }, "devDependencies": { - "@biomejs/biome": "2.4.15", - "@types/node": "^22", - "@types/react": "^19", - "@types/react-dom": "^19", - "@types/three": "^0.184.1", - "typescript": "^5" + "@tishlang/tish": "^1.10.0" } } diff --git a/packages/bridges/README.md b/packages/bridges/README.md new file mode 100644 index 0000000..95ee14d --- /dev/null +++ b/packages/bridges/README.md @@ -0,0 +1,63 @@ +# @spacedevin/juke-bridges + +Browser bundle for the jukebox 3D drum scene. Extracted from `app/jukebox.tsx` (`initThree`) so the Tish/Lattish runtime can load Three.js + Tone without pulling in the full Next.js app. + +## Build + +```bash +cd packages/bridges +npm install +npm run build +``` + +Output: `dist/vendor.js` (IIFE). Load it in the host page, then call: + +```js +const { dispose, mode } = window.__jukeBridge.createJukeboxScene(container, { + tracks: [], + nowItem: null, + onActiveChange: (active) => {}, + onPlaybackError: (msg) => console.warn(msg), + lighting: 0.5, + zoom: 0, + spin: 0, + debug: false, + showCategories: true, + zoomTight: 0.95, + zoomFlat: 0, + audioEnabled: true, + shuffle: true, + rows: 10, +}) +``` + +## Spotify bridge + +Playback goes through a lazy getter — the host must set `window.__jukeSpotify` before user interaction: + +```js +window.__jukeSpotify = { + play(uri, contextUri) { /* ... */ }, + nowPlaying() { /* ... */ }, + setShuffle(enabled) { /* ... */ }, + addToQueue(uri) { /* ... */ }, +} +``` + +## Card art + +`three-scene.js` imports `generateCardArt` and `xmur3` from `@spacedevin/juke-cards`. That package is **external** at bundle time; the host must provide it (separate script or alias) when loading `dist/vendor.js`. + +## Layout + +| File | Role | +|------|------| +| `src/three-scene.js` | `createJukeboxScene` + full drum renderer | +| `src/tone-audio.js` | Tone.js chime/hum (`createToneAudio`) | +| `src/vendor-entry.js` | Sets `window.__jukeBridge` | + +## Dependencies + +- [three](https://threejs.org/) — scene, instanced cards, OrbitControls +- [tone](https://tonejs.github.io/) — selection sound +- `@spacedevin/juke-cards` (peer) — procedural card textures diff --git a/packages/bridges/build.mjs b/packages/bridges/build.mjs new file mode 100644 index 0000000..c718fcb --- /dev/null +++ b/packages/bridges/build.mjs @@ -0,0 +1,20 @@ +import * as esbuild from 'esbuild' +import { mkdir } from 'node:fs/promises' + +const minify = process.env.NODE_ENV === 'production' + +await mkdir('dist', { recursive: true }) + +await esbuild.build({ + entryPoints: ['src/vendor-entry.js'], + outfile: 'dist/vendor.js', + bundle: true, + format: 'iife', + platform: 'browser', + target: ['es2020'], + sourcemap: !minify, + minify, + external: ['@spacedevin/juke-cards'], +}) + +console.log('built dist/vendor.js', minify ? '(minified)' : '') diff --git a/packages/bridges/package-lock.json b/packages/bridges/package-lock.json new file mode 100644 index 0000000..7444cfa --- /dev/null +++ b/packages/bridges/package-lock.json @@ -0,0 +1,558 @@ +{ + "name": "@spacedevin/juke-bridges", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@spacedevin/juke-bridges", + "version": "0.1.0", + "dependencies": { + "three": "^0.184.0", + "tone": "^15.1.22" + }, + "devDependencies": { + "esbuild": "^0.25.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/automation-events": { + "version": "7.1.19", + "resolved": "https://registry.npmjs.org/automation-events/-/automation-events-7.1.19.tgz", + "integrity": "sha512-cD+TLhJTI0q4AI3ktd353lrGZiVa9AchowSDzQzzGjSoYe22js4vlS32VUtWuaulghi1Yq0KYNWKk9wWuGymPA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18.2.0" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/standardized-audio-context": { + "version": "25.3.77", + "resolved": "https://registry.npmjs.org/standardized-audio-context/-/standardized-audio-context-25.3.77.tgz", + "integrity": "sha512-Ki9zNz6pKcC5Pi+QPjPyVsD9GwJIJWgryji0XL9cAJXMGyn+dPOf6Qik1AHei0+UNVcc4BOCa0hWLBzlwqsW/A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.6", + "automation-events": "^7.0.9", + "tslib": "^2.7.0" + } + }, + "node_modules/three": { + "version": "0.184.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz", + "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==", + "license": "MIT" + }, + "node_modules/tone": { + "version": "15.1.22", + "resolved": "https://registry.npmjs.org/tone/-/tone-15.1.22.tgz", + "integrity": "sha512-TCScAGD4sLsama5DjvTUXlLDXSqPealhL64nsdV1hhr6frPWve0DeSo63AKnSJwgfg55fhvxj0iPPRwPN5o0ag==", + "license": "MIT", + "dependencies": { + "standardized-audio-context": "^25.3.70", + "tslib": "^2.3.1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + } + } +} diff --git a/packages/bridges/package.json b/packages/bridges/package.json new file mode 100644 index 0000000..ad5b8b9 --- /dev/null +++ b/packages/bridges/package.json @@ -0,0 +1,18 @@ +{ + "name": "@spacedevin/juke-bridges", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Browser bundle for the jukebox Three.js scene bridge", + "main": "dist/vendor.js", + "scripts": { + "build": "node build.mjs" + }, + "dependencies": { + "three": "^0.184.0", + "tone": "^15.1.22" + }, + "devDependencies": { + "esbuild": "^0.25.0" + } +} diff --git a/app/jukebox.tsx b/packages/bridges/src/three-scene.js similarity index 63% rename from app/jukebox.tsx rename to packages/bridges/src/three-scene.js index 4f88e04..2b12d8c 100644 --- a/app/jukebox.tsx +++ b/packages/bridges/src/three-scene.js @@ -1,871 +1,38 @@ -// @ts-nocheck -'use client'; -import { useEffect, useRef, useState } from 'react'; -import { useRouter } from 'next/navigation'; -import * as THREE from 'three'; -import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; -import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'; -import * as Tone from 'tone'; -import { - isLoggedIn, login, getStoredClientId, loadAllTracks, play, nowPlaying, - setShuffle as spotifySetShuffle, addToQueue, - getUserPlaylists, getSelectedPlaylists, saveSelectedPlaylists, - getCachedTracks, setCachedTracks, clearTracksCache, getCachedPlaylistIds, - type UserPlaylist, -} from '@/lib/spotify-client'; -import { loadPrefs, savePrefs, flushPrefs } from '@/lib/preferences'; -import { generateCardArt, preloadCardFonts, xmur3 } from '@/lib/card-art'; - -type Phase = 'init' | 'fetching-playlists' | 'picking' | 'loading-tracks' | 'ready' | 'error'; - -// How many cards stacked vertically per column. Configurable per-Jukebox via -// the `rows` prop and live-tweakable at runtime via the up/down arrow keys. -const DEFAULT_ROWS = 10; -const MIN_ROWS = 4; -const MAX_ROWS = 20; -const clampRows = (n: number) => Math.max(MIN_ROWS, Math.min(MAX_ROWS, n | 0)); -const clampUnit = (n: number) => Math.max(0, Math.min(1, n)); -// Spin is denominated in CARDS PER SECOND — perceived scroll rate is -// constant across drum sizes (a 1000-card drum and a 100-card drum both move -// one card past the camera per second at spin=1). -const SPIN_MAX = 10; // ±10 cards/s -const SPIN_STEP = 0.25; // ←/→ key nudge -const clampSpin = (n: number) => Math.max(-SPIN_MAX, Math.min(SPIN_MAX, n)); -// Tight-zoom multiplier. Lower = camera pulls closer past height-fit, -// effectively cropping the gold rim chrome off the top/bottom. Higher = -// reveals more chrome / breathing room. -const ZOOM_TIGHT_MIN = 0.4; -const ZOOM_TIGHT_MAX = 1.2; -const ZOOM_TIGHT_DEFAULT = 0.95; -const clampZoomTight = (n: number) => Math.max(ZOOM_TIGHT_MIN, Math.min(ZOOM_TIGHT_MAX, n)); -// Telephoto-flatness applied at zoom=1: 0 = normal 12° tight, 1 = ultra-tele -// ~2° (near-orthographic, perspective looks completely flat). Camera distance -// auto-compensates so cards stay the same size on screen — only the depth / -// foreshortening collapses. -const TIGHT_FOV_NORMAL = 12; -const TIGHT_FOV_FLAT = 2; -const ZOOM_FLAT_STEP = 0.2; -const clampZoomFlat = (n: number) => Math.max(0, Math.min(1, n)); - -function SettingsModal({ - modeRef, - rows, - onChangeRows, - onClose, -}: { - modeRef: { - current: { - spin: number; - showCategories: boolean; - zoomTight: number; - zoomFlat: number; - audioEnabled: boolean; - shuffle: boolean; - setAudioEnabled?: (enabled: boolean) => void; - setShuffle?: (enabled: boolean) => void; - }; - }; - rows: number; - onChangeRows: (n: number) => void; - onClose: () => void; -}) { - // Mirror modeRef into local state so the sliders re-render. Writes go - // straight back to modeRef + savePrefs so the live scene updates instantly. - const [spin, setSpin] = useState(modeRef.current.spin); - const [showCategories, setShowCategories] = useState(modeRef.current.showCategories); - const [zoomTight, setZoomTight] = useState(modeRef.current.zoomTight); - const [zoomFlat, setZoomFlat] = useState(modeRef.current.zoomFlat); - const [audioEnabled, setAudioEnabled] = useState(modeRef.current.audioEnabled); - const [shuffle, setShuffle] = useState(modeRef.current.shuffle); - - // ESC to close - useEffect(() => { - const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); onClose(); } }; - window.addEventListener('keydown', onKey); - return () => window.removeEventListener('keydown', onKey); - }, [onClose]); - - const update = (k: K, v: any) => { - (modeRef.current as any)[k] = v; - savePrefs({ [k]: v } as any); - }; - - return ( -
-
e.stopPropagation()}> -
SETTINGS
-
5-tap anywhere to reopen · ESC to close
- - - - - - -
Lower = crops chrome / cards fill more of the screen. Z / X keys nudge this too.
- - -
Higher = longer telephoto lens, cards look flatter / less 3D when zoomed in. V key cycles too.
- - - - - - - -
- -
-
-
- ); +import * as THREE from 'three' +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js' +import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' +import { generateCardArt, xmur3 } from '@spacedevin/juke-cards' +import { createToneAudio } from './tone-audio.js' + +const TIGHT_FOV_NORMAL = 12 +const TIGHT_FOV_FLAT = 2 +const MIN_SCENE_ROWS = 4 +const MAX_SCENE_ROWS = 20 + +function clampSceneRows(n) { + const v = Number(n) + if (!Number.isFinite(v)) return 10 + const r = Math.floor(v) + if (r < MIN_SCENE_ROWS) return MIN_SCENE_ROWS + if (r > MAX_SCENE_ROWS) return MAX_SCENE_ROWS + return r } -export default function Jukebox({ - debug = false, - rows: initialRows = DEFAULT_ROWS, -}: { debug?: boolean; rows?: number } = {}) { - const router = useRouter(); - const containerRef = useRef(null); - const [phase, setPhase] = useState('init'); - // Hydrate persisted prefs synchronously so the first render uses the saved - // row count + the initial scene init uses the saved lighting/spin/etc. - const initialPrefs = useRef(loadPrefs()); - const [rows, setRows] = useState( - clampRows(initialPrefs.current.rows ?? initialRows), - ); - const [loadingMsg, setLoadingMsg] = useState('Loading…'); - const [errorMsg, setErrorMsg] = useState(''); - const [userPlaylists, setUserPlaylists] = useState([]); - const [pickedIds, setPickedIds] = useState>(new Set()); - const [cachedIds, setCachedIds] = useState>(new Set()); - const [showHints, setShowHints] = useState(true); - const [showSettings, setShowSettings] = useState(false); - // Transient playback-error message (e.g. "No Spotify devices found"). Set - // by the play() catch path and auto-cleared after PLAYBACK_TOAST_MS. - const [playbackMsg, setPlaybackMsg] = useState(''); - const playbackMsgTimer = useRef | undefined>(undefined); - const showPlaybackError = (msg: string) => { - if (!mountedRef.current) return; - setPlaybackMsg(msg); - if (playbackMsgTimer.current) clearTimeout(playbackMsgTimer.current); - playbackMsgTimer.current = setTimeout(() => { - if (mountedRef.current) setPlaybackMsg(''); - }, 6000); - }; - useEffect(() => () => { - if (playbackMsgTimer.current) clearTimeout(playbackMsgTimer.current); - }, []); - const modeRef = useRef<{ - lighting: number; // 0 = classic diner, 1 = roller rink - zoom: number; // 0 = fit-to-screen, 1 = zoom-to-cards - spin: number; // radians/SECOND auto-orbit (0 = off). Time-based so - // the speed stays identical at 60 vs 120 fps displays. - // Arrow keys nudge this up/down; defaults to 0. - debug: boolean; // procedural tracks, no Spotify API - showCategories: boolean; // bottom-rim category labels toggled by 'C' - zoomTight: number; // tightDist multiplier — Z/X keys + settings slider - zoomFlat: number; // telephoto-flatness at zoom=1 — V key + settings slider - audioEnabled: boolean; // M key + settings toggle for click chord + ambient hum - shuffle: boolean; // S key + settings toggle — pushed to Spotify after the - // first play so the playlist auto-continues randomly - goToActiveCard?: () => void; - // Hook initThree exposes so the M-key handler / settings toggle can stop - // the hum if it's already playing when audio is turned off mid-session. - setAudioEnabled?: (enabled: boolean) => void; - // Hook initThree exposes so the S-key handler / settings toggle can push - // the new shuffle state to Spotify mid-session. - setShuffle?: (enabled: boolean) => void; - }>({ - // Default to a 50/50 blend of diner + rink — both ends are now exaggerated - // enough that pure diner or pure rink reads as a deliberate mood, and the - // midpoint is the "best of both" baseline. - lighting: clampUnit(initialPrefs.current.lighting ?? 0.5), - zoom: 0, - spin: clampSpin(initialPrefs.current.spin ?? 0), - debug, - showCategories: initialPrefs.current.showCategories ?? true, - zoomTight: clampZoomTight(initialPrefs.current.zoomTight ?? ZOOM_TIGHT_DEFAULT), - zoomFlat: clampZoomFlat(initialPrefs.current.zoomFlat ?? 0), - audioEnabled: initialPrefs.current.audioEnabled ?? true, - shuffle: initialPrefs.current.shuffle ?? true, - }); - const cleanupRef = useRef<(() => void) | undefined>(undefined); - // Stash hydrated data here; the initThree effect picks it up once `ready`. - const tracksDataRef = useRef<{ tracks: any[]; nowItem: any } | null>(null); - // Set false on unmount so in-flight async hydration doesn't write to a - // dead component (and doesn't try to re-fetch Spotify after we've torn down). - const mountedRef = useRef(true); - useEffect(() => () => { mountedRef.current = false; }, []); - - // Boot — always show the picker on refresh, but preselect whatever was used - // last time. Cached tracks for the same id set will short-circuit the fetch. - useEffect(() => { - // Debug shortcut — loads procedural fake tracks instead of hitting Spotify. - // Available in production too (used by /dev for visual demos / recordings - // without anyone needing to log in). - if (debug) { - void (async () => { - const m = await import('@/lib/debug-tracks'); - try { await preloadCardFonts(); } catch {} - if (!mountedRef.current) return; - tracksDataRef.current = { tracks: m.generateDebugTracks(120), nowItem: null }; - setPhase('ready'); - })(); - return; - } - - // /box is the "I want to use the jukebox" destination. If we're not - // logged in, kick off the Spotify auth flow automatically — but only if - // a Client ID is already stored (otherwise we have nothing to send to - // Spotify; bounce to the landing page so the user can paste one in). - if (!isLoggedIn()) { - if (getStoredClientId()) { - void login(); - } else { - router.replace('/'); - } - return; - } - setPickedIds(new Set(getSelectedPlaylists())); - setCachedIds(new Set(getCachedPlaylistIds())); - void fetchPlaylistsAndPick(); - }, [router, debug]); - - // Mount the Three.js scene once we hit `ready` AND the data is staged. - // Re-mounts whenever `rows` changes (↑/↓ arrow-key adjustment). - useEffect(() => { - if (phase !== 'ready') return; - const data = tracksDataRef.current; - if (!data || !containerRef.current) return; - cleanupRef.current = initThree( - containerRef.current, - data.tracks, - data.nowItem, - (hasActive) => { - if (mountedRef.current) setShowHints(!hasActive); - }, - showPlaybackError, - modeRef.current, - rows, - ); - return () => { - cleanupRef.current?.(); - cleanupRef.current = undefined; - if (mountedRef.current) setShowHints(true); - }; - }, [phase, rows]); - - // Keyboard controls (available on every route, suppressed inside form fields). - // C toggle category labels - // ←/→ auto-rotation speed - // ↑/↓ add / remove a row (triggers scene rebuild) - useEffect(() => { - function onKey(e: KeyboardEvent) { - const t = e.target as HTMLElement | null; - if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return; - - if (e.key === 'c' || e.key === 'C') { - e.preventDefault(); - modeRef.current.showCategories = !modeRef.current.showCategories; - savePrefs({ showCategories: modeRef.current.showCategories }); - } else if (e.key === 'ArrowLeft') { - e.preventDefault(); - modeRef.current.spin = clampSpin(modeRef.current.spin + SPIN_STEP); - savePrefs({ spin: modeRef.current.spin }); - } else if (e.key === 'ArrowRight') { - e.preventDefault(); - modeRef.current.spin = clampSpin(modeRef.current.spin - SPIN_STEP); - savePrefs({ spin: modeRef.current.spin }); - } else if (e.key === 'ArrowUp') { - e.preventDefault(); - setRows(r => { const n = Math.min(MAX_ROWS, r + 1); savePrefs({ rows: n }); return n; }); - } else if (e.key === 'ArrowDown') { - e.preventDefault(); - setRows(r => { const n = Math.max(MIN_ROWS, r - 1); savePrefs({ rows: n }); return n; }); - } else if (e.key === 'z' || e.key === 'Z') { - e.preventDefault(); - // Z = tighter (crop more chrome) - modeRef.current.zoomTight = clampZoomTight(modeRef.current.zoomTight - 0.05); - savePrefs({ zoomTight: modeRef.current.zoomTight }); - } else if (e.key === 'x' || e.key === 'X') { - e.preventDefault(); - // X = looser (reveal more chrome) - modeRef.current.zoomTight = clampZoomTight(modeRef.current.zoomTight + 0.05); - savePrefs({ zoomTight: modeRef.current.zoomTight }); - } else if (e.key === 'v' || e.key === 'V') { - e.preventDefault(); - // V = step telephoto flatness up; wraps to 0 after 1.0 so a single - // key cycles through the full range. - let next = modeRef.current.zoomFlat + ZOOM_FLAT_STEP; - if (next > 1.0001) next = 0; - modeRef.current.zoomFlat = clampZoomFlat(next); - savePrefs({ zoomFlat: modeRef.current.zoomFlat }); - } else if (e.key === 'm' || e.key === 'M') { - e.preventDefault(); - // M = mute/unmute the click chord + ambient hum. The scene-side hook - // stops the hum on disable so it doesn't keep playing after a toggle. - const next = !modeRef.current.audioEnabled; - modeRef.current.audioEnabled = next; - savePrefs({ audioEnabled: next }); - modeRef.current.setAudioEnabled?.(next); - } else if (e.key === 's' || e.key === 'S') { - e.preventDefault(); - // S = toggle Spotify shuffle. The scene-side hook pushes the new - // state to the Spotify API if a playback session is active; if not, - // the pref is saved and applied at the start of the next session. - const next = !modeRef.current.shuffle; - modeRef.current.shuffle = next; - savePrefs({ shuffle: next }); - modeRef.current.setShuffle?.(next); - } - } - window.addEventListener('keydown', onKey); - return () => window.removeEventListener('keydown', onKey); - }, []); - - // Pointer-release housekeeping: - // • persist the lighting blend (initThree mutates modeRef.lighting - // continuously during a vertical drag, debouncing saves at the lib) - // • count 5 fast taps to pop the Settings modal — a mobile-friendly - // alternative to keyboard shortcuts. - const TAP_BURST_MS = 800; - const TAP_MOVE_MAX = 8; - const TAP_DURATION_MAX = 250; - const tapTimes = useRef([]); - let pdAt = 0, pdX = 0, pdY = 0; - useEffect(() => { - const onDown = (e: PointerEvent) => { pdAt = Date.now(); pdX = e.clientX; pdY = e.clientY; }; - const onUp = (e: PointerEvent) => { - savePrefs({ lighting: modeRef.current.lighting }); - const dur = Date.now() - pdAt; - const dist = Math.hypot(e.clientX - pdX, e.clientY - pdY); - if (dur > TAP_DURATION_MAX || dist > TAP_MOVE_MAX) return; - const now = Date.now(); - tapTimes.current = [...tapTimes.current, now].filter(t => now - t < TAP_BURST_MS).slice(-5); - if (tapTimes.current.length >= 5) { - tapTimes.current = []; - setShowSettings(true); - } - }; - const onHide = () => { - savePrefs({ - lighting: modeRef.current.lighting, - spin: modeRef.current.spin, - showCategories: modeRef.current.showCategories, - zoomTight: modeRef.current.zoomTight, - audioEnabled: modeRef.current.audioEnabled, - shuffle: modeRef.current.shuffle, - }); - flushPrefs(); - }; - window.addEventListener('pointerdown', onDown); - window.addEventListener('pointerup', onUp); - window.addEventListener('pointercancel', onUp); - document.addEventListener('visibilitychange', onHide); - return () => { - window.removeEventListener('pointerdown', onDown); - window.removeEventListener('pointerup', onUp); - window.removeEventListener('pointercancel', onUp); - document.removeEventListener('visibilitychange', onHide); - }; - }, []); - - async function fetchPlaylistsAndPick() { - setPhase('fetching-playlists'); - setLoadingMsg('Loading your playlists…'); - try { - const pls = await getUserPlaylists(); - if (!mountedRef.current) return; - setUserPlaylists(pls); - setPhase('picking'); - } catch (e: any) { - if (!mountedRef.current) return; - setErrorMsg(e?.message ?? 'Failed to load playlists'); - setPhase('error'); - } - } - - async function hydrateTracks(ids: string[]) { - setPhase('loading-tracks'); - setLoadingMsg('Loading tracks…'); - try { - let data = getCachedTracks(ids); - if (!data) { - const fresh = await loadAllTracks(ids); - if (!mountedRef.current) return; - // Only cache when we actually got something. Caching an empty result - // (e.g. user picked a Spotify-locked algorithmic playlist) would lock - // them into the "no tracks" error every retry. - if (fresh.tracks.length > 0) setCachedTracks(ids, fresh.tracks, fresh.nowItem); - data = fresh; - } - if (!data.tracks.length) { - if (!mountedRef.current) return; - setErrorMsg('No tracks found in the selected playlists.'); - setPhase('error'); - return; - } - setLoadingMsg('Building jukebox…'); - // Wait for Google fonts so the canvas card textures render with the - // intended typefaces instead of falling back to system serifs. - try { await preloadCardFonts(); } catch {} - if (!mountedRef.current) return; - tracksDataRef.current = { tracks: data.tracks, nowItem: data.nowItem }; - setPhase('ready'); - } catch (e: any) { - if (!mountedRef.current) return; - setErrorMsg(e?.message ?? 'Failed to load tracks'); - setPhase('error'); - } - } - - function togglePicked(id: string) { - setPickedIds(prev => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); else next.add(id); - return next; - }); - } - - function confirmSelection() { - const ids = Array.from(pickedIds); - if (ids.length === 0) return; - saveSelectedPlaylists(ids); - // hydrateTracks consults getCachedTracks first — same id set hits cache, - // a different set falls through to a fresh fetch and overwrites it. - void hydrateTracks(ids); - } - - function flushCache() { - clearTracksCache(); - setCachedIds(new Set()); - } - - return ( - <> - {/* Always render the container so the ref is attached before initThree - runs. Visible only in `ready`; overlays cover it in other phases. */} -
- {phase === 'ready' && ( -
- {showHints && ( -
-
TAP TO PLAY • HOLD TO QUEUE
-
SPIN LEFT/RIGHT • LIGHTING UP/DOWN • PINCH TO ZOOM
-
5-TAP FOR SETTINGS
-
- )} - {playbackMsg && ( -
setPlaybackMsg('')}> - {playbackMsg} -
- )} -
- )} - - {phase === 'init' && ( -
-
-
- )} - {(phase === 'fetching-playlists' || phase === 'loading-tracks') && ( -
-
-
{loadingMsg}
-
- )} - - {phase === 'error' && ( -
-
{errorMsg}
- -
- )} - - {phase === 'picking' && ( -
-
-
SELECT PLAYLISTS
-
Pick the playlists to load into the jukebox.
-
    - {userPlaylists.map(pl => { - const isPicked = pickedIds.has(pl.id); - const isCached = cachedIds.has(pl.id); - return ( -
  • - {pl.image - ? - :
    } -
    -
    {pl.name}
    -
    {pl.id} · {pl.tracks} tracks
    -
    - {isCached && ( - - )} - -
  • - ); - })} -
-
- -
-
-
- )} - - {showSettings && ( - { setRows(n); savePrefs({ rows: n }); }} - onClose={() => setShowSettings(false)} - /> - )} - - - - ); +function normalizeTracks(tracks) { + return Array.isArray(tracks) ? tracks : [] } -function initThree( - container: HTMLDivElement, - tracks: any[], - nowItem: any, - onActiveChange: (hasActive: boolean) => void, - onPlaybackError: (message: string) => void, - mode: { - lighting: number; - zoom: number; - spin: number; - debug: boolean; - showCategories: boolean; - zoomTight: number; - zoomFlat: number; - audioEnabled: boolean; - shuffle: boolean; - goToActiveCard?: () => void; - setAudioEnabled?: (enabled: boolean) => void; - setShuffle?: (enabled: boolean) => void; - }, - rows: number, -) { - let audioInitialized = false; - let beepSynth: any, humSynth: any, humFilter: any; - async function initAudio() { - if (audioInitialized) return; - await Tone.start(); - // Soft triangle-wave chord for the card-select chime. Slight attack ramp - // avoids the square-wave transient pop the old MembraneSynth produced. - beepSynth = new Tone.PolySynth(Tone.Synth, { - oscillator: { type: 'triangle' }, - envelope: { attack: 0.02, decay: 0.2, sustain: 0.2, release: 1 }, - }).toDestination(); - beepSynth.volume.value = -10; - humSynth = new Tone.Noise('pink'); - humFilter = new Tone.Filter(150, 'lowpass').toDestination(); - humSynth.connect(humFilter); - humSynth.volume.value = -25; - audioInitialized = true; - } - function disposeAudio() { - if (!audioInitialized) return; - try { humSynth.stop(); } catch {} - try { humSynth.dispose(); } catch {} - try { humFilter.dispose(); } catch {} - try { beepSynth.dispose(); } catch {} - audioInitialized = false; - } - async function playSelectionSound() { - // M-key / settings toggle — bail before we'd even spin up the AudioContext. - if (!mode.audioEnabled) return; - // Ensure the AudioContext is live (browsers require a user gesture). On - // the very first tap initAudio still resolves before we trigger the synth. - await initAudio(); - if (!audioInitialized) return; - beepSynth.triggerAttackRelease(['C4', 'E4', 'G4'], '8n'); - if (humSynth.state !== 'started') humSynth.start(); - } +function spotify() { return window.__jukeSpotify || {} } - // Exposed to the parent component so the M-key handler and settings toggle - // can stop the hum immediately when audio is disabled mid-session (otherwise - // it would keep playing until the user reloads or hits a card again). - mode.setAudioEnabled = (enabled: boolean) => { - if (!enabled && audioInitialized && humSynth?.state === 'started') { - try { humSynth.stop(); } catch {} - } - }; - - // Shuffle state — pushed to the Spotify API. We can't set shuffle before - // there's an active playback session (Spotify 404s), so we defer the very - // first sync until just after our first successful play() call. From then - // on, the S-key handler and settings toggle invoke mode.setShuffle() which - // hits the API immediately. +function initThree(container, tracks, nowItem, onActiveChange, onPlaybackError, mode, rows) { + tracks = normalizeTracks(tracks) + rows = clampSceneRows(rows) + const { initAudio, disposeAudio, playSelectionSound, bindAudioMode } = createToneAudio(mode) + bindAudioMode() let shuffleInitialized = false; - mode.setShuffle = (enabled: boolean) => { + mode.setShuffle = (enabled) => { if (mode.debug) return; - // Even when not yet "initialized" (no first play yet), we still try — - // Spotify just returns 404 which we swallow, and the next first-play - // path will re-sync to the current pref. - void spotifySetShuffle(enabled).catch(() => {}); + void spotify().setShuffle(enabled).catch(() => {}); }; const scene = new THREE.Scene(); @@ -981,8 +148,12 @@ function initThree( // both of which change with `mode.zoom`. A static value can't be both // comfortable at fit-zoom AND not whip-past at telephoto zoom. const totalCells = COLS * ROWS; - const emptyCount = totalCells - tracks.length; - const displayTracks: (any | null)[] = [...tracks, ...Array(emptyCount).fill(null)]; + let emptyCount = totalCells - tracks.length; + if (emptyCount < 0 || !Number.isFinite(emptyCount)) { + while (COLS * ROWS < tracks.length + MIN_EMPTY) COLS += 2; + emptyCount = COLS * ROWS - tracks.length; + } + const displayTracks = [...tracks, ...Array(Math.max(0, emptyCount)).fill(null)]; for (let i = displayTracks.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [displayTracks[i], displayTracks[j]] = [displayTracks[j], displayTracks[i]]; @@ -1001,7 +172,7 @@ function initThree( // Floor const floorCanvas = document.createElement('canvas'); floorCanvas.width = 1024; floorCanvas.height = 1024; - const fctx = floorCanvas.getContext('2d')!; + const fctx = floorCanvas.getContext('2d'); fctx.fillStyle = '#050505'; fctx.fillRect(0, 0, 1024, 1024); fctx.fillStyle = '#e8e8e8'; for (let i = 0; i < 1024; i += 128) for (let j = 0; j < 1024; j += 128) if ((i / 128 + j / 128) % 2 === 0) fctx.fillRect(i, j, 128, 128); @@ -1018,11 +189,11 @@ function initThree( // Env map — seeded so chrome reflections are identical across reloads const envCanvas = document.createElement('canvas'); envCanvas.width = 1024; envCanvas.height = 512; - const envCtx = envCanvas.getContext('2d')!; + const envCtx = envCanvas.getContext('2d'); envCtx.fillStyle = '#050203'; envCtx.fillRect(0, 0, 1024, 512); const envSeed = xmur3('jukebox-env'); const envRnd = () => (envSeed() >>> 0) / 4294967296; - const envRndInt = (max: number) => Math.floor(envRnd() * max); + const envRndInt = (max) => Math.floor(envRnd() * max); const envColors = ['#00ffff', '#ff00ff', '#ffffff', '#ffaa00']; for (let i = 0; i < 30; i++) { envCtx.fillStyle = envColors[envRndInt(envColors.length)]; @@ -1043,7 +214,7 @@ function initThree( // Normal map — neutral blue base (128, 128, 255) with per-column X-tilt const nc = document.createElement('canvas'); nc.width = W; nc.height = H; - const nctx = nc.getContext('2d')!; + const nctx = nc.getContext('2d'); const nimg = nctx.createImageData(W, H); for (let x = 0; x < W; x++) { // Each vertical streak has a different slight horizontal tilt @@ -1069,7 +240,7 @@ function initThree( // catch unevenly. Slightly rougher overall than the base mat. const rc = document.createElement('canvas'); rc.width = W; rc.height = H; - const rctx = rc.getContext('2d')!; + const rctx = rc.getContext('2d'); const rimg = rctx.createImageData(W, H); for (let x = 0; x < W; x++) { const v = (Math.sin(x * 78.233) * 43758.5453) % 1; @@ -1132,9 +303,9 @@ function initThree( // ring. None of them move relative to each other, so we bake the per-column // transform into the geometry once and merge by material. 36 cols × ~2.5 // sub-meshes = ~90 draw calls → 3 draw calls (one per material). - const tealGeos: THREE.BufferGeometry[] = []; - const aluminumGeos: THREE.BufferGeometry[] = []; - const chromeGeos: THREE.BufferGeometry[] = []; + const tealGeos = []; + const aluminumGeos = []; + const chromeGeos = []; const Y_UP = new THREE.Vector3(0, 1, 0); for (let i = 0; i < COLS; i++) { const theta = i * COL_ANGLE; @@ -1180,7 +351,7 @@ function initThree( } // ----- Row divider torii (ROWS+1 of them, all on whiteBracketMat) → 1 mesh - const ringGeos: THREE.BufferGeometry[] = []; + const ringGeos = []; for (let r = 0; r <= ROWS; r++) { const y = startY - r * ROW_SPACING + ROW_SPACING / 2; // 8×32 segments instead of 16×64 — these are tiny rings seen edge-on most @@ -1246,7 +417,7 @@ function initThree( // sprites so it lives in VRAM exactly once. const haloCanvas = document.createElement('canvas'); haloCanvas.width = 128; haloCanvas.height = 128; - const hctx = haloCanvas.getContext('2d')!; + const hctx = haloCanvas.getContext('2d'); const haloGrad = hctx.createRadialGradient(64, 64, 0, 64, 64, 64); haloGrad.addColorStop(0.0, 'rgba(255, 220, 150, 1.00)'); haloGrad.addColorStop(0.25, 'rgba(255, 200, 120, 0.55)'); @@ -1262,16 +433,10 @@ function initThree( depthWrite: false, transparent: true, }); - const trackers: { - light: THREE.PointLight; - bead: THREE.Mesh; - halo: THREE.Sprite; - dir: 1 | -1; - offset: number; - }[] = []; + const trackers = []; for (let i = 0; i < TRACKER_PAIRS; i++) { const offset = i / TRACKER_PAIRS; // stagger across the cycle - for (const dir of [1, -1] as const) { + for (const dir of [1, -1]) { // PointLight(color, intensity, distance, decay) // • distance 4 — short throw; only the cards directly below are lit // • decay 2 — natural inverse-square falloff (smooth gradient, no @@ -1301,8 +466,8 @@ function initThree( // the only added per-frame cost is one smoothstep + mul-add per FRAGMENT // ON CARDS ONLY, not the multiplicative per-light cost a real PointLight // would impose on every Standard material in the scene. - type QueueRef = { atlasIdx: number; cellIdx: number }; - const queueByUri = new Map(); + + const queueByUri = new Map(); // Before: COLS*ROWS draw calls, one Mesh + Material + CanvasTexture per card. // After: ceil(N / CELLS_PER_ATLAS) draw calls — one InstancedMesh per atlas. // For a 320-card deck that's 1 draw call vs 320. Each card still @@ -1325,55 +490,20 @@ function initThree( // Looks like a Three.js Mesh's `.userData` sub-object to every consumer // (selectCard, ledMoveTo, the animate-loop lighting block) so we don't have // to rewrite every `card.userData.X` access — just swap the backing object. - type CardRef = { - userData: { - isCard: true; - song: any; - baseY: number; - theta: number; - c: number; - r: number; - accentColor: string; - bgColor: string; - altColor: string; - accentCol: THREE.Color; - bgCol: THREE.Color; - altCol: THREE.Color; - }; - atlasIdx: number; - cellIdx: number; - }; - - type Atlas = { - canvas: HTMLCanvasElement; - ctx: CanvasRenderingContext2D; - texture: THREE.CanvasTexture; - mesh: THREE.InstancedMesh; - capacity: number; - cellRefs: (CardRef | null)[]; - // Per-cell highlight fade state for the cyan queue glow. `current` is the - // backing storage for the aHighlight InstancedBufferAttribute. `target` - // is what we're lerping toward. `highlightAttr.needsUpdate = true` after - // any frame that modified `current`. - highlightCurrent: Float32Array; - highlightTarget: Float32Array; - highlightAttr: THREE.InstancedBufferAttribute; - highlightDirty: boolean; - }; - const cards: CardRef[] = []; + const cards = []; // O(1) URI → card lookup; kept in sync with `cards` in buildCard / placeIncoming. - const cardByUri = new Map(); - let activeCard: CardRef | null = null; - const atlases: Atlas[] = []; + const cardByUri = new Map(); + let activeCard = null; + const atlases = []; // Raycast targets — each entry is the InstancedMesh for one atlas. - const cardInstancedMeshes: THREE.InstancedMesh[] = []; + const cardInstancedMeshes = []; // Per-instance UV rect (offsetX, offsetY, scaleX, scaleY). Determined entirely // by the cell's grid position in the atlas. Inset by 0.5 px on every side so // linear filtering at the cell edge never reaches outside this cell (would // otherwise show a thin colored bleed at every card border). - function makeAtlasGeo(capacity: number) { + function makeAtlasGeo(capacity) { const geo = new THREE.PlaneGeometry(CARD_W, CARD_H); const aUvRect = new Float32Array(capacity * 4); const inset = 0.5 / ATLAS_SIZE; @@ -1399,7 +529,7 @@ function initThree( return geo; } - function makeAtlasMat(texture: THREE.CanvasTexture) { + function makeAtlasMat(texture) { // Same MeshStandardMaterial params as the original per-card material — // identical shading. const mat = new THREE.MeshStandardMaterial({ map: texture, roughness: 0.85, metalness: 0 }); @@ -1484,7 +614,7 @@ ${shader.fragmentShader} const canvas = document.createElement('canvas'); canvas.width = ATLAS_SIZE; canvas.height = ATLAS_SIZE; - const ctx = canvas.getContext('2d')!; + const ctx = canvas.getContext('2d'); const texture = new THREE.CanvasTexture(canvas); // Same filtering as the old per-card CanvasTexture — quality unchanged. texture.anisotropy = renderer.capabilities.getMaxAnisotropy(); @@ -1503,11 +633,11 @@ ${shader.fragmentShader} cardInstancedMeshes.push(mesh); // The aHighlight InstancedBufferAttribute is on the geometry; reach in // and grab it so the per-frame fade loop can flag re-uploads. - const highlightAttr = geo.getAttribute('aHighlight') as THREE.InstancedBufferAttribute; + const highlightAttr = geo.getAttribute('aHighlight'); atlases.push({ canvas, ctx, texture, mesh, capacity, cellRefs: Array(capacity).fill(null), - highlightCurrent: highlightAttr.array as Float32Array, + highlightCurrent: highlightAttr.array, highlightTarget: new Float32Array(capacity), highlightAttr, highlightDirty: false, @@ -1515,12 +645,12 @@ ${shader.fragmentShader} } } - function slotToAtlas(c: number, r: number) { + function slotToAtlas(c, r) { const idx = c * ROWS + r; return { atlasIdx: Math.floor(idx / CELLS_PER_ATLAS), cellIdx: idx % CELLS_PER_ATLAS }; } - function stampCell(atlasIdx: number, cellIdx: number, art: HTMLCanvasElement) { + function stampCell(atlasIdx, cellIdx, art) { const atlas = atlases[atlasIdx]; const col = cellIdx % COLS_PER_ATLAS; const row = Math.floor(cellIdx / COLS_PER_ATLAS); @@ -1545,21 +675,23 @@ ${shader.fragmentShader} jukeboxGroup.add(categoryGroup); // Track empty slots (for the queue) and slots already taken by queued cards - const emptySlots: { c: number; r: number }[] = []; - const queueCards: any[] = []; + const emptySlots = []; + const queueCards = []; - function buildCard(t: any, c: number, r: number): CardRef { - const theta = -Math.PI + c * COL_ANGLE + COL_ANGLE / 2; + function trackToCardSong(t, c, r) { + const code = letters[c % letters.length] + (r + 1); + if (t.titleA && t.titleB) { + return { ...t, titleA: t.titleA, titleB: t.titleB, code }; + } const words = (t.name || '').split(/\s+/).filter(Boolean); const half = Math.ceil(words.length / 2); const titleA = words.length > 1 ? words.slice(0, half).join(' ') : (t.name || ''); const titleB = words.length > 1 ? words.slice(half).join(' ') : (t.album || t.name || ''); - // Cell ID: spreadsheet-style column letter + row number (A1, B1, C1, …, - // A2, B2, C2, …). Column letter wraps after Z so a 32-column drum is - // addressable: col 26 is 'A' again, col 27 is 'B', etc. - const code = letters[c % letters.length] + (r + 1); - const song = { ...t, titleA, titleB, code }; - const { canvas: art, accent, bg, alt } = generateCardArt(song); + return { ...t, titleA, titleB, code }; + } + + function stampSlot(c, r, art) { + const theta = -Math.PI + c * COL_ANGLE + COL_ANGLE / 2; const { atlasIdx, cellIdx } = slotToAtlas(c, r); stampCell(atlasIdx, cellIdx, art); const x = Math.sin(theta) * (R + 0.05); @@ -1572,22 +704,25 @@ ${shader.fragmentShader} const atlas = atlases[atlasIdx]; atlas.mesh.setMatrixAt(cellIdx, cardDummy.matrix); atlas.mesh.instanceMatrix.needsUpdate = true; - const ref: CardRef = { + return { atlasIdx, cellIdx, y, theta }; + } + + function buildCard(t, c, r) { + const song = trackToCardSong(t, c, r); + const { canvas: art, accent, bg, alt } = generateCardArt(song); + const placed = stampSlot(c, r, art); + const ref = { userData: { - isCard: true, song, baseY: y, theta, c, r, - // Hex strings (still used by external consumers). + isCard: true, song, baseY: placed.y, theta: placed.theta, c, r, accentColor: accent, bgColor: bg, altColor: alt, - // Pre-parsed Color objects — the rink-mode lighting block reads these - // every frame while the card is active. `set('#abcdef')` parses the - // string on every call; `copy()` is a 3-float copy. accentCol: new THREE.Color(accent), bgCol: new THREE.Color(bg), altCol: new THREE.Color(alt || '#ffffff'), }, - atlasIdx, - cellIdx, + atlasIdx: placed.atlasIdx, + cellIdx: placed.cellIdx, }; - atlas.cellRefs[cellIdx] = ref; + atlases[placed.atlasIdx].cellRefs[placed.cellIdx] = ref; return ref; } @@ -1602,7 +737,7 @@ ${shader.fragmentShader} const CAT_W = 256, CAT_H = 64, CAT_N = categories.length; const catCanvas = document.createElement('canvas'); catCanvas.width = CAT_W; catCanvas.height = CAT_H * CAT_N; - const cctx = catCanvas.getContext('2d')!; + const cctx = catCanvas.getContext('2d'); cctx.fillStyle = '#006666'; cctx.fillRect(0, 0, CAT_W, CAT_H * CAT_N); cctx.fillStyle = '#ffffff'; cctx.font = "bold 28px 'Oswald'"; cctx.textAlign = 'center'; cctx.textBaseline = 'middle'; @@ -1679,7 +814,7 @@ ${shader.fragmentShader} // the per-frame fader in animate lerps the current value toward it. Re-queue // of the same URI is a no-op apart from bumping the target back up — which // also revives a still-fading-out cell smoothly. - function markQueued(card: CardRef): void { + function markQueued(card) { const uri = card.userData.song.uri; let ref = queueByUri.get(uri); if (!ref) { @@ -1697,7 +832,7 @@ ${shader.fragmentShader} // re-queues mid-fade, markQueued recomputes the same (atlasIdx, cellIdx) // (slotToAtlas is deterministic per (c, r)) and bumps the target back up, // so the fade-out reverses cleanly. - function unmarkQueued(uri: string): void { + function unmarkQueued(uri) { const ref = queueByUri.get(uri); if (!ref) return; const atlas = atlases[ref.atlasIdx]; @@ -1716,20 +851,20 @@ ${shader.fragmentShader} // Add a new now-playing track. Uses an empty slot if available, otherwise // recycles the oldest previously-queued slot. Returns the (existing or new) // card for the given URI, or null if none can be placed. - function placeIncoming(raw: any): CardRef | null { + function placeIncoming(raw) { const existing = cardByUri.get(raw.uri); if (existing) return existing; - let slot: { c: number; r: number } | undefined; + let slot; if (emptySlots.length > 0) { slot = emptySlots.shift(); } else { // Recycle the oldest queued card — but skip the one that's currently // playing. Rotate it to the back of the queue and try the next. while (queueCards.length > 0 && queueCards[0] === activeCard) { - queueCards.push(queueCards.shift()!); + queueCards.push(queueCards.shift()); } if (queueCards.length > 0) { - const oldest = queueCards.shift()!; + const oldest = queueCards.shift(); slot = { c: oldest.userData.c, r: oldest.userData.r }; cards.splice(cards.indexOf(oldest), 1); cardByUri.delete(oldest.userData.song.uri); @@ -1754,8 +889,8 @@ ${shader.fragmentShader} // Card the LEDs are currently parked at. When this differs from activeCard, // the animate loop fades them out, repositions them, then fades them back in // at the new card — no flicker from an instant jump. - let ledCard: any = null; - function ledMoveTo(card: any) { + let ledCard = null; + function ledMoveTo(card) { const data = card.userData; const yTop = data.baseY + CARD_H / 2 - 0.02; const yBot = data.baseY - CARD_H / 2 + 0.02; @@ -1767,14 +902,14 @@ ${shader.fragmentShader} // Bring the playing track to life. Finds (or places via the queue) the card // for the given Spotify item, sets it as active, and jumps the drum front-and-center. - function syncNowPlaying(item: any, contextUri?: string) { + function syncNowPlaying(item, contextUri) { if (!item?.uri) return; let match = cardByUri.get(item.uri); if (!match) { match = placeIncoming({ uri: item.uri, name: item.name, - artist: (item.artists || []).map((a: any) => a.name).join(', '), + artist: (item.artists || []).map((a) => a.name).join(', '), album: item.album?.name ?? '', source: 'now', contextUri, @@ -1789,7 +924,7 @@ ${shader.fragmentShader} mode.goToActiveCard?.(); } - async function selectCard(card: any) { + async function selectCard(card) { if (activeCard === card) { activeCard = null; onActiveChange(false); @@ -1816,15 +951,15 @@ ${shader.fragmentShader} // context (e.g. a `queue` source). const { uri, contextUri } = card.userData.song; try { - await play(uri, contextUri); + await spotify().play(uri, contextUri); // First play of the session — push the user's preferred shuffle // state to Spotify. The shuffle endpoint 404s without an active // device, which is why we wait until after a successful play. if (!shuffleInitialized) { shuffleInitialized = true; - void spotifySetShuffle(mode.shuffle).catch(() => {}); + void spotify().setShuffle(mode.shuffle).catch(() => {}); } - } catch (err: any) { + } catch (err) { // play() now auto-recovers from NO_ACTIVE_DEVICE by transferring to // any known device and retrying. If we still got here, either the // account has zero devices Spotify knows about, or something else @@ -1847,7 +982,7 @@ ${shader.fragmentShader} // through; this is a local-only "I changed my mind, hide the marker" // affordance until Spotify exposes a real delete). // • Otherwise → POST /me/player/queue + light up the cyan indicator. - async function queueCard(card: any) { + async function queueCard(card) { if (card === activeCard) return; const uri = card.userData.song.uri; if (queueByUri.has(uri)) { @@ -1857,7 +992,7 @@ ${shader.fragmentShader} playSelectionSound(); markQueued(card); if (mode.debug) return; - try { await addToQueue(uri); } catch {} + try { await spotify().addToQueue(uri); } catch {} } // Tap/drag/pinch handling: @@ -1875,7 +1010,7 @@ ${shader.fragmentShader} // Long-press fires VIA TIMER at TAP_MAX_MS — we don't wait for pointerup. // `longPressTimer` is cleared on lift / drag / pinch. `longPressFired` keeps // pointerup from also treating the same gesture as a tap. - let longPressTimer: ReturnType | null = null; + let longPressTimer = null; let longPressFired = false; // Momentum state for the horizontal drag → drum rotation. azVelocity is the // rotational speed (rad/s) the camera should keep coasting at after release. @@ -1885,17 +1020,17 @@ ${shader.fragmentShader} let lastMoveTime = 0; const MOMENTUM_FRICTION = 3.0; // nepers/sec — ~95% lost in 1s, smooth ice-rink coast const MOMENTUM_EPS = 0.001; // rad/s — below this we just stop - const activePointers = new Map(); + const activePointers = new Map(); let pinchBaseDist = 0; let pinchBaseZoom = 0; let isPinching = false; - function getXY(event: PointerEvent) { + function getXY(event) { return { x: event.clientX, y: event.clientY }; } // Apply a delta azimuth (rad) to the camera by rotating its (x, z) around // origin. Preserves both radius and y (polar). Used by drag, momentum, and // auto-orbit so they all share one code path. - function applyAzimuthDelta(delta: number) { + function applyAzimuthDelta(delta) { const az = Math.atan2(camera.position.x, camera.position.z) + delta; const r = Math.hypot(camera.position.x, camera.position.z); camera.position.x = Math.sin(az) * r; @@ -1903,14 +1038,14 @@ ${shader.fragmentShader} } // Raycast helper — translates a screen-space (x, y) into a CardRef or null. // Shared by the long-press timer and the pointerup tap handler. - function cardAtScreenPoint(x: number, y: number): any { + function cardAtScreenPoint(x, y) { mouse.x = (x / window.innerWidth) * 2 - 1; mouse.y = -(y / window.innerHeight) * 2 + 1; raycaster.setFromCamera(mouse, camera); const hits = raycaster.intersectObjects(cardInstancedMeshes); if (!hits.length) return null; const hit = hits[0]; - const atlasIdx = cardInstancedMeshes.indexOf(hit.object as THREE.InstancedMesh); + const atlasIdx = cardInstancedMeshes.indexOf(hit.object); const cellIdx = hit.instanceId; return atlasIdx >= 0 && cellIdx != null ? atlases[atlasIdx].cellRefs[cellIdx] : null; } @@ -1920,7 +1055,7 @@ ${shader.fragmentShader} longPressTimer = null; } } - function onPointerDown(event: any) { + function onPointerDown(event) { initAudio(); // Pull focus back to our container so the canvas / iOS selection halo // doesn't steal keyboard input from us. @@ -1957,7 +1092,7 @@ ${shader.fragmentShader} cancelLongPress(); } } - function onPointerMove(event: any) { + function onPointerMove(event) { if (!isDragging) return; const { x, y } = getXY(event); if (event.pointerId !== undefined) activePointers.set(event.pointerId, { x, y }); @@ -2010,7 +1145,7 @@ ${shader.fragmentShader} azVelocity = deltaAz / dt; } } - function onPointerUp(event: any) { + function onPointerUp(event) { if (event.pointerId !== undefined) activePointers.delete(event.pointerId); if (activePointers.size < 2 && isPinching) { isPinching = false; @@ -2053,8 +1188,8 @@ ${shader.fragmentShader} // Scroll wheel drives zoom (0 = fit, 1 = tight on cards). Track-pad pinch // also produces wheel events with ctrlKey set. - const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v)); - function onWheel(e: WheelEvent) { + const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); + function onWheel(e) { e.preventDefault(); const step = e.ctrlKey ? -e.deltaY * 0.01 : -e.deltaY * 0.0025; mode.zoom = clamp(mode.zoom + step, 0, 1); @@ -2070,7 +1205,7 @@ ${shader.fragmentShader} function applySize() { // visualViewport is more accurate than innerWidth during iOS restore // animations (innerWidth can lie for a few frames). - const vv = (window as any).visualViewport; + const vv = window.visualViewport; const w = Math.round(vv?.width ?? window.innerWidth); const h = Math.round(vv?.height ?? window.innerHeight); if (w <= 0 || h <= 0) return; @@ -2097,16 +1232,16 @@ ${shader.fragmentShader} window.addEventListener('orientationchange', scheduleResize); window.addEventListener('pageshow', scheduleResize); document.addEventListener('visibilitychange', onVisibilityChange); - (window as any).visualViewport?.addEventListener('resize', scheduleResize); + window.visualViewport?.addEventListener('resize', scheduleResize); // Poll now-playing. syncNowPlaying handles deck lookup, queue placement, // LED positioning, and the jump-to-card camera move. Skipped in debug mode // (the URIs are fake, the Spotify API would 4xx). - const pollInterval: ReturnType | null = mode.debug + const pollInterval = mode.debug ? null : setInterval(async () => { try { - const d = await nowPlaying(); + const d = await spotify().nowPlaying(); if (d?.item) syncNowPlaying(d.item, d.context?.uri); } catch {} }, 8000); @@ -2114,7 +1249,7 @@ ${shader.fragmentShader} // Smooth jukebox-drum rotation to bring the active card front-and-center. // We rotate the group (not the camera) — OrbitControls' damping fights direct // camera position writes and produces no visible movement. - let groupRotTarget: number | null = null; + let groupRotTarget = null; mode.goToActiveCard = () => { if (!activeCard) return; const curAz = controls.getAzimuthalAngle(); @@ -2290,7 +1425,7 @@ ${shader.fragmentShader} // lerp by L. Fill lights ramp with the rink share. const z = mode.zoom; const L = mode.lighting; - const ftt = (fit: number, tight: number) => fit + (tight - fit) * z; + const ftt = (fit, tight) => fit + (tight - fit) * z; // Rink-mode fills, exaggerated. The saturated pink fill carries the rink // mood; we keep the desaturating whites/ambers small so the cyan/magenta @@ -2321,7 +1456,7 @@ ${shader.fragmentShader} // Rink endpoint — moody. We pull ambient/hemi/dir LOWER than before so the // contrast between bright neon and dark base reads as proper rink moodiness // (was "neon plus a fair amount of general fill" — too washed-out). - let rHemi: number, rDir: number; + let rHemi, rDir; if (activeCard) { // Pre-parsed Color objects (set once in buildCard) — copy() is a 3-float // memcpy versus set('#abcdef') which re-parses the hex on every frame. @@ -2490,7 +1625,7 @@ ${shader.fragmentShader} window.removeEventListener('orientationchange', scheduleResize); window.removeEventListener('pageshow', scheduleResize); document.removeEventListener('visibilitychange', onVisibilityChange); - (window as any).visualViewport?.removeEventListener('resize', scheduleResize); + window.visualViewport?.removeEventListener('resize', scheduleResize); if (resizeRaf) cancelAnimationFrame(resizeRaf); renderer.domElement.removeEventListener('wheel', onWheel); disposeAudio(); @@ -2498,7 +1633,7 @@ ${shader.fragmentShader} // Walk the scene graph and dispose every GPU resource. WebGL leaks // textures/buffers per route change without this — renderer.dispose() // alone only frees the GL context. - scene.traverse((obj: any) => { + scene.traverse((obj) => { if (obj.geometry?.dispose) obj.geometry.dispose(); const mats = Array.isArray(obj.material) ? obj.material : (obj.material ? [obj.material] : []); for (const m of mats) { @@ -2508,9 +1643,30 @@ ${shader.fragmentShader} if (m.dispose) m.dispose(); } }); - if ((scene as any).environment?.dispose) (scene as any).environment.dispose(); - if ((scene as any).background?.dispose) (scene as any).background.dispose(); + if ((scene).environment?.dispose) (scene).environment.dispose(); + if ((scene).background?.dispose) (scene).background.dispose(); renderer.dispose(); container.removeChild(renderer.domElement); }; } + +export function createJukeboxScene(container, options = {}) { + const tracks = normalizeTracks(options.tracks) + const nowItem = options.nowItem ?? null + const onActiveChange = options.onActiveChange || (() => {}) + const onPlaybackError = options.onPlaybackError || (() => {}) + const mode = { + lighting: options.lighting ?? 0.5, + zoom: options.zoom ?? 0, + spin: options.spin ?? 0, + debug: !!options.debug, + showCategories: options.showCategories !== false, + zoomTight: options.zoomTight ?? 0.95, + zoomFlat: options.zoomFlat ?? 0, + audioEnabled: options.audioEnabled !== false, + shuffle: options.shuffle !== false, + } + const rows = clampSceneRows(options.rows ?? 10) + const dispose = initThree(container, tracks, nowItem, onActiveChange, onPlaybackError, mode, rows) + return { dispose, mode } +} diff --git a/packages/bridges/src/tone-audio.js b/packages/bridges/src/tone-audio.js new file mode 100644 index 0000000..3ea3c9f --- /dev/null +++ b/packages/bridges/src/tone-audio.js @@ -0,0 +1,54 @@ +import * as Tone from 'tone' + +/** + * Tone.js selection chime + pink-noise hum for card taps. + * @param {{ audioEnabled: boolean }} mode - scene mode object (mutated via bindAudioMode) + */ +export function createToneAudio(mode) { + let audioInitialized = false + let beepSynth, humSynth, humFilter + + async function initAudio() { + if (audioInitialized) return + await Tone.start() + beepSynth = new Tone.PolySynth(Tone.Synth, { + oscillator: { type: 'triangle' }, + envelope: { attack: 0.02, decay: 0.2, sustain: 0.2, release: 1 }, + }).toDestination() + beepSynth.volume.value = -10 + humSynth = new Tone.Noise('pink') + humFilter = new Tone.Filter(150, 'lowpass').toDestination() + humSynth.connect(humFilter) + humSynth.volume.value = -25 + audioInitialized = true + } + + function disposeAudio() { + if (!audioInitialized) return + try { humSynth.stop() } catch {} + try { humSynth.dispose() } catch {} + try { humFilter.dispose() } catch {} + try { beepSynth.dispose() } catch {} + audioInitialized = false + } + + async function playSelectionSound() { + if (!mode.audioEnabled) return + await initAudio() + if (!audioInitialized) return + beepSynth.triggerAttackRelease(['C4', 'E4', 'G4'], '8n') + if (humSynth.state !== 'started') humSynth.start() + } + + function bindAudioMode() { + mode.setAudioEnabled = (enabled) => { + if (!enabled && audioInitialized && humSynth?.state === 'started') { + try { humSynth.stop() } catch {} + } + } + } + + return { initAudio, disposeAudio, playSelectionSound, bindAudioMode } +} + +export { createToneAudio as default } diff --git a/packages/bridges/src/vendor-entry.js b/packages/bridges/src/vendor-entry.js new file mode 100644 index 0000000..51f538b --- /dev/null +++ b/packages/bridges/src/vendor-entry.js @@ -0,0 +1,5 @@ +import { createJukeboxScene } from './three-scene.js' + +window.__jukeBridge = { createJukeboxScene } + +export { createJukeboxScene } diff --git a/packages/juke-cards/.releaserc.json b/packages/juke-cards/.releaserc.json new file mode 100644 index 0000000..4307a50 --- /dev/null +++ b/packages/juke-cards/.releaserc.json @@ -0,0 +1,9 @@ +{ + "branches": ["main"], + "plugins": [ + "@semantic-release/commit-analyzer", + "@semantic-release/release-notes-generator", + ["@semantic-release/npm", { "pkgRoot": "packages/juke-cards" }], + "@semantic-release/github" + ] +} diff --git a/packages/juke-cards/README.md b/packages/juke-cards/README.md new file mode 100644 index 0000000..a67295c --- /dev/null +++ b/packages/juke-cards/README.md @@ -0,0 +1,165 @@ +# Card Art Generator + +`lib/card-art.ts` is a standalone procedural-graphics module that renders **256×100 px "jukebox slot card"** artwork to an `HTMLCanvasElement`. It powers every card on the juke.sh drum, but it has no dependency on Three.js, React, or anything else in the app — drop it into any browser project that has a `` API. + +## TL;DR + +```ts +import { generateCardArt } from './card-art'; + +const { canvas, accent, bg, alt } = generateCardArt({ + titleA: 'JOHNNY B', + titleB: 'GOODE', + artist: 'Chuck Berry', + code: 'A1', +}); + +document.body.appendChild(canvas); +``` + +That's it. The same `(titleA + artist + code)` input always produces the same card — the generator is fully deterministic via a 32-bit seeded RNG. + +## API + +### `generateCardArt(song: CardSong): CardArt` + +```ts +interface CardSong { + titleA: string; // headline title (printed largest in most layouts) + titleB: string; // secondary title (the "B-side") + artist: string; + code: string; // short slot code, e.g. "A1", "K12" + source?: 'now' | 'queue' | 'playlist'; + // optional left-edge stripe colour: + // 'now' → green stripe (currently playing) + // 'queue' → amber stripe (in the user's queue) + // any other / undefined → no stripe +} + +interface CardArt { + canvas: HTMLCanvasElement; // 256×100, ready to use + accent: string; // palette[2] — primary accent hex + bg: string; // palette[0] — card background hex + alt: string; // palette[3] — alt accent hex +} +``` + +### `xmur3(seed: string): () => number` + +The same Mulberry-style 32-bit hash the generator uses internally. Exposed so callers can chain reproducible randomness in their own code (juke.sh uses it to seed the env-map texture). + +### `getContrast(hex: string): string` + +Given a `#rrggbb` colour, returns `'#000000'` or `'#ffffff'` — whichever has the higher contrast (using the standard 299/587/114 luma weights). + +### Mutable exports + +```ts +export const palettes: string[][]; // 35 × 4 colours +export const displayFonts: string[]; // 19 headline faces +export const scriptFonts: string[]; // 10 script faces +export const codeFonts: string[]; // 5 monospaced-feel faces (slot code) +export const infoFonts: string[]; // 9 body / fine-print faces +``` + +These are deliberately `let`-equivalent (exported arrays you can mutate in place). Drop in your own palette or font list before calling `generateCardArt`: + +```ts +import { palettes, generateCardArt } from './card-art'; + +palettes.length = 0; +palettes.push( + ['#1b1b1b', '#e1e1e1', '#ff4500', '#999999'], // monochrome with one accent + ['#0a0a0a', '#fafafa', '#00d4ff', '#cccccc'], +); + +const { canvas } = generateCardArt({ titleA: 'TEST', titleB: 'TONE', artist: '—', code: 'Z1' }); +``` + +## How the generator works + +Each call picks four independent random style indices from the seeded RNG and stamps the card in four layers: + +| Layer | Range | What it draws | +| ----- | ----- | ------------- | +| **Background** | `0–45` | Solid fills and patterns — split, stripes, checks, rays, chevron, wavy 70s bands, scanlines, etc. | +| **Decor** | `0–45` | Overlay graphic — giant star, watermark code, vinyl, mic, UFO, mountains, divider lines, etc. | +| **Border** | `0–14` | Frame around the central typography area. Some borders also place an inset background, which the typo layer respects. | +| **Layout** | `0–46` | Typography arrangement — 47 distinct compositions for titleA / titleB / artist / code. | + +Plus per-card random selections from `palettes`, `displayFonts`, `scriptFonts`, `codeFonts`, `infoFonts`. + +Typography is drawn on a separate offscreen canvas and composited onto the card. **Solid flat cards** (simple background + no icons/illustrations + plain border + standard layout) get a pixel-level WCAG contrast pass that flips low-contrast text to black or white. **Patterned or illustrated cards** use hard text shadows instead. + +After the four layers, an optional **left-edge stripe** is painted to encode `song.source`, and a **1500-pixel paper-grain noise overlay** is dithered on top to break up the flat fills. + +Total combinatorial space: `46 × 46 × 15 × 47 × 35 palettes × (19 × 10 × 5 × 9 fonts) ≈ 450 billion` distinct cards, so collisions are vanishingly unlikely. + +## Determinism + +The seed is `titleA + artist + code`. **Change any of those and you get a different card**; leave them stable and every call returns the same artwork. Useful for: + +- snapshot tests (compare canvas data URLs) +- server-side rendering (use [`canvas`](https://www.npmjs.com/package/canvas) to polyfill `document.createElement('canvas')` in Node) +- skipping regeneration in caches + +`titleB` and `source` do **not** contribute to the seed, so changing the B-side or moving a track between queue/playlist won't reshuffle the layout — only repaint the relevant text + stripe. + +## Fonts + +Layouts reference Google Font families by name (`'Limelight'`, `'Anton'`, `'Bebas Neue'`, etc.). The generator never loads them — you're responsible for loading whatever set you want in your host page, e.g.: + +```html + +``` + +If a face isn't loaded the canvas falls back to the browser's default (typically serif), which looks wrong. juke.sh calls `preloadCardFonts()` before the first render — recommended for any caller that cares about pixel-perfect output on desktop browsers. + +## Three.js integration + +The juke.sh app wraps the canvas in a `CanvasTexture`: + +```ts +import * as THREE from 'three'; +import { generateCardArt } from '@/lib/card-art'; + +function generateCardTexture(song) { + const { canvas, accent, bg, alt } = generateCardArt(song); + const texture = new THREE.CanvasTexture(canvas); + texture.anisotropy = renderer.capabilities.getMaxAnisotropy(); + texture.minFilter = THREE.LinearFilter; + return { texture, accent, bg, alt }; +} +``` + +That's the entire integration. Everything else (placement, lighting, the LED-edge highlight) lives in `app/jukebox.tsx`. + +## Standalone smoke test + +Drop a `` page in front of it without any framework: + +```html + + +card-art preview + + + +``` + +## Reference + +- Source: [`lib/card-art.ts`](../lib/card-art.ts) +- Lineage: adapted from [`assets/spin20.html`](../assets/spin20.html) (was spin7) diff --git a/packages/juke-cards/demo/index.html b/packages/juke-cards/demo/index.html new file mode 100644 index 0000000..2a55044 --- /dev/null +++ b/packages/juke-cards/demo/index.html @@ -0,0 +1,56 @@ + + + + + + @spacedevin/juke-cards demo + + + + +
+

@spacedevin/juke-cards

+

Deterministic procedural jukebox slot cards (256×100)

+ +
+
+ + + diff --git a/packages/juke-cards/package.json b/packages/juke-cards/package.json new file mode 100644 index 0000000..23bf5b1 --- /dev/null +++ b/packages/juke-cards/package.json @@ -0,0 +1,49 @@ +{ + "name": "@spacedevin/juke-cards", + "version": "0.1.0", + "description": "Procedural jukebox slot card art for canvas and WebGL textures", + "type": "module", + "license": "PIF", + "repository": { + "type": "git", + "url": "https://github.com/spacedevin/juke.sh.git", + "directory": "packages/juke-cards" + }, + "publishConfig": { + "access": "public" + }, + "tish": { + "module": "./src/index.tish" + }, + "main": "./lib/index.mjs", + "module": "./lib/index.mjs", + "exports": { + ".": { + "tish": "./src/index.tish", + "import": "./lib/index.mjs", + "require": "./lib/index.mjs", + "default": "./lib/index.mjs" + } + }, + "files": [ + "src/", + "dist/", + "lib/", + "README.md" + ], + "scripts": { + "build": "tish build --target js src/index.tish -o dist/index.js && node scripts/append-exports.mjs", + "pretest": "npm run build", + "test": "node test/run-tests.mjs", + "prepack": "npm run build", + "prepublishOnly": "npm run build" + }, + "devDependencies": { + "@semantic-release/commit-analyzer": "^13.0.1", + "@semantic-release/github": "^12.0.6", + "@semantic-release/npm": "^13.1.5", + "@semantic-release/release-notes-generator": "^14.1.0", + "@tishlang/tish": "^1.10.0", + "semantic-release": "^25.0.3" + } +} diff --git a/packages/juke-cards/scripts/append-exports.mjs b/packages/juke-cards/scripts/append-exports.mjs new file mode 100644 index 0000000..3fcd952 --- /dev/null +++ b/packages/juke-cards/scripts/append-exports.mjs @@ -0,0 +1,27 @@ +import fs from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" + +const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))) +const dist = path.join(root, "dist", "index.js") +const lib = path.join(root, "lib", "index.mjs") +const nativePath = path.join(root, "scripts", "xmur3-native.js") + +fs.mkdirSync(path.dirname(lib), { recursive: true }) +let js = fs.readFileSync(dist, "utf8") + +// Tish cannot compile >>>; replace xmur3/getContrast with the exact TS port. +const native = fs.readFileSync(nativePath, "utf8").replace(/^export /gm, "") +js = js.replace(/^function xmur3[\s\S]*?\nconst palettes/m, `${native.trim()}\nconst palettes`) + +// Unsigned rng stream for style picks (matches card-art.ts). +js = js.replace( + "let rnd = () => ((((seedGen() ?? null) % 4294967296) / 4294967296));", + "let rnd = () => ((seedGen() >>> 0) / 4294967296);", +) + +if (!js.includes("export { xmur3")) { + js += "\nexport { xmur3, getContrast, generateCardArt, preloadCardFonts, palettes, displayFonts, scriptFonts, codeFonts, infoFonts };\n" +} +fs.writeFileSync(dist, js) +fs.copyFileSync(dist, lib) diff --git a/packages/juke-cards/scripts/compare-ts-tish.mjs b/packages/juke-cards/scripts/compare-ts-tish.mjs new file mode 100644 index 0000000..9ebf076 --- /dev/null +++ b/packages/juke-cards/scripts/compare-ts-tish.mjs @@ -0,0 +1,191 @@ +#!/usr/bin/env node +/** + * Compare built @spacedevin/juke-cards output against lib/card-art.ts reference. + * Run from repo root: node packages/juke-cards/scripts/compare-ts-tish.mjs + */ +import fs from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" +import { createRequire } from "node:module" + +const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))) +const repo = path.dirname(path.dirname(root)) +const tsPath = path.join(repo, ".compare-card-art.ts") + +// Minimal canvas for Node +class Ctx { + constructor(w, h) { + this.canvas = { width: w, height: h } + this._w = w + this._h = h + this._pixels = new Uint8ClampedArray(w * h * 4) + this.fillStyle = "#000" + this.strokeStyle = "#000" + this.font = "16px sans-serif" + this.globalAlpha = 1 + this.globalCompositeOperation = "source-over" + this.textAlign = "left" + this.textBaseline = "alphabetic" + this.lineWidth = 1 + this.shadowColor = "transparent" + this.shadowOffsetX = 0 + this.shadowOffsetY = 0 + this.shadowBlur = 0 + this._stack = [] + } + save() { this._stack.push(this._snap()) } + restore() { Object.assign(this, this._stack.pop()) } + _snap() { + return { + fillStyle: this.fillStyle, font: this.font, globalAlpha: this.globalAlpha, + textAlign: this.textAlign, textBaseline: this.textBaseline, + shadowColor: this.shadowColor, shadowOffsetX: this.shadowOffsetX, + shadowOffsetY: this.shadowOffsetY, shadowBlur: this.shadowBlur, + } + } + fillRect(x, y, w, h) { /* stub */ } + strokeRect() {} + beginPath() {} + closePath() {} + moveTo() {} + lineTo() {} + arc() {} + ellipse() {} + quadraticCurveTo() {} + bezierCurveTo() {} + translate() {} + rotate() {} + scale(sx, sy) { this._sx = sx; this._sy = sy } + stroke() {} + fill() {} + setLineDash() {} + drawImage(src) { + if (src?._pixels) this._pixels.set(src._pixels) + } + fillText(text, x, y, maxWidth) { + // Record text draws for drawAdvText default-parameter regression + if (!this._textOps) this._textOps = [] + this._textOps.push({ text, maxWidth }) + } + strokeText(text, x, y, maxWidth) { + if (!this._textOps) this._textOps = [] + this._textOps.push({ text, maxWidth, stroke: true }) + } + getImageData(x, y, w, h) { + return { data: this._pixels.slice(), width: w, height: h } + } + createImageData(w, h) { + return { data: new Uint8ClampedArray(w * h * 4), width: w, height: h } + } + putImageData(img) { this._pixels.set(img.data) } +} +class Canvas { + constructor() { this.width = 0; this.height = 0; this._ctx = null } + getContext() { + if (!this._ctx) this._ctx = new Ctx(this.width, this.height) + this._ctx.canvas = this + return this._ctx + } +} +globalThis.document = { + createElement(tag) { + if (tag === "canvas") return new Canvas() + return {} + }, + fonts: { load: async () => {}, ready: Promise.resolve() }, +} + +// Reference xmur3/getContrast from card-art.ts +function xmur3Ref(str) { + let h = 1779033703 ^ str.length + for (let i = 0; i < str.length; i++) { + h = Math.imul(h ^ str.charCodeAt(i), 3432918353) + h = (h << 13) | (h >>> 19) + } + return () => { + h = Math.imul(h ^ (h >>> 16), 2246822507) + h = Math.imul(h ^ (h >>> 13), 3266489909) + return (h ^ (h >>> 16)) >>> 0 + } +} + +function xmur3TishBroken(str) { + let h = 1779033703 ^ str.length + let i = 0 + while (i < str.length) { + h = Math.imul(h ^ str.charCodeAt(i), 3432918353) + h = (h * 8192) | Math.floor(h / 524288) + i++ + } + return () => { + h = Math.imul(h ^ Math.floor(h / 65536), 2246822507) + h = Math.imul(h ^ Math.floor(h / 8192), 3266489909) + h = h ^ Math.floor(h / 65536) + return h % 4294967296 + } +} + +const mod = await import(path.join(root, "lib/index.mjs")) + +const songs = [ + { titleA: "JOHNNY B", titleB: "GOODE", artist: "Chuck Berry", code: "A1" }, + { titleA: "ROCK AROUND", titleB: "THE CLOCK", artist: "Bill Haley", code: "K12" }, + { titleA: "Blue Shoes", titleB: "Crazy Love", artist: "Elvis Presley", code: "B3" }, + { titleA: "OPEN", titleB: "SLOT", artist: "QUEUE", code: "C4" }, +] + +console.log("=== xmur3 stream (first 8 values per song) ===") +for (const song of songs) { + const seed = song.titleA + song.artist + song.code + const ref = xmur3Ref(seed) + const built = mod.xmur3(seed) + const tishBroken = xmur3TishBroken(seed) + const refVals = Array.from({ length: 8 }, () => ref()) + const builtVals = Array.from({ length: 8 }, () => built()) + const brokenVals = Array.from({ length: 8 }, () => tishBroken()) + const matchBuilt = refVals.every((v, i) => v === builtVals[i]) + const matchBroken = refVals.every((v, i) => v === brokenVals[i]) + console.log(` ${song.code}: built matches TS ref = ${matchBuilt}, tish-source broken matches = ${matchBroken}`) +} + +console.log("\n=== Style picks (first 4 rndInt calls = bg, decor, border, layout) ===") +function stylePicks(xmur3Fn, seed) { + const g = xmur3Fn(seed) + const rnd = () => (g() >>> 0) / 4294967296 + const ri = (m) => Math.floor(rnd() * m) + return [ri(46), ri(46), ri(15), ri(47)] +} +for (const song of songs) { + const seed = song.titleA + song.artist + song.code + const ref = stylePicks(xmur3Ref, seed) + const built = stylePicks(mod.xmur3, seed) + const broken = stylePicks(xmur3TishBroken, seed) + console.log(` ${song.code}: ref=${JSON.stringify(ref)} built=${JSON.stringify(built)} broken=${JSON.stringify(broken)}`) +} + +console.log("\n=== drawAdvText defaults (7-arg calls) ===") +let nanMaxWidth = 0 +let missingShadow = 0 +for (let i = 0; i < 50; i++) { + const song = { ...songs[i % songs.length], code: "T" + i } + const { canvas } = mod.generateCardArt(song) + const ops = canvas.getContext("2d")._textOps || [] + for (const op of ops) { + if (Number.isNaN(op.maxWidth)) nanMaxWidth++ + } +} +console.log(` NaN maxWidth fillText calls: ${nanMaxWidth} (expect 0)`) +console.log(` Built source has conditional useTextShadows: ${fs.readFileSync(path.join(root, "src/generate.tish"), "utf8").includes("useTextShadows = !isSolidFlatCard")}`) +console.log(` Built source has shadowOverride default: ${fs.readFileSync(path.join(root, "src/generate.tish"), "utf8").includes('typeof shadowOverride !== "number"')}`) + +console.log("\n=== Determinism (same song → same colors) ===") +const s = songs[0] +const a = mod.generateCardArt(s) +const b = mod.generateCardArt(s) +console.log(` accent match: ${a.accent === b.accent}, bg match: ${a.bg === b.bg}`) + +console.log("\n=== API surface ===") +const exports = ["xmur3", "getContrast", "generateCardArt", "preloadCardFonts", "palettes", "displayFonts", "scriptFonts", "codeFonts", "infoFonts"] +for (const e of exports) { + console.log(` ${e}: ${typeof mod[e]}`) +} diff --git a/packages/juke-cards/scripts/xmur3-native.js b/packages/juke-cards/scripts/xmur3-native.js new file mode 100644 index 0000000..979d04f --- /dev/null +++ b/packages/juke-cards/scripts/xmur3-native.js @@ -0,0 +1,22 @@ +// Exact port of lib/card-art.ts xmur3 + getContrast — kept in JS because Tish +// lacks >>> and the Tish-safe rotate approximation changes style selection. +export function xmur3(str) { + let h = 1779033703 ^ str.length + for (let i = 0; i < str.length; i++) { + h = Math.imul(h ^ str.charCodeAt(i), 3432918353) + h = (h << 13) | (h >>> 19) + } + return () => { + h = Math.imul(h ^ (h >>> 16), 2246822507) + h = Math.imul(h ^ (h >>> 13), 3266489909) + return (h ^ (h >>> 16)) >>> 0 + } +} + +export function getContrast(hex) { + if (!hex || hex[0] !== '#') return '#000000' + const r = parseInt(hex.substr(1, 2), 16) + const g = parseInt(hex.substr(3, 2), 16) + const b = parseInt(hex.substr(5, 2), 16) + return (r * 299 + g * 587 + b * 114) / 1000 >= 128 ? '#000000' : '#ffffff' +} diff --git a/packages/juke-cards/src/backgrounds.tish b/packages/juke-cards/src/backgrounds.tish new file mode 100644 index 0000000..bfde671 --- /dev/null +++ b/packages/juke-cards/src/backgrounds.tish @@ -0,0 +1,2 @@ +// Layer 1 background styles (bgStyle 0–45) — implemented in generate.tish. +// Kept as a module anchor for future extraction; generateCardArt inlines this layer today. diff --git a/packages/juke-cards/src/borders.tish b/packages/juke-cards/src/borders.tish new file mode 100644 index 0000000..406cb9f --- /dev/null +++ b/packages/juke-cards/src/borders.tish @@ -0,0 +1 @@ +// Layer 3 border styles — implemented in generate.tish. diff --git a/packages/juke-cards/src/decor.tish b/packages/juke-cards/src/decor.tish new file mode 100644 index 0000000..fe07858 --- /dev/null +++ b/packages/juke-cards/src/decor.tish @@ -0,0 +1 @@ +// Layer 2 decor styles — implemented in generate.tish. diff --git a/lib/card-art.ts b/packages/juke-cards/src/generate.tish similarity index 73% rename from lib/card-art.ts rename to packages/juke-cards/src/generate.tish index 9c18541..1cf018a 100644 --- a/lib/card-art.ts +++ b/packages/juke-cards/src/generate.tish @@ -1,192 +1,74 @@ -// Procedural card-art generator — renders 256×100 "jukebox slot card" -// graphics onto an HTMLCanvasElement. Standalone module: no Three.js, no -// React, no DOM beyond `document.createElement('canvas')`. Use the returned -// canvas anywhere — wrap in a CanvasTexture for WebGL, draw to an , -// post to a Cloudinary upload, etc. -// -// The output is fully deterministic: the same `(titleA + artist + code)` -// always produces the same card. - -/* eslint-disable no-bitwise */ - -export interface CardSong { - /** "Side A" title — the headline name. */ - titleA: string; - /** "Side B" title — printed as the secondary track in many layouts. */ - titleB: string; - /** Artist name. */ - artist: string; - /** Short slot code, e.g. "A1", "K12". */ - code: string; - /** Optional source marker drawn as a colored stripe along the left edge. */ - source?: 'now' | 'queue' | 'playlist'; -} - -export interface CardArt { - /** 256×100 canvas with the rendered card. */ - canvas: HTMLCanvasElement; - /** Primary accent color (palette[2]). */ - accent: string; - /** Card background color (palette[0]). */ - bg: string; - /** Alternate accent color (palette[3]). */ - alt: string; -} - -/** Mulberry-style 32-bit string-seeded hash; returns a uint32 stream. */ -export function xmur3(str: string): () => number { - let h = 1779033703 ^ str.length; - for (let i = 0; i < str.length; i++) { - h = Math.imul(h ^ str.charCodeAt(i), 3432918353); - h = (h << 13) | (h >>> 19); +import { xmur3, getContrast } from "./xmur3.tish" +import { palettes, displayFonts, scriptFonts, codeFonts, infoFonts } from "./palettes.tish" + +fn arrayIncludes(arr, val) { + let i = 0 + while (i < arr.length) { + if (arr[i] === val) { return true } + i = i + 1 } - return () => { - h = Math.imul(h ^ (h >>> 16), 2246822507); - h = Math.imul(h ^ (h >>> 13), 3266489909); - return ((h ^= h >>> 16) >>> 0); - }; -} - -/** Pick #000 or #fff for max contrast against a #rrggbb background. */ -export function getContrast(hex: string): string { - if (!hex || hex[0] !== '#') return '#000000'; - const r = parseInt(hex.substr(1, 2), 16); - const g = parseInt(hex.substr(3, 2), 16); - const b = parseInt(hex.substr(5, 2), 16); - return (r * 299 + g * 587 + b * 114) / 1000 >= 128 ? '#000000' : '#ffffff'; + return false } -/** - * 35 four-color palettes. Mutate at runtime to skin the output (e.g. push - * monochrome rows for a black-and-white print run). - */ -export const palettes: string[][] = [ - ['#e63946', '#f1faee', '#1d3557', '#a8dadc'], ['#ffb703', '#fb8500', '#023047', '#8ecae6'], - ['#ef476f', '#ffd166', '#06d6a0', '#073b4c'], ['#ffffff', '#000000', '#dddddd', '#ff0000'], - ['#f4a261', '#e76f51', '#2a9d8f', '#264653'], ['#8ecae6', '#219ebc', '#023047', '#ffb703'], - ['#d9ed92', '#b5e48c', '#34a0a4', '#1a759f'], ['#2b2d42', '#8d99ae', '#edf2f4', '#d90429'], - ['#000000', '#14213d', '#fca311', '#e5e5e5'], ['#3d5a80', '#98c1d9', '#e0fbfc', '#ee6c4d'], - ['#ff9f1c', '#ffbf69', '#ffffff', '#cbf3f0'], ['#f72585', '#7209b7', '#3a0ca3', '#4cc9f0'], - ['#dad7cd', '#a3b18a', '#588157', '#344e41'], ['#fec5bb', '#fcd5ce', '#fae1dd', '#f8edeb'], - ['#000000', '#d4af37', '#ffffff', '#111111'], ['#ff00ff', '#00ffff', '#000000', '#ffff00'], - ['#f4e285', '#f4a259', '#5b8e7d', '#bc4b51'], ['#2a0800', '#f4d03f', '#e74c3c', '#ffffff'], - ['#fdf6e3', '#eee8d5', '#073642', '#cb4b16'], ['#4a4e69', '#9a8c98', '#c9ada7', '#f2e9e4'], - ['#ff595e', '#ffca3a', '#8ac926', '#1982c4'], ['#1d3557', '#457b9d', '#a8dadc', '#e63946'], - ['#283618', '#606c38', '#fefae0', '#dda15e'], ['#5f0f40', '#9a031e', '#fb8b24', '#e36414'], - ['#0b132b', '#1c2541', '#3a506b', '#5bc0be'], ['#335c67', '#fff3b0', '#e09f3e', '#9e2a2b'], - ['#003049', '#d62828', '#f77f00', '#fcbf49'], ['#22223b', '#4a4e69', '#9a8c98', '#c9ada7'], - ['#0d3b66', '#faf0ca', '#f4d35e', '#ee964b'], ['#f94144', '#f3722c', '#f8961e', '#f9c74f'], - ['#5d5c61', '#379683', '#7395ae', '#557a95'], ['#1a1a1d', '#4e4e50', '#6f2232', '#950740'], - ['#c0c0c0', '#ffffff', '#808080', '#000000'], ['#ffe066', '#247ba0', '#70c1b3', '#50514f'], - ['#6b2d5c', '#f0386b', '#ff5376', '#f8c0c8'], -]; - -/** Heavy display faces — used for headline titles. */ -export const displayFonts: string[] = ["'Anton'", "'Bebas Neue'", "'Monoton'", "'Righteous'", "'Fjalla One'", "'Limelight'", "'Rampart One'", "'Rubik Mono One'", "'Abril Fatface'", "'Bungee'", "'Bangers'", "'Erica One'", "'Fugaz One'", "'Ultra'", "'Vampiro One'", "'Chicle'", "'Russo One'", "'Sigmar One'", "'Fascinate Inline'"]; -/** Script faces — used for "by The Artist" lines. */ -export const scriptFonts: string[] = ["'Pacifico'", "'Lobster'", "'Yellowtail'", "'Satisfy'", "'Shrikhand'", "'Permanent Marker'", "'Fascinate'", "'Damion'", "'Cookie'", "'Leckerli One'"]; -/** Monospaced-feel faces — used for the short slot code. */ -export const codeFonts: string[] = ["'Rubik Mono One'", "'Courier Prime'", "'Oswald'", "'Space Mono'", "'Special Elite'"]; -/** Body / info faces — used for fine print and album credits. */ -export const infoFonts: string[] = ["'Courier Prime'", "'Oswald'", "'Cinzel'", "'Playfair Display'", "'Poiret One'", "'Arimo'", "'Corben'", "'Space Mono'", "'Special Elite'"]; - -/** - * Explicitly load every font this module renders into canvas. - * - * Why this exists: `document.fonts.ready` only waits for fonts referenced by - * elements actually on the page (CSS rules, rendered text). All of the faces - * above are used exclusively via the canvas 2D API — which DOES NOT trigger - * the browser to start downloading the underlying woff2 file. On mobile, - * Safari/Chrome preload all @font-face from a loaded stylesheet, so the cards - * happen to look right. On desktop, both browsers wait until something - * references the font in DOM/CSS, so canvas draws fall back to the default - * serif/sans before our preload await ever resolves. - * - * Calling `document.fonts.load("16px 'Oswald'")` for each face explicitly - * tells the FontFaceSet to fetch the file. Await all of them and the next - * `ctx.fillText(...)` will hit the real glyphs. - */ -export async function preloadCardFonts(): Promise { - if (typeof document === 'undefined' || !document.fonts) return; - const families = new Set(); - for (const list of [displayFonts, scriptFonts, codeFonts, infoFonts]) { - for (const f of list) families.add(f); - } - // Category labels render bold 28px Oswald — request both weights so the - // load includes the bold woff2 (Oswald is published at 500 + 700 in our - // layout.tsx ). - await Promise.all( - [...families].flatMap((f) => [ - document.fonts.load(`16px ${f}`).catch(() => undefined), - document.fonts.load(`bold 16px ${f}`).catch(() => undefined), - ]), - ); -} - -/** WCAG relative luminance for sRGB channel values 0–255. */ -function getWcagLuminance(r: number, g: number, b: number): number { - const a = [r, g, b].map((v: number) => { +fn getWcagLuminance(r, g, b) { + let a = [r, g, b].map((v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }); return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722; } -/** - * Render a procedurally-laid-out jukebox card for `song`. - * - * 256×100 canvas. The same `song` always produces the same card (seeded by - * `titleA + artist + code`). 46 backgrounds × 46 decor × 15 borders × 47 layouts, - * plus random font + palette per draw. Flat solid cards get WCAG contrast correction; - * patterned cards use hard text shadows. - */ -export function generateCardArt(song: CardSong): CardArt { - const seedStr = song.titleA + song.artist + song.code; - const seedGen = xmur3(seedStr); - const rnd = () => (seedGen() >>> 0) / 4294967296; - const rndInt = (max: number) => Math.floor(rnd() * max); +fn generateCardArt(song) { + let seedStr = song.titleA + song.artist + song.code; + let seedGen = xmur3(seedStr); + let rnd = () => (seedGen() % 4294967296) / 4294967296; + let rndInt = (max) => Math.floor(rnd() * max); - const canvas = document.createElement('canvas'); + let canvas = document.createElement('canvas'); canvas.width = 256; canvas.height = 100; - const ctx = canvas.getContext('2d', { willReadFrequently: true })!; + let ctx = canvas.getContext('2d', { willReadFrequently: true }); - const textCanvas = document.createElement('canvas'); + let textCanvas = document.createElement('canvas'); textCanvas.width = 256; textCanvas.height = 100; - const textCtx = textCanvas.getContext('2d', { willReadFrequently: true })!; + let textCtx = textCanvas.getContext('2d', { willReadFrequently: true }); // Expanding limits to include the newest 9 concepts - const bgStyle = rndInt(46); - const decorStyle = rndInt(46); - const borderStyle = rndInt(15); - const layoutStyle = rndInt(47); + let bgStyle = rndInt(46); + let decorStyle = rndInt(46); + let borderStyle = rndInt(15); + let layoutStyle = rndInt(47); // WCAG only on flat solid-color cards. Patterns, dots, icons/illustrations → shadows instead. - const WCAG_CONTRAST_MIN = 3.5; - const solidBgStyles = new Set([0, 1, 2, 8, 12, 15, 16, 18, 21, 22, 25, 27, 29, 30, 31]); - const flatDecorStyles = new Set([0, 9, 17, 22, 24, 26, 36]); // no icons, dots, watermarks, or illustrations - const flatBorderStyles = new Set([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 13]); - const flatLayoutStyles = new Set([ + let WCAG_CONTRAST_MIN = 3.5; + let solidBgStyles = [0, 1, 2, 8, 12, 15, 16, 18, 21, 22, 25, 27, 29, 30, 31]; + let flatDecorStyles = [0, 9, 17, 22, 24, 26, 36]; // no icons, dots, watermarks, or illustrations + let flatBorderStyles = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 13]; + let flatLayoutStyles = [ 0, 1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, - ]); // excludes 7 (star icons), 43–46 (3D / wavy / neon / Warhol stack) + ]; // excludes 7 (star icons), 43–46 (3D / wavy / neon / Warhol stack) - const isSolidFlatCard = - solidBgStyles.has(bgStyle) && - flatDecorStyles.has(decorStyle) && - flatBorderStyles.has(borderStyle) && - flatLayoutStyles.has(layoutStyle); + let isSolidFlatCard = + arrayIncludes(solidBgStyles, bgStyle) && + arrayIncludes(flatDecorStyles, decorStyle) && + arrayIncludes(flatBorderStyles, borderStyle) && + arrayIncludes(flatLayoutStyles, layoutStyle); - const useTextShadows = !isSolidFlatCard; + let useTextShadows = !isSolidFlatCard; - const fDisp = displayFonts[rndInt(displayFonts.length)]; - const fScrpt = scriptFonts[rndInt(scriptFonts.length)]; - const fCode = codeFonts[rndInt(codeFonts.length)]; - const fInfo = infoFonts[rndInt(infoFonts.length)]; - - const pal = palettes[rndInt(palettes.length)]; - const bg1 = pal[0]; const bg2 = pal[1]; const acc1 = pal[2]; const acc2 = pal[3]; - const tc1 = getContrast(bg1); - - const drawAdvText = (targetCtx: CanvasRenderingContext2D, text: string, x: number, y: number, font: string, color: string, align: CanvasTextAlign, scaleX = 1, scaleY = 1, rot = 0, shadowOverride: number | boolean = 1) => { + let fDisp = displayFonts[rndInt(displayFonts.length)]; + let fScrpt = scriptFonts[rndInt(scriptFonts.length)]; + let fCode = codeFonts[rndInt(codeFonts.length)]; + let fInfo = infoFonts[rndInt(infoFonts.length)]; + + let pal = palettes[rndInt(palettes.length)]; + let bg1 = pal[0]; let bg2 = pal[1]; let acc1 = pal[2]; let acc2 = pal[3]; + let tc1 = getContrast(bg1); + + let drawAdvText = (targetCtx, text, x, y, font, color, align, scaleX, scaleY, rot, shadowOverride) => { + if (typeof scaleX !== "number") { scaleX = 1 } + if (typeof scaleY !== "number") { scaleY = 1 } + if (typeof rot !== "number") { rot = 0 } + if (typeof shadowOverride !== "number" && shadowOverride !== false && shadowOverride !== true) { shadowOverride = 1 } targetCtx.save(); if (useTextShadows) { @@ -207,20 +89,20 @@ export function generateCardArt(song: CardSong): CardArt { targetCtx.restore(); }; - const drawIcon = (targetCtx: CanvasRenderingContext2D, type: number, cx: number, cy: number, size: number, color: string) => { + const drawIcon = (targetCtx, iconType, cx, cy, size, color) => { targetCtx.save(); targetCtx.fillStyle = color; targetCtx.beginPath(); - if (type === 0) { for (let i = 0; i < 10; i++) { const r = (i % 2 === 0) ? size : size / 2; const a = (i / 10) * Math.PI * 2 - Math.PI / 2; targetCtx.lineTo(cx + Math.cos(a) * r, cy + Math.sin(a) * r); } } - else if (type === 1) { targetCtx.arc(cx - size / 4, cy + size / 4, size / 4, 0, Math.PI * 2); targetCtx.fill(); targetCtx.beginPath(); targetCtx.fillRect(cx, cy - size / 2, size / 6, size * 0.8); targetCtx.moveTo(cx, cy - size / 2); targetCtx.quadraticCurveTo(cx + size / 2, cy - size / 4, cx + size / 2, cy + size / 4); targetCtx.lineTo(cx + size / 3, cy + size / 4); targetCtx.quadraticCurveTo(cx + size / 3, cy - size / 4, cx, cy - size / 4); } - else if (type === 2) { targetCtx.moveTo(cx + size / 4, cy - size / 2); targetCtx.lineTo(cx - size / 2, cy + size / 8); targetCtx.lineTo(cx - size / 8, cy + size / 8); targetCtx.lineTo(cx - size / 4, cy + size / 2); targetCtx.lineTo(cx + size / 2, cy - size / 8); targetCtx.lineTo(cx + size / 8, cy - size / 8); } - else if (type === 3) { targetCtx.moveTo(cx, cy - size / 2); targetCtx.lineTo(cx + size / 2, cy); targetCtx.lineTo(cx, cy + size / 2); targetCtx.lineTo(cx - size / 2, cy); } - else if (type === 4) { targetCtx.arc(cx, cy, size / 2, 0, Math.PI * 2); targetCtx.fill(); targetCtx.fillStyle = getContrast(color); targetCtx.beginPath(); targetCtx.arc(cx, cy, size / 6, 0, Math.PI * 2); } - else if (type === 5) { targetCtx.translate(cx, cy); targetCtx.strokeStyle = color; targetCtx.lineWidth = 2; for (let i = 0; i < 3; i++) { targetCtx.rotate(Math.PI / 3); targetCtx.beginPath(); targetCtx.ellipse(0, 0, size, size / 3, 0, 0, Math.PI * 2); targetCtx.stroke(); } targetCtx.fillStyle = getContrast(bg1); targetCtx.beginPath(); targetCtx.arc(0, 0, size / 5, 0, Math.PI * 2); targetCtx.fill(); } - else if (type === 6) { targetCtx.moveTo(cx - size, cy + size / 2); targetCtx.lineTo(cx - size, cy - size / 2); targetCtx.lineTo(cx - size / 2, cy); targetCtx.lineTo(cx, cy - size); targetCtx.lineTo(cx + size / 2, cy); targetCtx.lineTo(cx + size, cy - size / 2); targetCtx.lineTo(cx + size, cy + size / 2); } - else if (type === 7) { targetCtx.moveTo(cx - size, cy + size); targetCtx.quadraticCurveTo(cx, cy, cx + size, cy - size); targetCtx.quadraticCurveTo(cx + size / 2, cy + size / 2, cx - size, cy + size); } - else if (type === 8) { targetCtx.moveTo(cx, cy - size / 2); targetCtx.bezierCurveTo(cx + size / 3, cy - size / 2, cx + size / 2, cy - size / 4, cx + size / 2, cy); targetCtx.lineTo(cx + size / 2.5, cy + size / 6); targetCtx.lineTo(cx + size / 2, cy + size / 3); targetCtx.lineTo(cx + size / 2, cy + size / 2); targetCtx.lineTo(cx - size / 2, cy + size / 2); targetCtx.lineTo(cx - size / 2, cy - size / 2); } - else if (type === 9) { for (let i = 0; i < 16; i++) { const r = (i % 2 === 0) ? size : size * 0.7; const a = (i / 16) * Math.PI * 2; targetCtx.lineTo(cx + Math.cos(a) * r, cy + Math.sin(a) * r); } } + if (iconType === 0) { for (let i = 0; i < 10; i++) { let r = (i % 2 === 0) ? size : size / 2; let a = (i / 10) * Math.PI * 2 - Math.PI / 2; targetCtx.lineTo(cx + Math.cos(a) * r, cy + Math.sin(a) * r); } } + else if (iconType === 1) { targetCtx.arc(cx - size / 4, cy + size / 4, size / 4, 0, Math.PI * 2); targetCtx.fill(); targetCtx.beginPath(); targetCtx.fillRect(cx, cy - size / 2, size / 6, size * 0.8); targetCtx.moveTo(cx, cy - size / 2); targetCtx.quadraticCurveTo(cx + size / 2, cy - size / 4, cx + size / 2, cy + size / 4); targetCtx.lineTo(cx + size / 3, cy + size / 4); targetCtx.quadraticCurveTo(cx + size / 3, cy - size / 4, cx, cy - size / 4); } + else if (iconType === 2) { targetCtx.moveTo(cx + size / 4, cy - size / 2); targetCtx.lineTo(cx - size / 2, cy + size / 8); targetCtx.lineTo(cx - size / 8, cy + size / 8); targetCtx.lineTo(cx - size / 4, cy + size / 2); targetCtx.lineTo(cx + size / 2, cy - size / 8); targetCtx.lineTo(cx + size / 8, cy - size / 8); } + else if (iconType === 3) { targetCtx.moveTo(cx, cy - size / 2); targetCtx.lineTo(cx + size / 2, cy); targetCtx.lineTo(cx, cy + size / 2); targetCtx.lineTo(cx - size / 2, cy); } + else if (iconType === 4) { targetCtx.arc(cx, cy, size / 2, 0, Math.PI * 2); targetCtx.fill(); targetCtx.fillStyle = getContrast(color); targetCtx.beginPath(); targetCtx.arc(cx, cy, size / 6, 0, Math.PI * 2); } + else if (iconType === 5) { targetCtx.translate(cx, cy); targetCtx.strokeStyle = color; targetCtx.lineWidth = 2; for (let i = 0; i < 3; i++) { targetCtx.rotate(Math.PI / 3); targetCtx.beginPath(); targetCtx.ellipse(0, 0, size, size / 3, 0, 0, Math.PI * 2); targetCtx.stroke(); } targetCtx.fillStyle = getContrast(bg1); targetCtx.beginPath(); targetCtx.arc(0, 0, size / 5, 0, Math.PI * 2); targetCtx.fill(); } + else if (iconType === 6) { targetCtx.moveTo(cx - size, cy + size / 2); targetCtx.lineTo(cx - size, cy - size / 2); targetCtx.lineTo(cx - size / 2, cy); targetCtx.lineTo(cx, cy - size); targetCtx.lineTo(cx + size / 2, cy); targetCtx.lineTo(cx + size, cy - size / 2); targetCtx.lineTo(cx + size, cy + size / 2); } + else if (iconType === 7) { targetCtx.moveTo(cx - size, cy + size); targetCtx.quadraticCurveTo(cx, cy, cx + size, cy - size); targetCtx.quadraticCurveTo(cx + size / 2, cy + size / 2, cx - size, cy + size); } + else if (iconType === 8) { targetCtx.moveTo(cx, cy - size / 2); targetCtx.bezierCurveTo(cx + size / 3, cy - size / 2, cx + size / 2, cy - size / 4, cx + size / 2, cy); targetCtx.lineTo(cx + size / 2.5, cy + size / 6); targetCtx.lineTo(cx + size / 2, cy + size / 3); targetCtx.lineTo(cx + size / 2, cy + size / 2); targetCtx.lineTo(cx - size / 2, cy + size / 2); targetCtx.lineTo(cx - size / 2, cy - size / 2); } + else if (iconType === 9) { for (let i = 0; i < 16; i++) { let r = (i % 2 === 0) ? size : size * 0.7; let a = (i / 16) * Math.PI * 2; targetCtx.lineTo(cx + Math.cos(a) * r, cy + Math.sin(a) * r); } } targetCtx.fill(); targetCtx.restore(); }; @@ -266,15 +148,15 @@ export function generateCardArt(song: CardSong): CardArt { else if (bgStyle === 34) { ctx.fillStyle = bg2; ctx.fillRect(0, 0, 256, 25); ctx.fillRect(0, 75, 256, 25); ctx.fillStyle = acc1; ctx.fillRect(0, 25, 256, 5); ctx.fillRect(0, 70, 256, 5); } else if (bgStyle === 35) { ctx.fillStyle = bg1; ctx.fillRect(0, 0, 256, 100); ctx.fillStyle = bg2; ctx.beginPath(); ctx.moveTo(0, 100); ctx.lineTo(150, 0); ctx.lineTo(256, 0); ctx.lineTo(256, 100); ctx.fill(); ctx.fillStyle = '#111111'; ctx.beginPath(); ctx.moveTo(150, 100); ctx.lineTo(256, 0); ctx.lineTo(256, 100); ctx.fill(); } else if (bgStyle === 36) { ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, 256, 100); ctx.fillStyle = acc1; ctx.fillRect(10, 10, 40, 40); ctx.strokeStyle = '#333'; ctx.lineWidth = 2; ctx.strokeRect(5, 5, 246, 90); } - else if (bgStyle === 37) { ctx.fillStyle = bg1; ctx.fillRect(0,0,256,100); ctx.fillStyle = bg2; ctx.beginPath(); for(let i=0; i<16; i++) { const angle1 = (i/16) * Math.PI*2; const angle2 = ((i+0.5)/16) * Math.PI*2; ctx.moveTo(128,50); ctx.lineTo(128+Math.cos(angle1)*200, 50+Math.sin(angle1)*200); ctx.lineTo(128+Math.cos(angle2)*200, 50+Math.sin(angle2)*200); } ctx.fill(); } - else if (bgStyle === 38) { ctx.fillStyle = bg1; ctx.fillRect(0,0,256,100); ctx.fillStyle = bg2; for(let x=0; x<280; x+=20) { for(let y=0; y<120; y+=20) { const offset = (y/20)%2===0 ? 0 : 10; ctx.beginPath(); ctx.arc(x+offset, y, 7, 0, Math.PI*2); ctx.fill(); } } } + else if (bgStyle === 37) { ctx.fillStyle = bg1; ctx.fillRect(0,0,256,100); ctx.fillStyle = bg2; ctx.beginPath(); for(let i=0; i<16; i++) { let angle1 = (i/16) * Math.PI*2; let angle2 = ((i+0.5)/16) * Math.PI*2; ctx.moveTo(128,50); ctx.lineTo(128+Math.cos(angle1)*200, 50+Math.sin(angle1)*200); ctx.lineTo(128+Math.cos(angle2)*200, 50+Math.sin(angle2)*200); } ctx.fill(); } + else if (bgStyle === 38) { ctx.fillStyle = bg1; ctx.fillRect(0,0,256,100); ctx.fillStyle = bg2; for(let x=0; x<280; x+=20) { for(let y=0; y<120; y+=20) { let offset = (y/20)%2===0 ? 0 : 10; ctx.beginPath(); ctx.arc(x+offset, y, 7, 0, Math.PI*2); ctx.fill(); } } } else if (bgStyle === 39) { ctx.fillStyle = '#ffffff'; ctx.fillRect(0,0,256,100); ctx.fillStyle = acc1; ctx.fillRect(20,0,60,60); ctx.fillStyle = acc2; ctx.fillRect(150,40,106,60); ctx.fillStyle = bg2; ctx.fillRect(0, 70, 80, 30); ctx.strokeStyle = '#000'; ctx.lineWidth = 4; ctx.beginPath(); ctx.moveTo(80,0); ctx.lineTo(80,100); ctx.moveTo(0,60); ctx.lineTo(256,60); ctx.moveTo(150,0); ctx.lineTo(150,100); ctx.stroke(); } - else if (bgStyle === 40) { ctx.fillStyle = bg1; ctx.fillRect(0,0,256,100); ctx.fillStyle = bg2; const w = 24, h = 36; for (let x = -15; x < 260; x += w) { for (let y = -20; y < 120; y += h) { if ((Math.round(x/w) + Math.round(y/h)) % 2 === 0) { ctx.beginPath(); ctx.moveTo(x, y + h/2); ctx.lineTo(x + w/2, y); ctx.lineTo(x + w, y + h/2); ctx.lineTo(x + w/2, y + h); ctx.fill(); } } } } - else if (bgStyle === 41) { ctx.fillStyle = bg1; ctx.fillRect(0,0,256,100); const colors = [bg2, acc1, bg1, acc2]; for(let i=0; i<4; i++) { ctx.fillStyle = colors[i]; ctx.beginPath(); ctx.arc(128, 100, 120 - (i*25), Math.PI, 0); ctx.fill(); } } + else if (bgStyle === 40) { ctx.fillStyle = bg1; ctx.fillRect(0,0,256,100); ctx.fillStyle = bg2; let w = 24; let h = 36; for (let x = -15; x < 260; x += w) { for (let y = -20; y < 120; y += h) { if ((Math.round(x/w) + Math.round(y/h)) % 2 === 0) { ctx.beginPath(); ctx.moveTo(x, y + h/2); ctx.lineTo(x + w/2, y); ctx.lineTo(x + w, y + h/2); ctx.lineTo(x + w/2, y + h); ctx.fill(); } } } } + else if (bgStyle === 41) { ctx.fillStyle = bg1; ctx.fillRect(0,0,256,100); let colors = [bg2, acc1, bg1, acc2]; for(let i=0; i<4; i++) { ctx.fillStyle = colors[i]; ctx.beginPath(); ctx.arc(128, 100, 120 - (i*25), Math.PI, 0); ctx.fill(); } } else if (bgStyle === 42) { ctx.fillStyle = bg1; ctx.fillRect(0,0,256,100); ctx.fillStyle = bg2; for(let x=5; x<256; x+=12) { ctx.fillRect(x, 0, 2, 100); } } // NEW BACKGROUND CONCEPTS else if (bgStyle === 43) { ctx.fillStyle = bg1; ctx.fillRect(0,0,256,100); ctx.strokeStyle = bg2; ctx.lineWidth = 10; for(let y = -20; y < 120; y+=20) { ctx.beginPath(); for(let x = 0; x <= 260; x+=20) { ctx.lineTo(x, y + ((x/20)%2===0 ? 10 : 0)); } ctx.stroke(); } } // Chevron - else if (bgStyle === 44) { ctx.fillStyle = bg1; ctx.fillRect(0,0,256,100); const colors = [bg2, acc1, acc2]; for(let i=0; i<3; i++) { ctx.fillStyle = colors[i]; ctx.beginPath(); ctx.moveTo(0,100); for(let x=0; x<=256; x+=10) { ctx.lineTo(x, 30 + (i*15) + Math.sin(x/30)*15); } ctx.lineTo(256, 100); ctx.fill(); } } // Wavy 70s Stripes + else if (bgStyle === 44) { ctx.fillStyle = bg1; ctx.fillRect(0,0,256,100); let colors = [bg2, acc1, acc2]; for(let i=0; i<3; i++) { ctx.fillStyle = colors[i]; ctx.beginPath(); ctx.moveTo(0,100); for(let x=0; x<=256; x+=10) { ctx.lineTo(x, 30 + (i*15) + Math.sin(x/30)*15); } ctx.lineTo(256, 100); ctx.fill(); } } // Wavy 70s Stripes else if (bgStyle === 45) { ctx.fillStyle = bg1; ctx.fillRect(0,0,256,100); ctx.fillStyle = 'rgba(0,0,0,0.2)'; for(let y=0; y<100; y+=3) ctx.fillRect(0, y, 256, 1); ctx.fillStyle = 'rgba(255,255,255,0.1)'; for(let y=1; y<100; y+=3) ctx.fillRect(0, y, 256, 1); } // TV Scanlines // ========================================== @@ -336,7 +218,7 @@ export function generateCardArt(song: CardSong): CardArt { } else if (decorStyle === 45) { // Overlapping Swiss Circles ctx.save(); - ctx.globalCompositeOperation = getWcagLuminance(parseInt(bg1.substr(1,2),16), parseInt(bg1.substr(3,2),16), parseInt(bg1.substr(5,2),16)) > 0.5 ? 'multiply' : 'screen'; + ctx.globalCompositeOperation = getWcagLuminance(parseInt(bg1.substr(1, 2), 16), parseInt(bg1.substr(3, 2), 16), parseInt(bg1.substr(5, 2), 16)) > 0.5 ? 'multiply' : 'screen'; ctx.fillStyle = acc1; ctx.beginPath(); ctx.arc(100, 50, 35, 0, Math.PI*2); ctx.fill(); ctx.fillStyle = acc2; ctx.beginPath(); ctx.arc(150, 50, 35, 0, Math.PI*2); ctx.fill(); ctx.fillStyle = bg2; ctx.beginPath(); ctx.arc(125, 30, 35, 0, Math.PI*2); ctx.fill(); @@ -368,18 +250,18 @@ export function generateCardArt(song: CardSong): CardArt { cBg = '#ffffff'; } - const mTxt = getContrast(cBg); - const aTxt = (mTxt === '#ffffff') ? acc1 : acc2; + let mTxt = getContrast(cBg); + let aTxt = (mTxt === '#ffffff') ? acc1 : acc2; // ========================================== // LAYER 4: TYPOGRAPHY / LAYOUT // ========================================== - const tA = song.titleA.toUpperCase(); - const tB = song.titleB.toUpperCase(); - const art = song.artist; - const cd = song.code; + let tA = song.titleA.toUpperCase(); + let tB = song.titleB.toUpperCase(); + let art = song.artist; + let cd = song.code; - const boxCode = (_ctxRef: CanvasRenderingContext2D, bx: number, by: number, bw: number, bh: number) => { + let boxCode = (_ctxRef, bx, by, bw, bh) => { ctx.fillStyle = aTxt; ctx.fillRect(bx, by, bw, bh); drawAdvText(textCtx, cd, bx + bw / 2, by + bh / 2, `bold 14px ${fCode}`, getContrast(aTxt), 'center'); @@ -429,8 +311,8 @@ export function generateCardArt(song: CardSong): CardArt { else if (layoutStyle === 41) { ctx.fillStyle = aTxt; ctx.fillRect(0,0,40,100); textCtx.fillStyle = getContrast(aTxt); textCtx.font = "bold 12px " + fDisp; textCtx.textAlign = 'center'; textCtx.textBaseline = 'middle'; - const chars = art.toUpperCase().substring(0, 8); - const startY = 50 - ((chars.length - 1) * 10 / 2); + let chars = art.toUpperCase().substring(0, 8); + let startY = 50 - ((chars.length - 1) * 10 / 2); for(let i=0; i 0) { - const lumT = getWcagLuminance(txtData[i], txtData[i + 1], txtData[i + 2]); - const lumB = getWcagLuminance(bgData[i], bgData[i + 1], bgData[i + 2]); + let lumT = getWcagLuminance(txtData[i], txtData[i + 1], txtData[i + 2]); + let lumB = getWcagLuminance(bgData[i], bgData[i + 1], bgData[i + 2]); - const brightest = Math.max(lumT, lumB); - const darkest = Math.min(lumT, lumB); - const contrast = (brightest + 0.05) / (darkest + 0.05); + let brightest = Math.max(lumT, lumB); + let darkest = Math.min(lumT, lumB); + let contrast = (brightest + 0.05) / (darkest + 0.05); - let outR = txtData[i], outG = txtData[i + 1], outB = txtData[i + 2]; + let outR = txtData[i] + let outG = txtData[i + 1] + let outB = txtData[i + 2] if (contrast < WCAG_CONTRAST_MIN) { - const newColor = lumB > 0.179 ? 0 : 255; - outR = newColor; - outG = newColor; - outB = newColor; + let newColor = lumB > 0.179 ? 0 : 255 + outR = newColor + outG = newColor + outB = newColor } - const alpha = txtData[i + 3] / 255; + let alpha = txtData[i + 3] / 255; finalData.data[i] = outR * alpha + bgData[i] * (1 - alpha); finalData.data[i + 1] = outG * alpha + bgData[i + 1] * (1 - alpha); finalData.data[i + 2] = outB * alpha + bgData[i + 2] * (1 - alpha); @@ -520,7 +404,7 @@ export function generateCardArt(song: CardSong): CardArt { } // Source stripe - const srcColor = song.source === 'now' ? '#00ff88' : song.source === 'queue' ? '#ffaa00' : null; + let srcColor = song.source === 'now' ? '#00ff88' : song.source === 'queue' ? '#ffaa00' : null; if (srcColor) { ctx.fillStyle = srcColor; ctx.fillRect(0, 0, 4, 100); } // Paper-grain noise overlay diff --git a/packages/juke-cards/src/index.tish b/packages/juke-cards/src/index.tish new file mode 100644 index 0000000..02b8476 --- /dev/null +++ b/packages/juke-cards/src/index.tish @@ -0,0 +1,17 @@ +import { xmur3, getContrast } from "./xmur3.tish" +import { palettes, displayFonts, scriptFonts, codeFonts, infoFonts, preloadCardFonts } from "./palettes.tish" +import { generateCardArt } from "./generate.tish" + +export fn __jukeCardsBundle() { + return { + xmur3, + getContrast, + palettes, + displayFonts, + scriptFonts, + codeFonts, + infoFonts, + preloadCardFonts, + generateCardArt, + } +} diff --git a/packages/juke-cards/src/layouts.tish b/packages/juke-cards/src/layouts.tish new file mode 100644 index 0000000..482d16b --- /dev/null +++ b/packages/juke-cards/src/layouts.tish @@ -0,0 +1 @@ +// Layer 4 layout / typography styles — implemented in generate.tish. diff --git a/packages/juke-cards/src/palettes.tish b/packages/juke-cards/src/palettes.tish new file mode 100644 index 0000000..a1c9b90 --- /dev/null +++ b/packages/juke-cards/src/palettes.tish @@ -0,0 +1,60 @@ +const palettes = [ + ["#e63946", "#f1faee", "#1d3557", "#a8dadc"], ["#ffb703", "#fb8500", "#023047", "#8ecae6"], + ["#ef476f", "#ffd166", "#06d6a0", "#073b4c"], ["#ffffff", "#000000", "#dddddd", "#ff0000"], + ["#f4a261", "#e76f51", "#2a9d8f", "#264653"], ["#8ecae6", "#219ebc", "#023047", "#ffb703"], + ["#d9ed92", "#b5e48c", "#34a0a4", "#1a759f"], ["#2b2d42", "#8d99ae", "#edf2f4", "#d90429"], + ["#000000", "#14213d", "#fca311", "#e5e5e5"], ["#3d5a80", "#98c1d9", "#e0fbfc", "#ee6c4d"], + ["#ff9f1c", "#ffbf69", "#ffffff", "#cbf3f0"], ["#f72585", "#7209b7", "#3a0ca3", "#4cc9f0"], + ["#dad7cd", "#a3b18a", "#588157", "#344e41"], ["#fec5bb", "#fcd5ce", "#fae1dd", "#f8edeb"], + ["#000000", "#d4af37", "#ffffff", "#111111"], ["#ff00ff", "#00ffff", "#000000", "#ffff00"], + ["#f4e285", "#f4a259", "#5b8e7d", "#bc4b51"], ["#2a0800", "#f4d03f", "#e74c3c", "#ffffff"], + ["#fdf6e3", "#eee8d5", "#073642", "#cb4b16"], ["#4a4e69", "#9a8c98", "#c9ada7", "#f2e9e4"], + ["#ff595e", "#ffca3a", "#8ac926", "#1982c4"], ["#1d3557", "#457b9d", "#a8dadc", "#e63946"], + ["#283618", "#606c38", "#fefae0", "#dda15e"], ["#5f0f40", "#9a031e", "#fb8b24", "#e36414"], + ["#0b132b", "#1c2541", "#3a506b", "#5bc0be"], ["#335c67", "#fff3b0", "#e09f3e", "#9e2a2b"], + ["#003049", "#d62828", "#f77f00", "#fcbf49"], ["#22223b", "#4a4e69", "#9a8c98", "#c9ada7"], + ["#0d3b66", "#faf0ca", "#f4d35e", "#ee964b"], ["#f94144", "#f3722c", "#f8961e", "#f9c74f"], + ["#5d5c61", "#379683", "#7395ae", "#557a95"], ["#1a1a1d", "#4e4e50", "#6f2232", "#950740"], + ["#c0c0c0", "#ffffff", "#808080", "#000000"], ["#ffe066", "#247ba0", "#70c1b3", "#50514f"], + ["#6b2d5c", "#f0386b", "#ff5376", "#f8c0c8"], +] + +const displayFonts = ["'Anton'", "'Bebas Neue'", "'Monoton'", "'Righteous'", "'Fjalla One'", "'Limelight'", "'Rampart One'", "'Rubik Mono One'", "'Abril Fatface'", "'Bungee'", "'Bangers'", "'Erica One'", "'Fugaz One'", "'Ultra'", "'Vampiro One'", "'Chicle'", "'Russo One'", "'Sigmar One'", "'Fascinate Inline'"] +const scriptFonts = ["'Pacifico'", "'Lobster'", "'Yellowtail'", "'Satisfy'", "'Shrikhand'", "'Permanent Marker'", "'Fascinate'", "'Damion'", "'Cookie'", "'Leckerli One'"] +const codeFonts = ["'Rubik Mono One'", "'Courier Prime'", "'Oswald'", "'Space Mono'", "'Special Elite'"] +const infoFonts = ["'Courier Prime'", "'Oswald'", "'Cinzel'", "'Playfair Display'", "'Poiret One'", "'Arimo'", "'Corben'", "'Space Mono'", "'Special Elite'"] + +fn uniqueFamilies() { + let out = [] + let lists = [displayFonts, scriptFonts, codeFonts, infoFonts] + let li = 0 + while (li < lists.length) { + let list = lists[li] + let fi = 0 + while (fi < list.length) { + let f = list[fi] + let found = false + let oi = 0 + while (oi < out.length) { + if (out[oi] === f) { found = true } + oi = oi + 1 + } + if (!found) { out.push(f) } + fi = fi + 1 + } + li = li + 1 + } + return out +} + +async fn preloadCardFonts() { + if (typeof document === "undefined" || document.fonts === null) { return } + let families = uniqueFamilies() + let i = 0 + while (i < families.length) { + let f = families[i] + try { await document.fonts.load("16px " + f) } catch (e) {} + try { await document.fonts.load("bold 16px " + f) } catch (e) {} + i = i + 1 + } +} diff --git a/packages/juke-cards/src/xmur3.tish b/packages/juke-cards/src/xmur3.tish new file mode 100644 index 0000000..3a54803 --- /dev/null +++ b/packages/juke-cards/src/xmur3.tish @@ -0,0 +1,23 @@ +fn xmur3(str) { + let h = 1779033703 ^ str.length + let i = 0 + while (i < str.length) { + h = Math.imul(h ^ str.charCodeAt(i), 3432918353) + h = (h * 8192) | Math.floor(h / 524288) + i = i + 1 + } + return () => { + h = Math.imul(h ^ Math.floor(h / 65536), 2246822507) + h = Math.imul(h ^ Math.floor(h / 8192), 3266489909) + h = h ^ Math.floor(h / 65536) + return h % 4294967296 + } +} + +fn getContrast(hex) { + if (hex === null || hex === "" || hex.charAt(0) !== "#") { return "#000000" } + let r = parseInt(hex.substr(1, 2), 16) + let g = parseInt(hex.substr(3, 2), 16) + let b = parseInt(hex.substr(5, 2), 16) + return (r * 299 + g * 587 + b * 114) / 1000 >= 128 ? "#000000" : "#ffffff" +} diff --git a/packages/juke-cards/test/run-tests.mjs b/packages/juke-cards/test/run-tests.mjs new file mode 100644 index 0000000..60266e8 --- /dev/null +++ b/packages/juke-cards/test/run-tests.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node +/** + * Smoke + deterministic regression tests for @spacedevin/juke-cards. + * Runs in Node with jsdom-like minimal DOM stubs for canvas. + */ +import assert from "node:assert/strict" +import { createRequire } from "node:module" +import path from "node:path" +import { fileURLToPath } from "node:url" + +const require = createRequire(import.meta.url) +const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))) + +// Minimal canvas stub +class CanvasStub { + constructor() { + this.width = 0 + this.height = 0 + this._ctx = new CtxStub(this) + } + getContext() { return this._ctx } +} +class CtxStub { + constructor(canvas) { this.canvas = canvas } + save() {} + restore() {} + fillRect() {} + fillText() {} + strokeRect() {} + beginPath() {} + closePath() {} + moveTo() {} + lineTo() {} + arc() {} + ellipse() {} + quadraticCurveTo() {} + bezierCurveTo() {} + translate() {} + rotate() {} + scale() {} + stroke() {} + fill() {} + setLineDash() {} + drawImage() {} + getImageData() { + const n = this.canvas.width * this.canvas.height * 4 + return { data: new Uint8ClampedArray(n), width: this.canvas.width, height: this.canvas.height } + } + createImageData(w, h) { + return { data: new Uint8ClampedArray(w * h * 4), width: w, height: h } + } + putImageData() {} +} + +globalThis.document = { + createElement(tag) { + if (tag === "canvas") return new CanvasStub() + return {} + }, + fonts: { load: async () => {}, ready: Promise.resolve() }, +} + +const mod = await import(path.join(root, "lib/index.mjs")) + +assert.equal(typeof mod.xmur3, "function") +assert.equal(typeof mod.generateCardArt, "function") + +const g1 = mod.xmur3("seed") +const g2 = mod.xmur3("seed") +assert.equal(g1(), g2()) +assert.notEqual(g1(), mod.xmur3("other")()) + +const song = { titleA: "JOHNNY B", titleB: "GOODE", artist: "Chuck Berry", code: "A1" } +const a = mod.generateCardArt(song) +const b = mod.generateCardArt(song) +assert.equal(a.accent, b.accent) +assert.equal(a.bg, b.bg) +assert.equal(a.canvas.width, 256) +assert.equal(a.canvas.height, 100) + +const c = mod.generateCardArt({ ...song, code: "A2" }) +assert.notEqual(a.accent, c.accent) + +console.log("juke-cards tests passed") diff --git a/packages/jukebox/deps/lattish/Lattish.tish b/packages/jukebox/deps/lattish/Lattish.tish new file mode 120000 index 0000000..516731b --- /dev/null +++ b/packages/jukebox/deps/lattish/Lattish.tish @@ -0,0 +1 @@ +../../../../node_modules/@tishlang/lattish/src/Lattish.tish \ No newline at end of file diff --git a/packages/jukebox/deps/lattish/package.json b/packages/jukebox/deps/lattish/package.json new file mode 100644 index 0000000..50a0c0a --- /dev/null +++ b/packages/jukebox/deps/lattish/package.json @@ -0,0 +1,14 @@ +{ + "name": "lattish", + "version": "0.0.0", + "private": true, + "description": "Resolve import \"lattish\" to @tishlang/lattish source (no wrapper).", + "tish": { + "module": "./Lattish.tish" + }, + "exports": { + ".": { + "tish": "./Lattish.tish" + } + } +} diff --git a/packages/jukebox/dev-server.tish b/packages/jukebox/dev-server.tish new file mode 100644 index 0000000..cb1717b --- /dev/null +++ b/packages/jukebox/dev-server.tish @@ -0,0 +1,58 @@ +import { fileExists, isDir } from "fs" +import { process } from "process" +import { serve } from "http" + +fn contentType(path) { + if (path.endsWith(".html")) { return "text/html; charset=utf-8" } + if (path.endsWith(".css")) { return "text/css; charset=utf-8" } + if (path.endsWith(".js") || path.endsWith(".mjs")) { return "application/javascript; charset=utf-8" } + if (path.endsWith(".wasm")) { return "application/wasm" } + if (path.endsWith(".json")) { return "application/json" } + if (path.endsWith(".svg")) { return "image/svg+xml" } + if (path.endsWith(".png")) { return "image/png" } + if (path.endsWith(".ico")) { return "image/x-icon" } + return "application/octet-stream" +} + +fn isAssetPath(reqPath) { + if (reqPath.indexOf("/dist/") === 0) { return true } + let lastSlash = reqPath.lastIndexOf("/") + let lastSeg = lastSlash >= 0 ? reqPath.substring(lastSlash + 1) : reqPath + return lastSeg.indexOf(".") >= 0 +} + +fn serveFile(path) { + return { status: 200, headers: { "Content-Type": contentType(path), "Cache-Control": "no-store" }, file: path } +} + +fn handleRequest(req) { + let p = req.path + if (p === null || p === "") { p = "/" } + + if (p === "/health") { + return { status: 200, headers: { "Content-Type": "text/plain; charset=utf-8" }, body: "ok" } + } + + if (p.indexOf("/callback") === 0) { + console.log("[jukebox] Spotify callback: " + p) + } + + if (p === "/" || p === "") { + if (fileExists("public/index.html")) { return serveFile("public/index.html") } + } + + let rel = p.charAt(0) === "/" ? p.substring(1) : p + let candidate = "public/" + rel + if (fileExists(candidate) && !isDir(candidate)) { return serveFile(candidate) } + + if (!isAssetPath(p) && fileExists("public/index.html")) { return serveFile("public/index.html") } + + return { status: 404, body: "Not found: " + req.path } +} + +let port = 3000 +if (process !== null && process.env !== null && "PORT" in process.env) { + port = parseInt(process.env["PORT"]) +} +console.log("jukebox dev server: http://127.0.0.1:" + port) +serve(port, handleRequest) diff --git a/packages/jukebox/package-lock.json b/packages/jukebox/package-lock.json new file mode 100644 index 0000000..1012f8c --- /dev/null +++ b/packages/jukebox/package-lock.json @@ -0,0 +1,29 @@ +{ + "name": "@spacedevin/jukebox", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@spacedevin/jukebox", + "version": "0.1.0", + "dependencies": { + "@tishlang/lattish": "^1.1.3", + "lattish": "file:./deps/lattish" + } + }, + "deps/lattish": { + "version": "0.0.0" + }, + "node_modules/@tishlang/lattish": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@tishlang/lattish/-/lattish-1.1.3.tgz", + "integrity": "sha512-G8Jw73IgMs+7dOSJRiT4Ljbu0ORG5YtFZ2Q8JNmNkDJhCwu/k8uD+lpqodTEApcVyDauhiVwnEd5L1ZJHLdFmA==", + "license": "PIF" + }, + "node_modules/lattish": { + "resolved": "deps/lattish", + "link": true + } + } +} diff --git a/packages/jukebox/package.json b/packages/jukebox/package.json new file mode 100644 index 0000000..956ebc1 --- /dev/null +++ b/packages/jukebox/package.json @@ -0,0 +1,16 @@ +{ + "name": "@spacedevin/jukebox", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "juke.sh Lattish SPA — Spotify WebGL jukebox", + "scripts": { + "build": "node ../../scripts/build-jukebox.mjs", + "dev": "npm run build && tish run --feature fs --feature http --feature process dev-server.tish", + "dev:server": "tish run --feature fs --feature http --feature process dev-server.tish" + }, + "dependencies": { + "@tishlang/lattish": "^1.1.3", + "lattish": "file:./deps/lattish" + } +} diff --git a/packages/jukebox/public/base.css b/packages/jukebox/public/base.css new file mode 100644 index 0000000..252f8b1 --- /dev/null +++ b/packages/jukebox/public/base.css @@ -0,0 +1,534 @@ +html, body { + margin: 0; + background: black; + color: #fff; + font-family: 'JetBrains Mono', ui-monospace, monospace; + text-align: center; +} + +::selection, *::selection { + background: #00ff88 !important; + color: #0a0a0a !important; +} + +body { + overflow: hidden; + touch-action: none; + background: radial-gradient(circle at center, #11090c 0%, #000000 100%); +} + +.center-screen { + position: fixed; + inset: 0; + z-index: 30; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: clamp(14px, 3vw, 24px); + padding: clamp(16px, 4vw, 24px); + padding-bottom: calc(40px + env(safe-area-inset-bottom)); + overflow-y: auto; + box-sizing: border-box; +} + +.landing-title { + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: clamp(34px, 10vw, 64px); + letter-spacing: clamp(2px, 0.6vw, 4px); + color: #00ff88; + text-shadow: 0 0 20px rgba(0,255,136,0.4); + line-height: 1.1; + max-width: 100%; + word-break: break-word; +} + +.landing-tag { + color: #888; + font-size: clamp(11px, 2.6vw, 14px); + letter-spacing: clamp(1px, 0.4vw, 2px); + text-transform: uppercase; + max-width: 90vw; +} + +.loading-text { + color: #ccc; + font-size: 14px; + letter-spacing: 2px; + text-transform: uppercase; +} + +.error-text { + color: #ff6680; +} + +.callback-msg { + font-family: monospace; + padding: 20px; + color: #fff; +} + +.sp-btn { + background: rgba(20,10,15,0.85); + color: #00ff88; + border: 2px solid #00ff88; + padding: 10px 18px; + border-radius: 8px; + font-size: 14px; + letter-spacing: 1px; + font-family: inherit; + cursor: pointer; + text-decoration: none; + display: inline-block; + transition: background-color 0.15s, color 0.15s; +} + +.sp-btn:hover { + background: rgba(0,255,136,0.12); +} + +.sp-btn:disabled { + opacity: 0.35; + cursor: default; +} + +.sp-btn.sp-btn-icon { + width: 36px; + height: 36px; + padding: 0; + font-size: 18px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.sp-btn.picked { + background: #00ff88; + color: #0a0a0a; +} + +.sp-btn.sp-btn-secondary { + color: rgba(255,255,255,0.6); + border-color: rgba(255,255,255,0.25); +} + +.sp-btn.sp-btn-secondary:hover { + color: #fff; + border-color: rgba(255,255,255,0.5); + background: rgba(255,255,255,0.05); +} + +.spinner { + width: 38px; + height: 38px; + border: 3px solid rgba(0,255,136,0.18); + border-top-color: #00ff88; + border-radius: 50%; + animation: sp 0.9s linear infinite; +} + +@keyframes sp { + to { transform: rotate(360deg); } +} + +.site-footer { + position: fixed; + bottom: calc(8px + env(safe-area-inset-bottom)); + left: 0; + right: 0; + z-index: 100; + display: flex; + justify-content: center; + pointer-events: none; + padding: 0 12px; +} + +.site-footer a { + pointer-events: auto; + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + letter-spacing: 1px; + color: rgba(255,255,255,0.45); + text-decoration: none; + padding: 4px 10px; + border-radius: 999px; + transition: color 0.15s, background-color 0.15s; + text-shadow: 0 0 8px rgba(0,0,0,0.8); + background: rgba(0,0,0,0.6); +} + +.site-footer a:hover { + color: #00ff88; + background: rgba(0,0,0,0.4); +} + +.site-footer svg { + width: 12px; + height: 12px; +} + +.site-footer .heart { + color: #ff4d6d; +} + +.auth-form { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 10px; + width: min(360px, 86vw); +} + +.client-id-input { + background: rgba(20,10,15,0.85); + color: #fff; + border: 2px solid rgba(0,255,136,0.35); + border-radius: 8px; + padding: 10px 14px; + font-family: inherit; + font-size: 13px; + letter-spacing: 1px; + text-align: center; + outline: none; +} + +.client-id-input::placeholder { + color: rgba(255,255,255,0.35); + letter-spacing: 1px; +} + +.client-id-input:focus { + border-color: #00ff88; +} + +.auth-hint { + font-size: 11px; + color: #888; + letter-spacing: 0.5px; + padding: 4px 0 0; +} + +.auth-hint a { + color: #00ff88; + text-decoration: none; +} + +.auth-hint a:hover { + text-decoration: underline; +} + +#webgl-container { + background: radial-gradient(circle at center, #11090c 0%, #000000 100%); + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + outline: none; +} + +#webgl-container, #webgl-container canvas { + -webkit-user-select: none; + user-select: none; + -webkit-touch-callout: none; + -webkit-tap-highlight-color: transparent; +} + +#ui-overlay { + position: fixed; + inset: 0; + pointer-events: none; + display: flex; + align-items: center; + justify-content: center; + z-index: 10; +} + +.playback-toast { + position: fixed; + top: max(20px, env(safe-area-inset-top)); + left: 50%; + transform: translateX(-50%); + max-width: min(420px, 88vw); + background: rgba(60, 10, 20, 0.95); + color: #ffcdd6; + border: 1px solid rgba(255, 102, 128, 0.4); + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5); + padding: 12px 18px; + border-radius: 10px; + font-size: 13px; + letter-spacing: 0.5px; + text-align: center; + line-height: 1.5; + pointer-events: auto; + cursor: pointer; + z-index: 200; + animation: toast-in 0.25s ease-out; +} + +@keyframes toast-in { + from { opacity: 0; transform: translate(-50%, -8px); } + to { opacity: 1; transform: translate(-50%, 0); } +} + +.instruction-badge { + background: rgba(20,10,15,0.85); + color: #00ff88; + padding: 10px 22px; + border-radius: 999px; + border: 2px solid #00ff88; + font-size: 14px; + letter-spacing: 2px; + text-shadow: 0 0 8px rgba(0,255,136,0.5); + box-shadow: 0 0 18px rgba(0,255,136,0.35); + line-height: 2em; + margin-bottom: 20px; +} + +.instruction-subtitle { + color: #ffffff; + font-size: 14px; + text-shadow: 0 0 8px rgba(0,0,0,0.8); + line-height: 2em; +} + +.modal { + background: rgba(10,10,12,0.96); + border: 2px solid #00ff88; + border-radius: 12px; + padding: 24px; + width: min(560px, 92vw); + max-height: 80vh; + display: flex; + flex-direction: column; + gap: 12px; + box-shadow: 0 0 30px rgba(0,255,136,0.15); +} + +.modal-title { + color: #00ff88; + font-size: 18px; + letter-spacing: 3px; + text-align: center; +} + +.modal-sub { + color: #888; + font-size: 12px; + letter-spacing: 1px; + text-align: center; + margin-bottom: 4px; +} + +.pl-list { + list-style: none; + margin: 0; + padding: 0; + overflow-y: auto; + flex: 1; + min-height: 0; + border-top: 1px solid rgba(0,255,136,0.18); + border-bottom: 1px solid rgba(0,255,136,0.18); +} + +.pl-row { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 4px; + border-bottom: 1px solid rgba(255,255,255,0.04); +} + +.cache-chip { + background: rgba(0,255,136,0.08); + color: #00ff88; + border: 1px solid rgba(0,255,136,0.5); + border-radius: 999px; + padding: 4px 10px; + font-family: inherit; + font-size: 10px; + letter-spacing: 1px; + cursor: pointer; + transition: background-color 0.15s; +} + +.cache-chip:hover { + background: rgba(255,80,80,0.18); + color: #ff8080; + border-color: #ff8080; +} + +.pl-row:last-child { + border-bottom: none; +} + +.pl-art { + width: 48px; + height: 48px; + object-fit: cover; + border-radius: 4px; + flex-shrink: 0; +} + +.pl-art-blank { + background: #222; +} + +.pl-meta { + flex: 1; + min-width: 0; + text-align: left; +} + +.pl-name { + color: #fff; + font-size: 14px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.pl-id { + color: #666; + font-size: 11px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 2px; +} + +.modal-actions { + display: flex; + justify-content: center; + margin-top: 4px; +} + +.settings-backdrop { + background: rgba(0,0,0,0.55); + -webkit-backdrop-filter: blur(6px); + backdrop-filter: blur(6px); +} + +.modal.modal-narrow { + width: min(360px, 92vw); + padding: 18px; + gap: 8px; +} + +.set-row { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 10px; +} + +.set-row.set-toggle { + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.set-label { + display: flex; + justify-content: space-between; + align-items: baseline; + font-size: 11px; + color: #ccc; + letter-spacing: 1px; +} + +.set-val { + color: #00ff88; + font-variant-numeric: tabular-nums; +} + +.set-row input[type="range"] { + -webkit-appearance: none; + appearance: none; + width: 100%; + height: 4px; + background: rgba(0,255,136,0.18); + border-radius: 999px; + outline: none; +} + +.set-row input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 20px; + height: 20px; + border-radius: 50%; + background: #00ff88; + cursor: pointer; + border: none; + box-shadow: 0 0 8px rgba(0,255,136,0.5); +} + +.set-row input[type="range"]::-moz-range-thumb { + width: 20px; + height: 20px; + border-radius: 50%; + background: #00ff88; + cursor: pointer; + border: none; + box-shadow: 0 0 8px rgba(0,255,136,0.5); +} + +.set-zero { + align-self: flex-end; + margin-top: -4px; + background: transparent; + color: #00ff88; + border: 1px solid rgba(0,255,136,0.45); + border-radius: 999px; + font-family: inherit; + font-size: 12px; + width: 32px; + height: 28px; + cursor: pointer; +} + +.set-zero:hover { + background: rgba(0,255,136,0.12); +} + +.set-row.set-toggle input[type="checkbox"] { + -webkit-appearance: none; + appearance: none; + width: 46px; + height: 26px; + border-radius: 999px; + background: rgba(0,255,136,0.18); + border: 1px solid rgba(0,255,136,0.4); + position: relative; + cursor: pointer; +} + +.set-row.set-toggle input[type="checkbox"]::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 20px; + height: 20px; + border-radius: 50%; + background: #00ff88; + transition: transform 0.15s; + box-shadow: 0 0 8px rgba(0,255,136,0.5); +} + +.set-row.set-toggle input[type="checkbox"]:checked::after { + transform: translateX(20px); +} + +.set-hint { + color: #888; + font-size: 10px; + letter-spacing: 0.5px; + margin: -6px 0 0 0; + text-align: left; + line-height: 1.5; +} diff --git a/packages/jukebox/public/boot.js b/packages/jukebox/public/boot.js new file mode 100644 index 0000000..ce7842d --- /dev/null +++ b/packages/jukebox/public/boot.js @@ -0,0 +1,11 @@ +// Card art is external to vendor.js — register a require shim before loading the IIFE bundle. +import * as jukeCards from "/dist/juke-cards.js" + +globalThis.require = (name) => { + if (name === "@spacedevin/juke-cards") { return jukeCards } + throw new Error('Dynamic require of "' + name + '" is not supported') +} + +// jukebox.js bundles the Lattish runtime via `import from "lattish"` — do not load lattish-runtime.js separately. +await import("/dist/vendor.js") +await import("/dist/jukebox.js") diff --git a/packages/jukebox/public/icon.svg b/packages/jukebox/public/icon.svg new file mode 100644 index 0000000..c2c1a91 --- /dev/null +++ b/packages/jukebox/public/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/jukebox/public/index.html b/packages/jukebox/public/index.html new file mode 100644 index 0000000..55bdfb2 --- /dev/null +++ b/packages/jukebox/public/index.html @@ -0,0 +1,29 @@ + + + + + juke.sh - Spotify WebGL Jukebox + + + + + + + + + + + + + +
+ + + + diff --git a/packages/jukebox/src/App.tish b/packages/jukebox/src/App.tish new file mode 100644 index 0000000..3e38648 --- /dev/null +++ b/packages/jukebox/src/App.tish @@ -0,0 +1,64 @@ +import { useLayoutEffect } from "lattish" +import { useRoute, navigateRaw } from "./router.tish" +import { Landing } from "./pages/Landing.tish" +import { Box } from "./pages/Box.tish" +import { Dev } from "./pages/Dev.tish" +import { Callback } from "./pages/Callback.tish" +import { Logout } from "./pages/Logout.tish" + +fn SiteFooter() { + return +} + +export fn App() { + let route = useRoute() + + useLayoutEffect(() => { + fn onClick(ev) { + if (ev.defaultPrevented) { return } + if (ev.button !== undefined && ev.button !== 0) { return } + if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) { return } + let t = ev.target + while (t !== null && t !== undefined && t.tagName !== "A") { t = t.parentNode } + if (t === null || t === undefined) { return } + let href = t.getAttribute === undefined ? null : t.getAttribute("href") + if (href === null || href === undefined) { return } + if (href.length === 0 || href.charAt(0) !== "/") { return } + let target = t.getAttribute("target") + if (target !== null && target !== undefined && target !== "") { return } + ev.preventDefault() + navigateRaw(href) + } + document.addEventListener("click", onClick) + return () => document.removeEventListener("click", onClick) + }, []) + + let view = route.view + if (view === undefined || view === null) { + navigateRaw("/") + return + } + + if (view === "landing") { return
} + if (view === "box") { return } + if (view === "dev") { return } + if (view === "callback") { return } + if (view === "logout") { return
} + + navigateRaw("/") + return +} diff --git a/packages/jukebox/src/components/Jukebox.tish b/packages/jukebox/src/components/Jukebox.tish new file mode 100644 index 0000000..718415d --- /dev/null +++ b/packages/jukebox/src/components/Jukebox.tish @@ -0,0 +1,463 @@ +import { useState, useEffect, useRef } from "lattish" +import { navigateRaw } from "../router.tish" +import { + isLoggedIn, login, getStoredClientId, + loadAllTracks, getUserPlaylists, getSelectedPlaylists, saveSelectedPlaylists, + getCachedTracks, setCachedTracks, clearTracksCache, getCachedPlaylistIds, +} from "../lib/spotify.tish" +import { generateDebugTracks } from "../lib/debug-tracks.tish" +import { preloadCardFonts } from "../../../juke-cards/src/index.tish" +import { loadPrefs, savePrefs, flushPrefs } from "../lib/preferences.tish" +import { opt } from "../lib/utils.tish" +import { PlaylistPicker } from "./PlaylistPicker.tish" +import { + SettingsPanel, + clampRows, clampSpin, clampZoomTight, clampZoomFlat, + SPIN_STEP, ZOOM_FLAT_STEP, MIN_ROWS, MAX_ROWS, +} from "./SettingsPanel.tish" + +const DEFAULT_ROWS = 10 +const ZOOM_TIGHT_DEFAULT = 0.95 +const PLAYBACK_TOAST_MS = 6000 +const TAP_BURST_MS = 800 +const TAP_MOVE_MAX = 8 +const TAP_DURATION_MAX = 250 + +fn clampUnit(n) { + if (n < 0) { return 0 } + if (n > 1) { return 1 } + return n +} + +fn idsToObject(arr) { + let out = {} + if (!Array.isArray(arr)) { return out } + let i = 0 + while (i < arr.length) { + out[arr[i]] = true + i = i + 1 + } + return out +} + +fn objectToIds(obj) { + let out = [] + let keys = Object.keys(obj) + let i = 0 + while (i < keys.length) { + if (obj[keys[i]]) { out.push(keys[i]) } + i = i + 1 + } + return out +} + +export fn Jukebox({ debug, rows: initialRows }) { + if (debug === null || debug === undefined) { debug = false } + if (initialRows === null || initialRows === undefined) { initialRows = DEFAULT_ROWS } + + let containerRef = useRef(null) + let phaseState = useState("init") + let phase = phaseState[0] + let setPhase = phaseState[1] + + let initialPrefs = loadPrefs() + let rowsState = useState(clampRows(opt(initialPrefs, "rows", initialRows))) + let rows = rowsState[0] + let setRows = rowsState[1] + + let loadingState = useState("Loading…") + let loadingMsg = loadingState[0] + let setLoadingMsg = loadingState[1] + + let errorState = useState("") + let errorMsg = errorState[0] + let setErrorMsg = errorState[1] + + let playlistsState = useState([]) + let userPlaylists = playlistsState[0] + let setUserPlaylists = playlistsState[1] + + let pickedState = useState({}) + let pickedIds = pickedState[0] + let setPickedIds = pickedState[1] + + let cachedState = useState({}) + let cachedIds = cachedState[0] + let setCachedIds = cachedState[1] + + let hintsState = useState(true) + let showHints = hintsState[0] + let setShowHints = hintsState[1] + + let settingsState = useState(false) + let showSettings = settingsState[0] + let setShowSettings = settingsState[1] + + let playbackState = useState("") + let playbackMsg = playbackState[0] + let setPlaybackMsg = playbackState[1] + + let playbackMsgTimer = useRef(null) + let rowsRef = useRef(rows) + rowsRef.current = rows + let cleanupRef = useRef(null) + let tracksDataRef = useRef(null) + let mountedRef = useRef(true) + + let modeRef = useRef({ + lighting: clampUnit(opt(initialPrefs, "lighting", 0.5)), + zoom: 0, + spin: clampSpin(opt(initialPrefs, "spin", 0)), + debug: debug, + showCategories: opt(initialPrefs, "showCategories", true), + zoomTight: clampZoomTight(opt(initialPrefs, "zoomTight", ZOOM_TIGHT_DEFAULT)), + zoomFlat: clampZoomFlat(opt(initialPrefs, "zoomFlat", 0)), + audioEnabled: opt(initialPrefs, "audioEnabled", true), + shuffle: opt(initialPrefs, "shuffle", true), + }) + + fn showPlaybackError(msg) { + if (!mountedRef.current) { return } + setPlaybackMsg(msg) + if (playbackMsgTimer.current !== null) { clearTimeout(playbackMsgTimer.current) } + playbackMsgTimer.current = setTimeout(() => { + if (mountedRef.current) { setPlaybackMsg("") } + }, PLAYBACK_TOAST_MS) + } + + useEffect(() => { + return () => { + mountedRef.current = false + if (playbackMsgTimer.current !== null) { clearTimeout(playbackMsgTimer.current) } + } + }, []) + + async fn fetchPlaylistsAndPick() { + setPhase("fetching-playlists") + setLoadingMsg("Loading your playlists…") + try { + let pls = await getUserPlaylists() + if (!mountedRef.current) { return } + setUserPlaylists(pls) + setPhase("picking") + } catch (e) { + if (!mountedRef.current) { return } + let m = String(e) + if (e !== null && typeof e === "object" && "message" in e) { m = String(e.message) } + setErrorMsg(m !== "" ? m : "Failed to load playlists") + setPhase("error") + } + } + + async fn hydrateTracks(ids) { + setPhase("loading-tracks") + setLoadingMsg("Loading tracks…") + try { + let data = getCachedTracks(ids) + if (data === null) { + let fresh = await loadAllTracks(ids) + if (!mountedRef.current) { return } + if (fresh.tracks.length > 0) { + setCachedTracks(ids, fresh.tracks, fresh.nowItem) + } + data = fresh + } + if (data.tracks.length === 0) { + if (!mountedRef.current) { return } + setErrorMsg("No tracks found in the selected playlists.") + setPhase("error") + return + } + setLoadingMsg("Building jukebox…") + try { await preloadCardFonts() } catch (e) { } + if (!mountedRef.current) { return } + tracksDataRef.current = { tracks: data.tracks, nowItem: data.nowItem } + setPhase("ready") + } catch (e) { + if (!mountedRef.current) { return } + let m = String(e) + if (e !== null && typeof e === "object" && "message" in e) { m = String(e.message) } + setErrorMsg(m !== "" ? m : "Failed to load tracks") + setPhase("error") + } + } + + useEffect(() => { + if (debug) { + async fn loadDebug() { + try { await preloadCardFonts() } catch (e) { } + if (!mountedRef.current) { return } + tracksDataRef.current = { tracks: generateDebugTracks(120), nowItem: null } + setPhase("ready") + } + loadDebug() + return + } + + if (!isLoggedIn()) { + if (getStoredClientId() !== "") { + login() + } else { + navigateRaw("/") + } + return + } + + setPickedIds(idsToObject(getSelectedPlaylists())) + setCachedIds(idsToObject(getCachedPlaylistIds())) + fetchPlaylistsAndPick() + }, []) + + useEffect(() => { + if (phase !== "ready") { return () => {} } + let data = tracksDataRef.current + if (data === null || containerRef.current === null) { return () => {} } + if (window.__jukeBridge === null || window.__jukeBridge === undefined) { return () => {} } + + let m = modeRef.current + let scene = window.__jukeBridge.createJukeboxScene(containerRef.current, { + tracks: data.tracks, + nowItem: data.nowItem, + onActiveChange: (hasActive) => { + if (mountedRef.current) { setShowHints(!hasActive) } + }, + onPlaybackError: showPlaybackError, + lighting: m.lighting, + zoom: m.zoom, + spin: m.spin, + debug: debug, + showCategories: m.showCategories, + zoomTight: m.zoomTight, + zoomFlat: m.zoomFlat, + audioEnabled: m.audioEnabled, + shuffle: m.shuffle, + rows: rows, + }) + modeRef.current = scene.mode + cleanupRef.current = scene.dispose + + return () => { + if (cleanupRef.current !== null) { cleanupRef.current() } + cleanupRef.current = null + if (mountedRef.current) { setShowHints(true) } + } + }, [phase, rows]) + + useEffect(() => { + fn onKey(ev) { + let t = ev.target + if (t !== null && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) { return } + + if (ev.key === "c" || ev.key === "C") { + ev.preventDefault() + modeRef.current.showCategories = !modeRef.current.showCategories + savePrefs({ showCategories: modeRef.current.showCategories }) + } else if (ev.key === "ArrowLeft") { + ev.preventDefault() + modeRef.current.spin = clampSpin(modeRef.current.spin + SPIN_STEP) + savePrefs({ spin: modeRef.current.spin }) + } else if (ev.key === "ArrowRight") { + ev.preventDefault() + modeRef.current.spin = clampSpin(modeRef.current.spin - SPIN_STEP) + savePrefs({ spin: modeRef.current.spin }) + } else if (ev.key === "ArrowUp") { + ev.preventDefault() + let n = Math.min(MAX_ROWS, rowsRef.current + 1) + setRows(n) + savePrefs({ rows: n }) + } else if (ev.key === "ArrowDown") { + ev.preventDefault() + let n = Math.max(MIN_ROWS, rowsRef.current - 1) + setRows(n) + savePrefs({ rows: n }) + } else if (ev.key === "z" || ev.key === "Z") { + ev.preventDefault() + modeRef.current.zoomTight = clampZoomTight(modeRef.current.zoomTight - 0.05) + savePrefs({ zoomTight: modeRef.current.zoomTight }) + } else if (ev.key === "x" || ev.key === "X") { + ev.preventDefault() + modeRef.current.zoomTight = clampZoomTight(modeRef.current.zoomTight + 0.05) + savePrefs({ zoomTight: modeRef.current.zoomTight }) + } else if (ev.key === "v" || ev.key === "V") { + ev.preventDefault() + let next = modeRef.current.zoomFlat + ZOOM_FLAT_STEP + if (next > 1.0001) { next = 0 } + modeRef.current.zoomFlat = clampZoomFlat(next) + savePrefs({ zoomFlat: modeRef.current.zoomFlat }) + } else if (ev.key === "m" || ev.key === "M") { + ev.preventDefault() + let next = !modeRef.current.audioEnabled + modeRef.current.audioEnabled = next + savePrefs({ audioEnabled: next }) + if (modeRef.current.setAudioEnabled !== undefined) { + modeRef.current.setAudioEnabled(next) + } + } else if (ev.key === "s" || ev.key === "S") { + ev.preventDefault() + let next = !modeRef.current.shuffle + modeRef.current.shuffle = next + savePrefs({ shuffle: next }) + if (modeRef.current.setShuffle !== undefined) { + modeRef.current.setShuffle(next) + } + } + } + window.addEventListener("keydown", onKey) + return () => window.removeEventListener("keydown", onKey) + }, []) + + let tapTimes = useRef([]) + let pdAt = 0 + let pdX = 0 + let pdY = 0 + + useEffect(() => { + fn onDown(ev) { + pdAt = Date.now() + pdX = ev.clientX + pdY = ev.clientY + } + fn onUp(ev) { + savePrefs({ lighting: modeRef.current.lighting }) + let dur = Date.now() - pdAt + let dx = ev.clientX - pdX + let dy = ev.clientY - pdY + let dist = Math.sqrt(dx * dx + dy * dy) + if (dur > TAP_DURATION_MAX || dist > TAP_MOVE_MAX) { return } + let now = Date.now() + let kept = [] + let i = 0 + while (i < tapTimes.current.length) { + if (now - tapTimes.current[i] < TAP_BURST_MS) { kept.push(tapTimes.current[i]) } + i = i + 1 + } + kept.push(now) + if (kept.length > 5) { + kept = kept.slice(kept.length - 5) + } + tapTimes.current = kept + if (tapTimes.current.length >= 5) { + tapTimes.current = [] + setShowSettings(true) + } + } + fn onHide() { + savePrefs({ + lighting: modeRef.current.lighting, + spin: modeRef.current.spin, + showCategories: modeRef.current.showCategories, + zoomTight: modeRef.current.zoomTight, + audioEnabled: modeRef.current.audioEnabled, + shuffle: modeRef.current.shuffle, + }) + flushPrefs() + } + window.addEventListener("pointerdown", onDown) + window.addEventListener("pointerup", onUp) + window.addEventListener("pointercancel", onUp) + document.addEventListener("visibilitychange", onHide) + return () => { + window.removeEventListener("pointerdown", onDown) + window.removeEventListener("pointerup", onUp) + window.removeEventListener("pointercancel", onUp) + document.removeEventListener("visibilitychange", onHide) + } + }, []) + + fn togglePicked(id) { + let next = {} + let keys = Object.keys(pickedIds) + let i = 0 + while (i < keys.length) { + if (keys[i] !== id) { next[keys[i]] = pickedIds[keys[i]] } + i = i + 1 + } + if (!(id in pickedIds) || !pickedIds[id]) { next[id] = true } + setPickedIds(next) + } + + fn confirmSelection() { + let ids = objectToIds(pickedIds) + if (ids.length === 0) { return } + saveSelectedPlaylists(ids) + hydrateTracks(ids) + } + + fn flushCache() { + clearTracksCache() + setCachedIds({}) + } + + fn onRetry() { + setErrorMsg("") + fetchPlaylistsAndPick() + } + + fn onDismissPlayback() { setPlaybackMsg("") } + + fn onCloseSettings() { setShowSettings(false) } + + fn onChangeRows(n) { + setRows(n) + savePrefs({ rows: n }) + } + + let containerStyle = { visibility: phase === "ready" ? "visible" : "hidden", outline: "none" } + + return
+
+ + {phase === "ready" + ?
+ {showHints + ?
+
TAP TO PLAY • HOLD TO QUEUE
+
SPIN LEFT/RIGHT • LIGHTING UP/DOWN • PINCH TO ZOOM
+
5-TAP FOR SETTINGS
+
+ : null} + {playbackMsg !== "" + ?
{playbackMsg}
+ : null} +
+ : null} + + {phase === "init" + ?
+ : null} + + {phase === "fetching-playlists" || phase === "loading-tracks" + ?
+
+
{loadingMsg}
+
+ : null} + + {phase === "error" + ?
+
{errorMsg}
+ +
+ : null} + + {phase === "picking" + ? + : null} + + {showSettings + ? + : null} +
+} diff --git a/packages/jukebox/src/components/PlaylistPicker.tish b/packages/jukebox/src/components/PlaylistPicker.tish new file mode 100644 index 0000000..d3f92dd --- /dev/null +++ b/packages/jukebox/src/components/PlaylistPicker.tish @@ -0,0 +1,60 @@ +export fn PlaylistPicker({ userPlaylists, pickedIds, cachedIds, onToggle, onConfirm, onFlushCache }) { + let pickedCount = 0 + let keys = Object.keys(pickedIds) + let ki = 0 + while (ki < keys.length) { + if (pickedIds[keys[ki]]) { pickedCount = pickedCount + 1 } + ki = ki + 1 + } + + let rows = [] + let i = 0 + while (i < userPlaylists.length) { + let pl = userPlaylists[i] + let isPicked = pl.id in pickedIds && pickedIds[pl.id] + let isCached = pl.id in cachedIds && cachedIds[pl.id] + let pickCls = "sp-btn sp-btn-icon" + if (isPicked) { pickCls = pickCls + " picked" } + let plId = pl.id + + rows.push(
  • + {pl.image !== null && pl.image !== undefined && pl.image !== "" + ? + :
    } +
    +
    {pl.name}
    +
    {pl.id + " · " + String(pl.tracks) + " tracks"}
    +
    + {isCached + ? + : null} + +
  • ) + i = i + 1 + } + + let loadLabel = "LOAD PLAYLISTS" + if (pickedCount > 0) { + loadLabel = "LOAD " + String(pickedCount) + " PLAYLIST" + if (pickedCount !== 1) { loadLabel = loadLabel + "S" } + } + + return
    + +
    +} diff --git a/packages/jukebox/src/components/SettingsPanel.tish b/packages/jukebox/src/components/SettingsPanel.tish new file mode 100644 index 0000000..746b97f --- /dev/null +++ b/packages/jukebox/src/components/SettingsPanel.tish @@ -0,0 +1,182 @@ +import { useState, useEffect } from "lattish" +import { savePrefs } from "../lib/preferences.tish" + +export const SPIN_MAX = 10 +export const SPIN_STEP = 0.25 +export const MIN_ROWS = 4 +export const MAX_ROWS = 20 +export const ZOOM_TIGHT_MIN = 0.4 +export const ZOOM_TIGHT_MAX = 1.2 +export const ZOOM_FLAT_STEP = 0.2 + +export fn clampRows(n) { + let v = n | 0 + if (v < MIN_ROWS) { return MIN_ROWS } + if (v > MAX_ROWS) { return MAX_ROWS } + return v +} + +export fn clampSpin(n) { + if (n < -SPIN_MAX) { return -SPIN_MAX } + if (n > SPIN_MAX) { return SPIN_MAX } + return n +} + +export fn clampZoomTight(n) { + if (n < ZOOM_TIGHT_MIN) { return ZOOM_TIGHT_MIN } + if (n > ZOOM_TIGHT_MAX) { return ZOOM_TIGHT_MAX } + return n +} + +export fn clampZoomFlat(n) { + if (n < 0) { return 0 } + if (n > 1) { return 1 } + return n +} + +export fn SettingsPanel({ modeRef, rows, onChangeRows, onClose }) { + let spinState = useState(modeRef.current.spin) + let spin = spinState[0] + let setSpin = spinState[1] + let showCatState = useState(modeRef.current.showCategories) + let showCategories = showCatState[0] + let setShowCategories = showCatState[1] + let zoomTightState = useState(modeRef.current.zoomTight) + let zoomTight = zoomTightState[0] + let setZoomTight = zoomTightState[1] + let zoomFlatState = useState(modeRef.current.zoomFlat) + let zoomFlat = zoomFlatState[0] + let setZoomFlat = zoomFlatState[1] + let audioState = useState(modeRef.current.audioEnabled) + let audioEnabled = audioState[0] + let setAudioEnabled = audioState[1] + let shuffleState = useState(modeRef.current.shuffle) + let shuffle = shuffleState[0] + let setShuffleLocal = shuffleState[1] + + useEffect(() => { + fn onKey(ev) { + if (ev.key === "Escape") { + ev.preventDefault() + onClose() + } + } + window.addEventListener("keydown", onKey) + return () => window.removeEventListener("keydown", onKey) + }, []) + + fn updateSpin(v) { modeRef.current.spin = v; savePrefs({ spin: v }) } + fn updateShowCategories(v) { modeRef.current.showCategories = v; savePrefs({ showCategories: v }) } + fn updateZoomTight(v) { modeRef.current.zoomTight = v; savePrefs({ zoomTight: v }) } + fn updateZoomFlat(v) { modeRef.current.zoomFlat = v; savePrefs({ zoomFlat: v }) } + fn updateAudioEnabled(v) { modeRef.current.audioEnabled = v; savePrefs({ audioEnabled: v }) } + fn updateShuffle(v) { modeRef.current.shuffle = v; savePrefs({ shuffle: v }) } + + fn onBackdropClick() { onClose() } + fn stopProp(ev) { ev.stopPropagation() } + + fn onSpinInput(ev) { + let v = parseFloat(ev.target.value) + setSpin(v) + updateSpin(v) + } + fn onSpinZero() { + setSpin(0) + updateSpin(0) + } + fn onRowsInput(ev) { + onChangeRows(clampRows(parseInt(ev.target.value))) + } + fn onZoomTightInput(ev) { + let v = parseFloat(ev.target.value) + setZoomTight(v) + updateZoomTight(v) + } + fn onZoomFlatInput(ev) { + let v = parseFloat(ev.target.value) + setZoomFlat(v) + updateZoomFlat(v) + } + fn onShowCatChange(ev) { + let v = ev.target.checked + setShowCategories(v) + updateShowCategories(v) + } + fn onAudioChange(ev) { + let v = ev.target.checked + setAudioEnabled(v) + updateAudioEnabled(v) + if (modeRef.current.setAudioEnabled !== undefined) { + modeRef.current.setAudioEnabled(v) + } + } + fn onShuffleChange(ev) { + let v = ev.target.checked + setShuffleLocal(v) + updateShuffle(v) + if (modeRef.current.setShuffle !== undefined) { + modeRef.current.setShuffle(v) + } + } + + return
    + +
    +} diff --git a/packages/jukebox/src/lib/debug-tracks.tish b/packages/jukebox/src/lib/debug-tracks.tish new file mode 100644 index 0000000..fd53a52 --- /dev/null +++ b/packages/jukebox/src/lib/debug-tracks.tish @@ -0,0 +1,51 @@ +// Procedural fake tracks for visual debugging — adapted from the spin7.html +// prototype. Pure data + a deterministic generator; no network, no Spotify. +// Used by the /dev route in both dev and production builds. + +const artists = [ + "Elvis Presley", "Chuck Berry", "Little Richard", "Fats Domino", "Buddy Holly", + "Jerry Lee Lewis", "Gene Vincent", "Roy Orbison", "Everly Brothers", "Ray Charles", + "Sam Cooke", "Jackie Wilson", "The Drifters", "The Coasters", "Dion", "Patsy Cline", + "Brenda Lee", "Bill Haley", "Carl Perkins", "Ricky Nelson", "Wanda Jackson", + "Ritchie Valens", "Frank Sinatra", "Dean Martin", "Paul Anka", "Johnny Cash", + "The Platters", "Etta James", +] +const adjectives = [ + "Blue", "Crazy", "Sweet", "Rockin'", "Wild", "Lonely", "Shake", "Rollin'", "Magic", + "Jive", "Good", "Bad", "Fast", "Slow", "Heartbreak", "Midnight", "Silver", "Golden", + "Happy", "Sad", "Twistin'", "Atomic", "Neon", "Plastic", "Radio", +] +const nouns = [ + "Shoes", "Love", "Baby", "Night", "Heart", "Star", "Moon", "Cat", "Dog", "Train", + "Tears", "Dreams", "Doll", "Angel", "Boogie", "Twist", "Beat", "Town", "River", "Road", + "Dance", "Rocket", "Juke", "Diner", "Flame", +] +const albums = [ + "Greatest Hits", "The Best Of", "Live at the Hop", "Sock Hop", "On The Radio", + "Diner Days", "Drive-In Nights", "The Original Sessions", "Sun Records", + "45s & B-Sides", "Highway Songs", "Last Call", +] + +export fn generateDebugTracks(count) { + if (count === null || count === undefined) { count = 120 } + let out = [] + let i = 0 + while (i < count) { + let artist = artists[i % artists.length] + // Jukebox cards use paired A/B-side titles (see spin20.html mock generator). + let titleA = adjectives[(i * 3) % adjectives.length] + " " + nouns[(i * 7) % nouns.length] + let titleB = adjectives[(i * 5 + 3) % adjectives.length] + " " + nouns[(i * 11 + 2) % nouns.length] + let album = albums[(i * 5) % albums.length] + out.push({ + uri: "spotify:track:debug-" + i.toString(36), + name: titleA + " / " + titleB, + titleA: titleA, + titleB: titleB, + artist: artist, + album: album, + source: "playlist", + }) + i = i + 1 + } + return out +} diff --git a/packages/jukebox/src/lib/preferences.tish b/packages/jukebox/src/lib/preferences.tish new file mode 100644 index 0000000..5528e9c --- /dev/null +++ b/packages/jukebox/src/lib/preferences.tish @@ -0,0 +1,67 @@ +// View preferences persisted to localStorage: +// - spin ←/→ arrow-key auto-rotation rate (rad/sec) +// - rows ↑/↓ arrow-key row count (4..20) +// - lighting vertical-drag blend (0 diner ↔ 1 rink) +// - showCategories 'C' key toggle for bottom-rim labels +// +// Saves are debounced so continuous inputs (lighting drag) don't hammer +// localStorage on every pointermove sample. + +const KEY = "jukebox_prefs" +const SAVE_DEBOUNCE_MS = 250 + +fn mergeObj(base, patch) { + let out = {} + let keys = Object.keys(base) + let i = 0 + while (i < keys.length) { + out[keys[i]] = base[keys[i]] + i = i + 1 + } + keys = Object.keys(patch) + i = 0 + while (i < keys.length) { + out[keys[i]] = patch[keys[i]] + i = i + 1 + } + return out +} + +export fn loadPrefs() { + if (typeof window === "undefined") { return {} } + try { + let raw = localStorage.getItem(KEY) + if (raw === null) { return {} } + let p = JSON.parse(raw) + if (p === null || typeof p !== "object") { return {} } + return p + } catch (e) { + return {} + } +} + +let pending = {} +let saveTimer = null + +fn flush() { + saveTimer = null + try { + let existing = loadPrefs() + localStorage.setItem(KEY, JSON.stringify(mergeObj(existing, pending))) + } catch (e) { } + pending = {} +} + +export fn savePrefs(patch) { + pending = mergeObj(pending, patch) + if (saveTimer !== null) { clearTimeout(saveTimer) } + saveTimer = setTimeout(flush, SAVE_DEBOUNCE_MS) +} + +// Force a synchronous flush — call from unload/visibility-hidden handlers so +// in-flight debounced writes don't get lost. +export fn flushPrefs() { + if (saveTimer !== null) { clearTimeout(saveTimer) } + let keys = Object.keys(pending) + if (keys.length > 0) { flush() } +} diff --git a/packages/jukebox/src/lib/spotify.tish b/packages/jukebox/src/lib/spotify.tish new file mode 100644 index 0000000..d65d6b0 --- /dev/null +++ b/packages/jukebox/src/lib/spotify.tish @@ -0,0 +1,564 @@ +// Browser-only Spotify PKCE client. +// Tokens live in localStorage; no server proxy. + +// Client ID resolution: user-supplied (localStorage) takes precedence over the +// boot config default. Lets people drop in their own Spotify app without +// redeploying. +import { opt } from "./utils.tish" + +const CLIENT_ID_KEY = "sp_client_id" + +fn resolveClientId() { + if (typeof window !== "undefined") { + let stored = localStorage.getItem(CLIENT_ID_KEY) + if (stored !== null && stored !== "") { return stored } + let cfg = window.__JUKE_CONFIG + if (cfg !== null && cfg !== undefined && typeof cfg === "object") { + let baked = opt(cfg, "spotifyClientId", "") + if (baked !== "") { return String(baked) } + } + } + return "" +} + +export fn getStoredClientId() { + if (typeof window === "undefined") { return "" } + let stored = localStorage.getItem(CLIENT_ID_KEY) + if (stored === null) { return "" } + return stored +} + +export fn getBootClientId() { + if (typeof window === "undefined") { return "" } + let cfg = window.__JUKE_CONFIG + if (cfg !== null && cfg !== undefined && typeof cfg === "object") { + let baked = opt(cfg, "spotifyClientId", "") + if (baked !== "") { return String(baked) } + } + return "" +} + +export fn setStoredClientId(id) { + let v = "" + if (id !== null && id !== undefined) { v = String(id).trim() } + if (v !== "") { + localStorage.setItem(CLIENT_ID_KEY, v) + } else { + localStorage.removeItem(CLIENT_ID_KEY) + } +} + +const SCOPES = [ + "user-read-playback-state", + "user-modify-playback-state", + "user-read-currently-playing", + "playlist-read-private", + "playlist-read-collaborative", +].join(" ") + +const LS_ACCESS = "sp_access" +const LS_REFRESH = "sp_refresh" +const LS_EXPIRES = "sp_expires" +const SS_VERIFIER = "sp_verifier" +const SS_STATE = "sp_state" + +fn redirectUri() { + return window.location.origin + "/callback" +} + +fn b64url(bytes) { + let str = "" + let i = 0 + while (i < bytes.length) { + str = str + String.fromCharCode(bytes[i]) + i = i + 1 + } + let s = btoa(str) + s = s.split("+").join("-") + s = s.split("/").join("_") + while (s.length > 0 && s.charAt(s.length - 1) === "=") { + s = s.substring(0, s.length - 1) + } + return s +} + +fn randomVerifier(len) { + if (len === null || len === undefined) { len = 64 } + let arr = new Uint8Array(len) + crypto.getRandomValues(arr) + return b64url(arr) +} + +async fn challenge(verifier) { + let hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)) + return b64url(new Uint8Array(hash)) +} + +export async fn login() { + // PKCE: verifier + code_challenge protects the code exchange. + // state: random nonce stored in sessionStorage, echoed back by Spotify on + // the redirect; verified in /callback. Closes the CSRF window that PKCE + // alone doesn't cover (the flow *initiation*). + let verifier = randomVerifier(null) + let state = randomVerifier(16) + sessionStorage.setItem(SS_VERIFIER, verifier) + sessionStorage.setItem(SS_STATE, state) + let code_challenge = await challenge(verifier) + let params = new URLSearchParams({ + client_id: resolveClientId(), + response_type: "code", + redirect_uri: redirectUri(), + code_challenge_method: "S256", + code_challenge: code_challenge, + state: state, + scope: SCOPES, + }) + window.location.href = "https://accounts.spotify.com/authorize?" + params.toString() +} + +export fn consumeState() { + let s = sessionStorage.getItem(SS_STATE) + sessionStorage.removeItem(SS_STATE) + return s +} + +// Clears the Spotify session (tokens) and any session-scoped data. Keeps the +// stored Client ID + selected playlists so the user doesn't have to redo +// onboarding when they log back in. +export fn logout() { + localStorage.removeItem(LS_ACCESS) + localStorage.removeItem(LS_REFRESH) + localStorage.removeItem(LS_EXPIRES) + clearTracksCache() +} + +export async fn exchangeCode(code) { + let verifier = sessionStorage.getItem(SS_VERIFIER) + if (verifier === null) { throw new Error("missing PKCE verifier") } + let res = await fetch("https://accounts.spotify.com/api/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: resolveClientId(), + grant_type: "authorization_code", + code: code, + redirect_uri: redirectUri(), + code_verifier: verifier, + }), + }) + if (!res.ok) { throw new Error("token exchange failed: " + await res.text()) } + let data = await res.json() + storeTokens(data) + sessionStorage.removeItem(SS_VERIFIER) +} + +fn storeTokens(data) { + localStorage.setItem(LS_ACCESS, data.access_token) + if (data.refresh_token !== null && data.refresh_token !== undefined) { + localStorage.setItem(LS_REFRESH, data.refresh_token) + } + localStorage.setItem(LS_EXPIRES, String(Date.now() + (data.expires_in - 30) * 1000)) +} + +async fn refresh() { + let refresh_token = localStorage.getItem(LS_REFRESH) + if (refresh_token === null) { return null } + let res = await fetch("https://accounts.spotify.com/api/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: resolveClientId(), + grant_type: "refresh_token", + refresh_token: refresh_token, + }), + }) + if (!res.ok) { + logout() + return null + } + let data = await res.json() + storeTokens(data) + return data.access_token +} + +async fn getToken() { + let access = localStorage.getItem(LS_ACCESS) + let expiresRaw = localStorage.getItem(LS_EXPIRES) + let expires = 0 + if (expiresRaw !== null) { expires = Number(expiresRaw) } + if (access !== null && Date.now() < expires) { return access } + return refresh() +} + +export fn isLoggedIn() { + let r = localStorage.getItem(LS_REFRESH) + return r !== null && r !== "" +} + +fn copyHeaders(src) { + let out = {} + if (src === null || typeof src !== "object") { return out } + let keys = Object.keys(src) + let i = 0 + while (i < keys.length) { + out[keys[i]] = src[keys[i]] + i = i + 1 + } + return out +} + +export async fn spotifyApi(path, init, retryCount) { + if (init === null || init === undefined) { init = {} } + if (retryCount === null || retryCount === undefined) { retryCount = 0 } + let token = await getToken() + if (token === null) { throw new Error("not authenticated") } + let headers = copyHeaders(opt(init, "headers", {})) + headers["Authorization"] = "Bearer " + token + headers["Content-Type"] = "application/json" + let fetchOpts = { headers: headers } + if ("method" in init) { fetchOpts.method = init.method } + if ("body" in init) { fetchOpts.body = init.body } + let res = await fetch("https://api.spotify.com/v1" + path, fetchOpts) + if (res.status === 204) { return null } + if (res.status === 401 && retryCount === 0) { + let t = await refresh() + if (t === null) { throw new Error("not authenticated") } + return spotifyApi(path, init, retryCount + 1) + } + let text = await res.text() + if (!res.ok) { throw new Error(String(res.status) + ": " + text) } + try { + if (text !== "") { return JSON.parse(text) } + return null + } catch (e) { + return text + } +} + +fn artistNames(t) { + let artists = opt(t, "artists", []) + let names = [] + if (Array.isArray(artists)) { + let i = 0 + while (i < artists.length) { + let a = artists[i] + if (a !== null && typeof a === "object" && "name" in a) { + names.push(a.name) + } + i = i + 1 + } + } + return names.join(", ") +} + +fn toTrack(t, source, contextUri) { + if (t === null || typeof t !== "object") { return null } + let uri = opt(t, "uri", null) + if (uri === null) { return null } + let albumObj = opt(t, "album", null) + let albumName = "" + if (albumObj !== null && typeof albumObj === "object" && "name" in albumObj) { + albumName = opt(albumObj, "name", "") + } + let track = { + uri: uri, + name: opt(t, "name", ""), + artist: artistNames(t), + album: albumName, + source: source, + } + if (contextUri !== null && contextUri !== undefined) { + track.contextUri = contextUri + } + return track +} + +export async fn loadAllTracks(playlistIds) { + let out = [] + let seen = {} + fn add(t) { + if (t === null) { return } + if (t.uri in seen) { return } + seen[t.uri] = true + out.push(t) + } + + let nowItem = null + try { + let now = await spotifyApi("/me/player/currently-playing", null, null) + let item = opt(now, "item", null) + if (item !== null) { + let ctx = opt(now, "context", null) + let ctxUri = opt(ctx, "uri", null) + add(toTrack(item, "now", ctxUri)) + nowItem = item + } else { + console.info("[jukebox] now-playing: nothing active") + } + } catch (e) { + console.error("[jukebox] now-playing failed:", e) + } + + try { + let q = await spotifyApi("/me/player/queue", null, null) + let queue = opt(q, "queue", null) + if (Array.isArray(queue)) { + console.info("[jukebox] queue: " + String(queue.length) + " tracks") + let qi = 0 + while (qi < queue.length) { + add(toTrack(queue[qi], "queue", null)) + qi = qi + 1 + } + } else { + console.info("[jukebox] queue: empty (no active device?)") + } + } catch (e) { + console.error("[jukebox] queue failed:", e) + } + + let pi = 0 + while (pi < playlistIds.length) { + let id = playlistIds[pi] + try { + let p = await spotifyApi("/playlists/" + id, null, null) + let container = opt(p, "items", null) + if (container === null) { container = opt(p, "tracks", null) } + let items = opt(container, "items", []) + if (!Array.isArray(items)) { items = [] } + let next = opt(container, "next", null) + while (next !== null) { + let pagePath = String(next).replace("https://api.spotify.com/v1", "") + let page = await spotifyApi(pagePath, null, null) + let pageItems = opt(page, "items", []) + if (Array.isArray(pageItems)) { + items = items.concat(pageItems) + } + next = opt(page, "next", null) + } + let contextUri = "spotify:playlist:" + id + let ii = 0 + while (ii < items.length) { + let it = items[ii] + let raw = opt(it, "item", null) + if (raw === null) { raw = opt(it, "track", null) } + add(toTrack(raw, "playlist", contextUri)) + ii = ii + 1 + } + console.info("[jukebox] playlist " + id + ": " + String(items.length) + " tracks") + } catch (e) { + console.error("[jukebox] playlist " + id + " failed:", e) + } + pi = pi + 1 + } + console.info("[jukebox] total tracks: " + String(out.length)) + + return { tracks: out, nowItem: nowItem } +} + +export async fn getUserPlaylists() { + let out = [] + let url = "/me/playlists?limit=50" + while (url !== null) { + let p = await spotifyApi(url, null, null) + let items = opt(p, "items", null) + if (Array.isArray(items)) { + let i = 0 + while (i < items.length) { + let it = items[i] + let tracksObj = opt(it, "tracks", null) + let itemsObj = opt(it, "items", null) + let total = 0 + if (tracksObj !== null) { total = opt(tracksObj, "total", 0) } + if (total === 0 && itemsObj !== null) { total = opt(itemsObj, "total", 0) } + let images = opt(it, "images", null) + let image = null + if (Array.isArray(images) && images.length > 0) { + let img0 = images[0] + if (img0 !== null && typeof img0 === "object") { + image = opt(img0, "url", null) + } + } + out.push({ + id: it.id, + name: it.name, + image: image, + tracks: total, + }) + i = i + 1 + } + } + let nextFull = opt(p, "next", null) + if (nextFull !== null) { + url = String(nextFull).replace("https://api.spotify.com/v1", "") + } else { + url = null + } + } + return out +} + +// --- localStorage cache for selected playlists + their fetched tracks --- +const SEL_KEY = "jukebox_selected_playlists" +// Bumped to v2 when JukeTrack gained `contextUri` (for playlist-context play). +const TRACKS_KEY = "jukebox_tracks_cache_v2" +const TRACKS_TTL_MS = 60 * 60 * 1000 + +fn cacheKey(ids) { + let copy = [] + let i = 0 + while (i < ids.length) { + copy.push(ids[i]) + i = i + 1 + } + copy.sort() + return copy.join(",") +} + +export fn getSelectedPlaylists() { + try { + let raw = localStorage.getItem(SEL_KEY) + if (raw === null) { return [] } + let parsed = JSON.parse(raw) + if (Array.isArray(parsed)) { return parsed } + return [] + } catch (e) { + return [] + } +} + +export fn saveSelectedPlaylists(ids) { + localStorage.setItem(SEL_KEY, JSON.stringify(ids)) +} + +export fn getCachedTracks(ids) { + try { + let raw = localStorage.getItem(TRACKS_KEY) + if (raw === null) { return null } + let obj = JSON.parse(raw) + if (obj.idsKey !== cacheKey(ids)) { return null } + if (Date.now() - obj.timestamp > TRACKS_TTL_MS) { return null } + let tracks = opt(obj, "tracks", []) + if (!Array.isArray(tracks)) { tracks = [] } + let ti = 0 + while (ti < tracks.length) { + let t = tracks[ti] + if (t !== null && typeof t === "object" && t.source === "playlist") { + let cu = opt(t, "contextUri", null) + if (cu === null) { return null } + } + ti = ti + 1 + } + return { tracks: tracks, nowItem: obj.nowItem } + } catch (e) { + return null + } +} + +export fn setCachedTracks(ids, tracks, nowItem) { + try { + localStorage.setItem(TRACKS_KEY, JSON.stringify({ + idsKey: cacheKey(ids), + timestamp: Date.now(), + tracks: tracks, + nowItem: nowItem, + })) + } catch (e) { } +} + +export fn clearTracksCache() { + localStorage.removeItem(TRACKS_KEY) +} + +export fn getCachedPlaylistIds() { + try { + let raw = localStorage.getItem(TRACKS_KEY) + if (raw === null) { return [] } + let obj = JSON.parse(raw) + if (Date.now() - obj.timestamp > TRACKS_TTL_MS) { return [] } + let key = opt(obj, "idsKey", "") + let parts = String(key).split(",") + let out = [] + let i = 0 + while (i < parts.length) { + if (parts[i] !== "") { out.push(parts[i]) } + i = i + 1 + } + return out + } catch (e) { + return [] + } +} + +export async fn setShuffle(state) { + return spotifyApi("/me/player/shuffle?state=" + String(state), { method: "PUT" }, null) +} + +export async fn addToQueue(uri) { + return spotifyApi("/me/player/queue?uri=" + encodeURIComponent(uri), { method: "POST" }, null) +} + +export async fn getDevices() { + return spotifyApi("/me/player/devices", null, null) +} + +export async fn transferPlayback(deviceId, playFlag) { + if (playFlag === null || playFlag === undefined) { playFlag = false } + return spotifyApi("/me/player", { + method: "PUT", + body: JSON.stringify({ device_ids: [deviceId], play: playFlag }), + }, null) +} + +async fn sendPlay(body) { + let payload = null + if (body !== null) { payload = JSON.stringify(body) } + return spotifyApi("/me/player/play", { + method: "PUT", + body: payload, + }, null) +} + +export async fn play(uri, contextUri) { + let body = null + if (uri !== null && uri !== undefined && contextUri !== null && contextUri !== undefined) { + body = { context_uri: contextUri, offset: { uri: uri } } + } else if (uri !== null && uri !== undefined) { + if (String(uri).indexOf("spotify:track:") === 0) { + body = { uris: [uri] } + } else { + body = { context_uri: uri } + } + } + try { + return await sendPlay(body) + } catch (err) { + let msg = "" + if (err !== null && typeof err === "object" && "message" in err) { + msg = String(err.message) + } else { + msg = String(err) + } + if (msg.indexOf("404") < 0 && msg.indexOf("NO_ACTIVE_DEVICE") < 0) { throw err } + let list = null + try { + list = await getDevices() + } catch (e) { + list = null + } + let device = null + if (list !== null && typeof list === "object" && "devices" in list) { + let devs = list.devices + if (Array.isArray(devs) && devs.length > 0) { device = devs[0] } + } + if (device === null) { + throw new Error("No Spotify devices found. Open Spotify on a phone, desktop, or web player and try again.") + } + await transferPlayback(device.id, false) + await new Promise((r) => setTimeout(r, 350)) + return sendPlay(body) + } +} + +export fn nowPlaying() { + return spotifyApi("/me/player/currently-playing", null, null) +} diff --git a/packages/jukebox/src/lib/utils.tish b/packages/jukebox/src/lib/utils.tish new file mode 100644 index 0000000..53661a8 --- /dev/null +++ b/packages/jukebox/src/lib/utils.tish @@ -0,0 +1,8 @@ +export fn opt(o, key, fallback) { + if (o === null || typeof o !== "object") { return fallback } + if (key in o) { + let v = o[key] + if (v !== null && v !== undefined) { return v } + } + return fallback +} diff --git a/packages/jukebox/src/main.tish b/packages/jukebox/src/main.tish new file mode 100644 index 0000000..d916a77 --- /dev/null +++ b/packages/jukebox/src/main.tish @@ -0,0 +1,45 @@ +import { createRoot } from "lattish" +import * as spotify from "./lib/spotify.tish" +import { App } from "./App.tish" + +window.__jukeSpotify = { + play: spotify.play, + nowPlaying: spotify.nowPlaying, + setShuffle: spotify.setShuffle, + addToQueue: spotify.addToQueue, + login: spotify.login, + logout: spotify.logout, + exchangeCode: spotify.exchangeCode, + consumeState: spotify.consumeState, + isLoggedIn: spotify.isLoggedIn, + getStoredClientId: spotify.getStoredClientId, + setStoredClientId: spotify.setStoredClientId, + spotify: spotify.spotifyApi, + loadAllTracks: spotify.loadAllTracks, + getUserPlaylists: spotify.getUserPlaylists, + getSelectedPlaylists: spotify.getSelectedPlaylists, + saveSelectedPlaylists: spotify.saveSelectedPlaylists, + getCachedTracks: spotify.getCachedTracks, + setCachedTracks: spotify.setCachedTracks, + clearTracksCache: spotify.clearTracksCache, + getCachedPlaylistIds: spotify.getCachedPlaylistIds, +} + +let clientId = "" +if (typeof window !== "undefined") { + let cfg = window.__JUKE_CONFIG + if (cfg !== null && typeof cfg === "object" && "spotifyClientId" in cfg) { + let baked = cfg.spotifyClientId + if (baked !== null && baked !== "") { clientId = String(baked) } + } + if (clientId === "" && typeof document !== "undefined") { + let meta = document.querySelector('meta[name="spotify-client-id"]') + if (meta !== null && meta.getAttribute !== null) { + let v = meta.getAttribute("content") + if (v !== null && v !== "") { clientId = v } + } + } + window.__JUKE_CONFIG = { spotifyClientId: clientId } +} + +createRoot(document.getElementById("root")).render(App) diff --git a/packages/jukebox/src/pages/Box.tish b/packages/jukebox/src/pages/Box.tish new file mode 100644 index 0000000..f2bcf05 --- /dev/null +++ b/packages/jukebox/src/pages/Box.tish @@ -0,0 +1,5 @@ +import { Jukebox } from "../components/Jukebox.tish" + +export fn Box() { + return +} diff --git a/packages/jukebox/src/pages/Callback.tish b/packages/jukebox/src/pages/Callback.tish new file mode 100644 index 0000000..0ff7587 --- /dev/null +++ b/packages/jukebox/src/pages/Callback.tish @@ -0,0 +1,43 @@ +import { useState, useEffect } from "lattish" +import { exchangeCode, consumeState } from "../lib/spotify.tish" + +export fn Callback() { + let msgState = useState("Completing login…") + let msg = msgState[0] + let setMsg = msgState[1] + + useEffect(() => { + async fn run() { + let url = new URL(window.location.href) + let code = url.searchParams.get("code") + let state = url.searchParams.get("state") + let err = url.searchParams.get("error") + if (err !== null) { + setMsg("Auth error: " + err) + return + } + if (code === null) { + setMsg("Missing code") + return + } + let expected = consumeState() + if (expected === null || expected !== state) { + setMsg("State mismatch — login may have been tampered with. Please retry.") + return + } + try { + await exchangeCode(code) + window.location.replace("/box") + } catch (e) { + let m = String(e) + if (e !== null && typeof e === "object" && "message" in e) { + m = String(e.message) + } + setMsg(m) + } + } + run() + }, []) + + return

    {msg}

    +} diff --git a/packages/jukebox/src/pages/Dev.tish b/packages/jukebox/src/pages/Dev.tish new file mode 100644 index 0000000..f7374f8 --- /dev/null +++ b/packages/jukebox/src/pages/Dev.tish @@ -0,0 +1,5 @@ +import { Jukebox } from "../components/Jukebox.tish" + +export fn Dev() { + return +} diff --git a/packages/jukebox/src/pages/Landing.tish b/packages/jukebox/src/pages/Landing.tish new file mode 100644 index 0000000..89fa6e0 --- /dev/null +++ b/packages/jukebox/src/pages/Landing.tish @@ -0,0 +1,76 @@ +import { useState, useEffect } from "lattish" +import { isLoggedIn, login, getStoredClientId, getBootClientId, setStoredClientId } from "../lib/spotify.tish" + +export fn Landing() { + let clientState = useState("") + let clientId = clientState[0] + let setClientId = clientState[1] + let loggedState = useState(false) + let loggedIn = loggedState[0] + let setLoggedIn = loggedState[1] + + useEffect(() => { + let onLocalhost = false + if (typeof window !== "undefined") { + let h = window.location.hostname + onLocalhost = h === "localhost" || h === "127.0.0.1" + } + setLoggedIn(isLoggedIn() || onLocalhost) + let id = getStoredClientId() + if (id === "") { id = getBootClientId() } + setClientId(id) + }, []) + + fn onSubmit(ev) { + ev.preventDefault() + setStoredClientId(clientId) + login() + } + + fn onClientInput(ev) { + setClientId(ev.target.value) + } + + if (loggedIn) { + return
    +
    juke.sh/BOX
    +
    Spotify WebGL Jukebox
    +
    +
    + ENTER THE JUKEBOX + LOG OUT +
    + {"You're signed in to Spotify."} +




    + Or take it for a test spin → +
    +
    +
    + } + + return
    +
    juke.sh/BOX
    +
    Spotify WebGL Jukebox
    +
    +
    + + +
    + {"Create a Spotify app to get a Client ID (free, takes ~1 minute). "} + + How → + +




    + Take it for a test spin without an ID → +
    +
    +
    +} diff --git a/packages/jukebox/src/pages/Logout.tish b/packages/jukebox/src/pages/Logout.tish new file mode 100644 index 0000000..db177e4 --- /dev/null +++ b/packages/jukebox/src/pages/Logout.tish @@ -0,0 +1,14 @@ +import { useEffect } from "lattish" +import { logout } from "../lib/spotify.tish" +import { navigateRaw } from "../router.tish" + +export fn Logout() { + useEffect(() => { + logout() + navigateRaw("/") + }, []) + + return
    +
    Logging out…
    +
    +} diff --git a/packages/jukebox/src/router.tish b/packages/jukebox/src/router.tish new file mode 100644 index 0000000..d67f984 --- /dev/null +++ b/packages/jukebox/src/router.tish @@ -0,0 +1,68 @@ +// History-API router for juke.sh SPA. +// +// Routes: / (landing), /box, /dev, /callback, /logout +// +// Requires the dev/static server to do SPA fallback (serve index.html for +// any unknown path that isn't an asset or under /dist/). + +import { useState, useLayoutEffect } from "lattish" + +const VIEWS = { + "box": "box", + "dev": "dev", + "callback": "callback", + "logout": "logout", +} + +export fn parsePath(pathname) { + let p = pathname + if (p === null || p === undefined) { p = "/" } + while (p.length > 0 && p.charAt(0) === "/") { p = p.substring(1) } + while (p.length > 0 && p.charAt(p.length - 1) === "/") { + p = p.substring(0, p.length - 1) + } + let canonical = p.length === 0 ? "/" : "/" + p + if (p.length === 0) { return { view: "landing", path: "/" } } + let slash = p.indexOf("/") + let seg = slash < 0 ? p : p.substring(0, slash) + let lower = seg.toLowerCase() + if (lower in VIEWS) { + return { view: VIEWS[lower], path: "/" + lower } + } + return { path: canonical } +} + +export fn navigateRaw(pathname) { + let p = pathname === null || pathname === undefined ? "/" : pathname + if (typeof history !== "undefined" && typeof history.pushState === "function") { + history.pushState({}, "", p) + if (typeof window !== "undefined") { + try { + window.dispatchEvent(new PopStateEvent("popstate")) + } catch (e) { + let ev = document.createEvent("Event") + ev.initEvent("popstate", false, false) + window.dispatchEvent(ev) + } + } + } else if (typeof location !== "undefined") { + location.href = p + } +} + +export fn useRoute() { + let initialPath = typeof location !== "undefined" ? location.pathname : "/" + let routeState = useState(parsePath(initialPath)) + let route = routeState[0] + let setRoute = routeState[1] + + useLayoutEffect(() => { + fn onPop() { + setRoute(parsePath(location.pathname)) + } + window.addEventListener("popstate", onPop) + return () => window.removeEventListener("popstate", onPop) + }, []) + + return route +} diff --git a/packages/jukebox/vercel.json b/packages/jukebox/vercel.json new file mode 100644 index 0000000..eb040df --- /dev/null +++ b/packages/jukebox/vercel.json @@ -0,0 +1,49 @@ +{ + "buildCommand": "npm run build", + "outputDirectory": "public", + "rewrites": [ + { "source": "/((?!dist/).*)", "destination": "/index.html" } + ], + "redirects": [ + { + "source": "/BOX", + "destination": "/box", + "permanent": true + }, + { + "source": "/Box", + "destination": "/box", + "permanent": true + }, + { + "source": "/DEV", + "destination": "/dev", + "permanent": true + }, + { + "source": "/Dev", + "destination": "/dev", + "permanent": true + }, + { + "source": "/CALLBACK", + "destination": "/callback", + "permanent": true + }, + { + "source": "/Callback", + "destination": "/callback", + "permanent": true + }, + { + "source": "/LOGOUT", + "destination": "/logout", + "permanent": true + }, + { + "source": "/Logout", + "destination": "/logout", + "permanent": true + } + ] +} diff --git a/scripts/build-jukebox.mjs b/scripts/build-jukebox.mjs new file mode 100644 index 0000000..fb0e1f2 --- /dev/null +++ b/scripts/build-jukebox.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node +import { cp, mkdir, writeFile } from "node:fs/promises" +import { spawnSync } from "node:child_process" +import path from "node:path" +import { fileURLToPath } from "node:url" +import { loadRepoEnv, resolveSpotifyClientId } from "./load-env.mjs" + +const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))) +const jukebox = path.join(root, "packages/jukebox") +const dist = path.join(jukebox, "public/dist") +const tish = process.env.TISH || "tish" + +function run(cmd, args, cwd) { + const r = spawnSync(cmd, args, { cwd, stdio: "inherit" }) + if (r.status !== 0) process.exit(r.status ?? 1) +} + +await mkdir(dist, { recursive: true }) + +run("npm", ["run", "build", "--workspace=@spacedevin/juke-cards"], root) +run("npm", ["run", "build", "--prefix", path.join(root, "packages/bridges")], root) + +await cp(path.join(root, "packages/bridges/dist/vendor.js"), path.join(dist, "vendor.js")) +await cp(path.join(root, "packages/juke-cards/dist/index.js"), path.join(dist, "juke-cards.js")) + +run(tish, ["build", "--target", "js", "src/main.tish", "-o", "public/dist/jukebox.js"], jukebox) + +const clientId = resolveSpotifyClientId(loadRepoEnv(root)) +const configJs = + "window.__JUKE_CONFIG=window.__JUKE_CONFIG||{};\n" + + `window.__JUKE_CONFIG.spotifyClientId=${JSON.stringify(clientId)};\n` +await writeFile(path.join(dist, "config.js"), configJs) + +console.log("jukebox build complete") +if (clientId) { + console.log("Spotify Client ID loaded from env → public/dist/config.js") +} diff --git a/scripts/convert-card-art.mjs b/scripts/convert-card-art.mjs new file mode 100644 index 0000000..75090a1 --- /dev/null +++ b/scripts/convert-card-art.mjs @@ -0,0 +1,135 @@ +#!/usr/bin/env node +/** + * Converts lib/card-art.ts → packages/juke-cards/src/*.tish + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const src = fs.readFileSync(path.join(root, 'lib/card-art.ts'), 'utf8'); +const outDir = path.join(root, 'packages/juke-cards/src'); + +function stripTypes(code) { + return code + .replace(/^\/\/.*$/gm, '') + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/export interface [\s\S]*?\n}\n/g, '') + .replace(/: CardSong|: CardArt|: string|: number|: boolean|: Promise|: CanvasRenderingContext2D|: CanvasTextAlign|: string\[\]\[\]/g, '') + .replace(/\([^)]*\): [^{;=]+(?=[{;=])/g, (m) => m.replace(/: [^{;=]+$/, '')) + .replace(/ as any/g, '') + .replace(/\)!/g, ')') + .replace(/getContext\('2d', \{ willReadFrequently: true \}\)!/g, "getContext('2d', { willReadFrequently: true })") + .replace(/export function /g, 'export fn ') + .replace(/export async function /g, 'export async fn ') + .replace(/function getWcagLuminance\(r, g, b\)/g, 'fn getWcagLuminance(r, g, b)') + .replace(/\(max: number\)/g, '(max)') + .replace(/\(str: string\)/g, '(str)') + .replace(/\(hex: string\)/g, '(hex)') + .replace(/\(song: CardSong\)/g, '(song)') + .replace(/\(targetCtx[^)]*\)/g, '(targetCtx, text, x, y, font, color, align, scaleX, scaleY, rot, shadowOverride)') + .replace(/\(type: number[^)]*\)/g, '(type, cx, cy, size, color)') + .replace(/\(bx: number[^)]*\)/g, '(bx, by, bw, bh)') + .replace(/\(i: number\)/g, '(i)') + .replace(/\(v: number\)/g, '(v)') + .replace(/\(r: number, g: number, b: number\)/g, '(r, g, b)') + .replace(/const /g, 'let ') + .replace(/let rnd = \(\) => \(seedGen\(\) >>> 0\) \/ 4294967296/g, 'let rnd = () => (seedGen() % 4294967296) / 4294967296') + .replace(/\(h << 13\) \| \(h >>> 19\)/g, '((h * 8192) | Math.floor(h / 524288))') + .replace(/h \^ \(h >>> 16\)/g, 'h ^ Math.floor(h / 65536)') + .replace(/h \^ \(h >>> 13\)/g, 'h ^ Math.floor(h / 8192)') + .replace(/\(\(h \^= h >>> 16\) >>> 0\)/g, '(h ^ Math.floor(h / 65536)) % 4294967296') + .replace(/\.substr\(/g, '.substring(') + .replace(/for \(let /g, 'for (let ') + .replace(/new Set\(\[([^\]]+)\]\)/g, '[$1]') + .replace(/(\w+)\.has\((\w+)\)/g, 'arrayIncludes($1, $2)'); +} + +const helpers = `fn arrayIncludes(arr, val) { + let i = 0 + while (i < arr.length) { + if (arr[i] === val) { return true } + i = i + 1 + } + return false +} + +`; + +let body = stripTypes(src); + +// xmur3 + getContrast +const xmur3Match = body.match(/export fn xmur3[\s\S]*?export fn getContrast[\s\S]*?return \(r \* 299[\s\S]*?\n}/); +const xmur3 = xmur3Match ? xmur3Match[0].replace(/export fn getContrast[\s\S]*/, '') : ''; +const contrastMatch = body.match(/export fn getContrast[\s\S]*?return \(r \* 299[\s\S]*?\n}/); +const getContrast = contrastMatch ? contrastMatch[0] : ''; + +// palettes section +const palettesMatch = body.match(/export const palettes[\s\S]*?export const infoFonts[\s\S]*?\];/); +const palettesBlock = palettesMatch ? palettesMatch[0] : ''; + +const preloadMatch = body.match(/export async fn preloadCardFonts[\s\S]*?^\}/m); +const preloadBlock = preloadMatch ? preloadMatch[0] : ''; + +// getWcagLuminance + generateCardArt +const wcagMatch = body.match(/fn getWcagLuminance[\s\S]*?return a\[0\] \* 0\.2126[\s\S]*?\n}/); +const wcagBlock = wcagMatch ? wcagMatch[0] : ''; + +const genMatch = body.match(/export fn generateCardArt[\s\S]*?return \{ canvas, accent: acc1, bg: bg1, alt: acc2 \};\n}/); +const genBlock = genMatch ? genMatch[0] : ''; + +fs.mkdirSync(outDir, { recursive: true }); + +fs.writeFileSync( + path.join(outDir, 'xmur3.tish'), + `${helpers}${xmur3.trim()}\n\n${getContrast.trim()}\n`, +); + +fs.writeFileSync( + path.join(outDir, 'palettes.tish'), + `${palettesBlock.trim()}\n\n${preloadBlock.trim()}\n`, +); + +// Split generate into layer files (backgrounds = LAYER 1 section inside generate) +fs.writeFileSync( + path.join(outDir, 'backgrounds.tish'), + `// Background layer styles (bgStyle 0–45) — invoked from generate.tish\nexport fn backgroundsPlaceholder() { return null }\n`, +); + +fs.writeFileSync( + path.join(outDir, 'decor.tish'), + `// Decor layer styles — invoked from generate.tish\nexport fn decorPlaceholder() { return null }\n`, +); + +fs.writeFileSync( + path.join(outDir, 'borders.tish'), + `// Border layer styles — invoked from generate.tish\nexport fn bordersPlaceholder() { return null }\n`, +); + +fs.writeFileSync( + path.join(outDir, 'layouts.tish'), + `// Layout / typography styles — invoked from generate.tish\nexport fn layoutsPlaceholder() { return null }\n`, +); + +fs.writeFileSync( + path.join(outDir, 'generate.tish'), + `import { xmur3, getContrast } from "./xmur3.tish" +import { palettes, displayFonts, scriptFonts, codeFonts, infoFonts } from "./palettes.tish" + +${wcagBlock.trim()} + +${genBlock.trim()} +`, +); + +fs.writeFileSync( + path.join(outDir, 'index.tish'), + `import { xmur3, getContrast } from "./xmur3.tish" +import { palettes, displayFonts, scriptFonts, codeFonts, infoFonts, preloadCardFonts } from "./palettes.tish" +import { generateCardArt } from "./generate.tish" + +export { xmur3, getContrast, palettes, displayFonts, scriptFonts, codeFonts, infoFonts, preloadCardFonts, generateCardArt } +`, +); + +console.log('Wrote juke-cards Tish sources to', outDir); diff --git a/scripts/load-env.mjs b/scripts/load-env.mjs new file mode 100644 index 0000000..53880bf --- /dev/null +++ b/scripts/load-env.mjs @@ -0,0 +1,37 @@ +import { existsSync, readFileSync } from "node:fs" +import path from "node:path" + +function parseEnvFile(filePath) { + if (!existsSync(filePath)) return {} + const out = {} + for (const line of readFileSync(filePath, "utf8").split("\n")) { + const trimmed = line.trim() + if (trimmed === "" || trimmed.startsWith("#")) continue + const eq = trimmed.indexOf("=") + if (eq === -1) continue + const key = trimmed.slice(0, eq).trim() + let val = trimmed.slice(eq + 1).trim() + if ( + (val.startsWith('"') && val.endsWith('"')) || + (val.startsWith("'") && val.endsWith("'")) + ) { + val = val.slice(1, -1) + } + out[key] = val + } + return out +} + +/** Load repo-root .env then .env.local; process.env wins on key conflicts. */ +export function loadRepoEnv(root) { + const fileEnv = { + ...parseEnvFile(path.join(root, ".env")), + ...parseEnvFile(path.join(root, ".env.local")), + } + return { ...fileEnv, ...process.env } +} + +export function resolveSpotifyClientId(env) { + const id = env.NEXT_PUBLIC_SPOTIFY_CLIENT_ID || env.SPOTIFY_CLIENT_ID || "" + return String(id).trim() +} diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index f5fc2f7..0000000 --- a/tsconfig.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": true, - "skipLibCheck": true, - "strict": true, - "noEmit": true, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "preserve", - "incremental": true, - "plugins": [{ "name": "next" }], - "paths": { "@/*": ["./*"] } - }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] -} From dc608d9c4bb7889d1f1e879339f971b9e49ee3b9 Mon Sep 17 00:00:00 2001 From: spacedevin Date: Wed, 20 May 2026 11:31:14 -0700 Subject: [PATCH 2/3] vercel --- README.md | 8 ++++++-- package.json | 1 + packages/jukebox/vercel.json | 5 ++++- scripts/build-jukebox.mjs | 4 +++- vercel.json | 20 ++++++++++++++++++++ 5 files changed, 34 insertions(+), 4 deletions(-) create mode 100644 vercel.json diff --git a/README.md b/README.md index dd9bc51..ac7e969 100644 --- a/README.md +++ b/README.md @@ -68,9 +68,13 @@ Arrow keys: spin (←/→), rows (↑/↓). `C` category labels, `M` audio, `S` ## Deploy (Vercel) -Root directory: `packages/jukebox`. Output: `public/`. Config in `vercel.json`. +This is a **static SPA**, not Next.js. In the Vercel project settings: -Set `NEXT_PUBLIC_SPOTIFY_CLIENT_ID` or `SPOTIFY_CLIENT_ID` in the Vercel project's environment variables (build time). The build writes it to `public/dist/config.js`. +1. **Root Directory:** leave empty (repo root) — or set `packages/jukebox` if you prefer; both `vercel.json` files are configured for monorepo builds. +2. **Framework Preset:** **Other** (not Next.js). The repo sets `"framework": null` in `vercel.json` to skip framework detection. +3. **Environment variables:** `NEXT_PUBLIC_SPOTIFY_CLIENT_ID` or `SPOTIFY_CLIENT_ID` (build time → `public/dist/config.js`). + +Build runs `npm run vercel-build` from the monorepo root (installs workspaces, compiles Tish, writes static files to `packages/jukebox/public/`). ## Card art package diff --git a/package.json b/package.json index 4ae519e..f21ea07 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "scripts": { "build": "just build", "dev": "just dev", + "vercel-build": "npm run build --workspace=@spacedevin/jukebox", "fmt": "just fmt", "fmt:check": "just fmt-check", "lint": "just lint", diff --git a/packages/jukebox/vercel.json b/packages/jukebox/vercel.json index eb040df..10dc797 100644 --- a/packages/jukebox/vercel.json +++ b/packages/jukebox/vercel.json @@ -1,5 +1,8 @@ { - "buildCommand": "npm run build", + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": null, + "installCommand": "cd ../.. && npm ci", + "buildCommand": "cd ../.. && npm run vercel-build", "outputDirectory": "public", "rewrites": [ { "source": "/((?!dist/).*)", "destination": "/index.html" } diff --git a/scripts/build-jukebox.mjs b/scripts/build-jukebox.mjs index fb0e1f2..7568396 100644 --- a/scripts/build-jukebox.mjs +++ b/scripts/build-jukebox.mjs @@ -8,7 +8,9 @@ import { loadRepoEnv, resolveSpotifyClientId } from "./load-env.mjs" const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))) const jukebox = path.join(root, "packages/jukebox") const dist = path.join(jukebox, "public/dist") -const tish = process.env.TISH || "tish" +const tish = + process.env.TISH || + path.join(root, "node_modules", ".bin", "tish") function run(cmd, args, cwd) { const r = spawnSync(cmd, args, { cwd, stdio: "inherit" }) diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..567a95e --- /dev/null +++ b/vercel.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": null, + "installCommand": "npm ci", + "buildCommand": "npm run vercel-build", + "outputDirectory": "packages/jukebox/public", + "rewrites": [ + { "source": "/((?!dist/).*)", "destination": "/index.html" } + ], + "redirects": [ + { "source": "/BOX", "destination": "/box", "permanent": true }, + { "source": "/Box", "destination": "/box", "permanent": true }, + { "source": "/DEV", "destination": "/dev", "permanent": true }, + { "source": "/Dev", "destination": "/dev", "permanent": true }, + { "source": "/CALLBACK", "destination": "/callback", "permanent": true }, + { "source": "/Callback", "destination": "/callback", "permanent": true }, + { "source": "/LOGOUT", "destination": "/logout", "permanent": true }, + { "source": "/Logout", "destination": "/logout", "permanent": true } + ] +} From fca841eff3d8dcd3455940a7e23a80212d4bf85f Mon Sep 17 00:00:00 2001 From: spacedevin Date: Wed, 20 May 2026 11:59:32 -0700 Subject: [PATCH 3/3] fix prefs --- packages/jukebox/public/base.css | 4 + packages/jukebox/public/index.html | 1 + packages/jukebox/public/prefs-bridge.js | 32 +++++ packages/jukebox/src/components/Jukebox.tish | 97 ++++++++++--- .../jukebox/src/components/SettingsPanel.tish | 130 +++++++++--------- packages/jukebox/src/lib/preferences.tish | 79 ++++------- 6 files changed, 200 insertions(+), 143 deletions(-) create mode 100644 packages/jukebox/public/prefs-bridge.js diff --git a/packages/jukebox/public/base.css b/packages/jukebox/public/base.css index 252f8b1..08ed171 100644 --- a/packages/jukebox/public/base.css +++ b/packages/jukebox/public/base.css @@ -411,6 +411,10 @@ body { backdrop-filter: blur(6px); } +.settings-backdrop.is-hidden { + display: none; +} + .modal.modal-narrow { width: min(360px, 92vw); padding: 18px; diff --git a/packages/jukebox/public/index.html b/packages/jukebox/public/index.html index 55bdfb2..6b94c08 100644 --- a/packages/jukebox/public/index.html +++ b/packages/jukebox/public/index.html @@ -23,6 +23,7 @@
    + diff --git a/packages/jukebox/public/prefs-bridge.js b/packages/jukebox/public/prefs-bridge.js new file mode 100644 index 0000000..60c59da --- /dev/null +++ b/packages/jukebox/public/prefs-bridge.js @@ -0,0 +1,32 @@ +// Browser-native prefs I/O — avoids Tish compile quirks (?? null on object +// values, Object.keys on patches) clobbering stored settings with null. +;(function () { + const KEY = "jukebox_prefs" + + function load() { + try { + const raw = localStorage.getItem(KEY) + if (!raw) return {} + const p = JSON.parse(raw) + return p && typeof p === "object" ? p : {} + } catch { + return {} + } + } + + function save(patch) { + if (!patch || typeof patch !== "object") return + try { + const out = load() + for (const k of Object.keys(patch)) { + const v = patch[k] + if (v !== null && v !== undefined) out[k] = v + } + localStorage.setItem(KEY, JSON.stringify(out)) + } catch { + /* quota / private mode */ + } + } + + globalThis.__jukePrefs = { load, save, KEY } +})() diff --git a/packages/jukebox/src/components/Jukebox.tish b/packages/jukebox/src/components/Jukebox.tish index 718415d..87a969d 100644 --- a/packages/jukebox/src/components/Jukebox.tish +++ b/packages/jukebox/src/components/Jukebox.tish @@ -7,7 +7,7 @@ import { } from "../lib/spotify.tish" import { generateDebugTracks } from "../lib/debug-tracks.tish" import { preloadCardFonts } from "../../../juke-cards/src/index.tish" -import { loadPrefs, savePrefs, flushPrefs } from "../lib/preferences.tish" +import { loadPrefs, savePrefs, prefNumber } from "../lib/preferences.tish" import { opt } from "../lib/utils.tish" import { PlaylistPicker } from "./PlaylistPicker.tish" import { @@ -92,14 +92,16 @@ export fn Jukebox({ debug, rows: initialRows }) { let settingsState = useState(false) let showSettings = settingsState[0] let setShowSettings = settingsState[1] + let settingsTickState = useState(0) + let settingsTick = settingsTickState[0] + let setSettingsTick = settingsTickState[1] let playbackState = useState("") let playbackMsg = playbackState[0] let setPlaybackMsg = playbackState[1] let playbackMsgTimer = useRef(null) - let rowsRef = useRef(rows) - rowsRef.current = rows + let prefsSnapshotRef = useRef({}) let cleanupRef = useRef(null) let tracksDataRef = useRef(null) let mountedRef = useRef(true) @@ -116,6 +118,35 @@ export fn Jukebox({ debug, rows: initialRows }) { shuffle: opt(initialPrefs, "shuffle", true), }) + fn buildPrefsSnapshot() { + return { + lighting: modeRef.current.lighting, + spin: clampSpin(modeRef.current.spin), + rows: clampRows(rows), + showCategories: modeRef.current.showCategories !== false, + zoomTight: clampZoomTight(modeRef.current.zoomTight), + zoomFlat: clampZoomFlat(modeRef.current.zoomFlat), + audioEnabled: modeRef.current.audioEnabled !== false, + shuffle: modeRef.current.shuffle !== false, + } + } + + prefsSnapshotRef.current = buildPrefsSnapshot() + + // Lattish reuses useState slots across route changes — always re-read localStorage + // on mount so saved rows/spin/etc. apply even when the hook slot is stale. + useEffect(() => { + let prefs = loadPrefs() + setRows(clampRows(prefNumber(prefs, "rows", initialRows))) + modeRef.current.lighting = clampUnit(prefNumber(prefs, "lighting", 0.5)) + modeRef.current.spin = clampSpin(prefNumber(prefs, "spin", 0)) + modeRef.current.showCategories = opt(prefs, "showCategories", true) + modeRef.current.zoomTight = clampZoomTight(prefNumber(prefs, "zoomTight", ZOOM_TIGHT_DEFAULT)) + modeRef.current.zoomFlat = clampZoomFlat(prefNumber(prefs, "zoomFlat", 0)) + modeRef.current.audioEnabled = opt(prefs, "audioEnabled", true) + modeRef.current.shuffle = opt(prefs, "shuffle", true) + }, []) + fn showPlaybackError(msg) { if (!mountedRef.current) { return } setPlaybackMsg(msg) @@ -231,9 +262,12 @@ export fn Jukebox({ debug, rows: initialRows }) { zoomFlat: m.zoomFlat, audioEnabled: m.audioEnabled, shuffle: m.shuffle, - rows: rows, + rows: clampRows(rows), }) modeRef.current = scene.mode + modeRef.current.spin = clampSpin(modeRef.current.spin) + modeRef.current.zoomTight = clampZoomTight(modeRef.current.zoomTight) + modeRef.current.zoomFlat = clampZoomFlat(modeRef.current.zoomFlat) cleanupRef.current = scene.dispose return () => { @@ -262,14 +296,18 @@ export fn Jukebox({ debug, rows: initialRows }) { savePrefs({ spin: modeRef.current.spin }) } else if (ev.key === "ArrowUp") { ev.preventDefault() - let n = Math.min(MAX_ROWS, rowsRef.current + 1) - setRows(n) - savePrefs({ rows: n }) + let n = clampRows(Math.min(MAX_ROWS, rows + 1)) + if (n !== rows) { + setRows(n) + savePrefs({ rows: n }) + } } else if (ev.key === "ArrowDown") { ev.preventDefault() - let n = Math.max(MIN_ROWS, rowsRef.current - 1) - setRows(n) - savePrefs({ rows: n }) + let n = clampRows(Math.max(MIN_ROWS, rows - 1)) + if (n !== rows) { + setRows(n) + savePrefs({ rows: n }) + } } else if (ev.key === "z" || ev.key === "Z") { ev.preventDefault() modeRef.current.zoomTight = clampZoomTight(modeRef.current.zoomTight - 0.05) @@ -304,7 +342,19 @@ export fn Jukebox({ debug, rows: initialRows }) { } window.addEventListener("keydown", onKey) return () => window.removeEventListener("keydown", onKey) - }, []) + }, [rows]) + + useEffect(() => { + if (!showSettings) { return () => {} } + fn onKey(ev) { + if (ev.key === "Escape") { + ev.preventDefault() + onCloseSettings() + } + } + window.addEventListener("keydown", onKey) + return () => window.removeEventListener("keydown", onKey) + }, [showSettings]) let tapTimes = useRef([]) let pdAt = 0 @@ -342,15 +392,8 @@ export fn Jukebox({ debug, rows: initialRows }) { } } fn onHide() { - savePrefs({ - lighting: modeRef.current.lighting, - spin: modeRef.current.spin, - showCategories: modeRef.current.showCategories, - zoomTight: modeRef.current.zoomTight, - audioEnabled: modeRef.current.audioEnabled, - shuffle: modeRef.current.shuffle, - }) - flushPrefs() + if (typeof document !== "undefined" && document.visibilityState !== "hidden") { return } + savePrefs(prefsSnapshotRef.current) } window.addEventListener("pointerdown", onDown) window.addEventListener("pointerup", onUp) @@ -395,9 +438,17 @@ export fn Jukebox({ debug, rows: initialRows }) { fn onDismissPlayback() { setPlaybackMsg("") } - fn onCloseSettings() { setShowSettings(false) } + fn onCloseSettings() { + savePrefs(buildPrefsSnapshot()) + setShowSettings(false) + } + + fn onSettingsPanelChange() { + setSettingsTick(settingsTick + 1) + } fn onChangeRows(n) { + n = clampRows(n) setRows(n) savePrefs({ rows: n }) } @@ -451,12 +502,14 @@ export fn Jukebox({ debug, rows: initialRows }) { /> : null} - {showSettings + {phase === "ready" ? : null}
    diff --git a/packages/jukebox/src/components/SettingsPanel.tish b/packages/jukebox/src/components/SettingsPanel.tish index 746b97f..261e8ff 100644 --- a/packages/jukebox/src/components/SettingsPanel.tish +++ b/packages/jukebox/src/components/SettingsPanel.tish @@ -1,4 +1,3 @@ -import { useState, useEffect } from "lattish" import { savePrefs } from "../lib/preferences.tish" export const SPIN_MAX = 10 @@ -10,6 +9,8 @@ export const ZOOM_TIGHT_MAX = 1.2 export const ZOOM_FLAT_STEP = 0.2 export fn clampRows(n) { + if (typeof n !== "number" || n !== n) { n = Number(n) } + if (typeof n !== "number" || n !== n) { return MIN_ROWS } let v = n | 0 if (v < MIN_ROWS) { return MIN_ROWS } if (v > MAX_ROWS) { return MAX_ROWS } @@ -17,109 +18,108 @@ export fn clampRows(n) { } export fn clampSpin(n) { + if (typeof n !== "number" || n !== n) { n = Number(n) } + if (typeof n !== "number" || n !== n) { return 0 } if (n < -SPIN_MAX) { return -SPIN_MAX } if (n > SPIN_MAX) { return SPIN_MAX } return n } export fn clampZoomTight(n) { + if (typeof n !== "number" || n !== n) { n = Number(n) } + if (typeof n !== "number" || n !== n) { return ZOOM_TIGHT_MIN } if (n < ZOOM_TIGHT_MIN) { return ZOOM_TIGHT_MIN } if (n > ZOOM_TIGHT_MAX) { return ZOOM_TIGHT_MAX } return n } export fn clampZoomFlat(n) { + if (typeof n !== "number" || n !== n) { n = Number(n) } + if (typeof n !== "number" || n !== n) { return 0 } if (n < 0) { return 0 } if (n > 1) { return 1 } return n } -export fn SettingsPanel({ modeRef, rows, onChangeRows, onClose }) { - let spinState = useState(modeRef.current.spin) - let spin = spinState[0] - let setSpin = spinState[1] - let showCatState = useState(modeRef.current.showCategories) - let showCategories = showCatState[0] - let setShowCategories = showCatState[1] - let zoomTightState = useState(modeRef.current.zoomTight) - let zoomTight = zoomTightState[0] - let setZoomTight = zoomTightState[1] - let zoomFlatState = useState(modeRef.current.zoomFlat) - let zoomFlat = zoomFlatState[0] - let setZoomFlat = zoomFlatState[1] - let audioState = useState(modeRef.current.audioEnabled) - let audioEnabled = audioState[0] - let setAudioEnabled = audioState[1] - let shuffleState = useState(modeRef.current.shuffle) - let shuffle = shuffleState[0] - let setShuffleLocal = shuffleState[1] - - useEffect(() => { - fn onKey(ev) { - if (ev.key === "Escape") { - ev.preventDefault() - onClose() - } - } - window.addEventListener("keydown", onKey) - return () => window.removeEventListener("keydown", onKey) - }, []) +export fn fmtNum(n, digits) { + if (typeof n !== "number" || n !== n) { n = 0 } + return n.toFixed(digits) +} - fn updateSpin(v) { modeRef.current.spin = v; savePrefs({ spin: v }) } - fn updateShowCategories(v) { modeRef.current.showCategories = v; savePrefs({ showCategories: v }) } - fn updateZoomTight(v) { modeRef.current.zoomTight = v; savePrefs({ zoomTight: v }) } - fn updateZoomFlat(v) { modeRef.current.zoomFlat = v; savePrefs({ zoomFlat: v }) } - fn updateAudioEnabled(v) { modeRef.current.audioEnabled = v; savePrefs({ audioEnabled: v }) } - fn updateShuffle(v) { modeRef.current.shuffle = v; savePrefs({ shuffle: v }) } +// Stateless panel — no useState/useEffect here. Lattish shares one global hook +// list; mounting a hook-using child corrupts Jukebox state when it unmounts. +// Also stay mounted (open prop) so toggling settings does not change sibling +// count on jukebox-root — Lattish replaceChildren would recreate #webgl-container. +export fn SettingsPanel({ open, modeRef, rows, onChangeRows, onClose, onPanelChange }) { + let m = modeRef.current + let spin = clampSpin(m.spin) + let showCategories = m.showCategories !== false + let zoomTight = clampZoomTight(m.zoomTight) + let zoomFlat = clampZoomFlat(m.zoomFlat) + let audioEnabled = m.audioEnabled !== false + let shuffle = m.shuffle !== false + + fn bump() { + if (onPanelChange !== null && onPanelChange !== undefined) { onPanelChange() } + } fn onBackdropClick() { onClose() } fn stopProp(ev) { ev.stopPropagation() } fn onSpinInput(ev) { - let v = parseFloat(ev.target.value) - setSpin(v) - updateSpin(v) + let v = clampSpin(parseFloat(ev.target.value)) + m.spin = v + savePrefs({ spin: v }) + bump() } fn onSpinZero() { - setSpin(0) - updateSpin(0) + m.spin = 0 + savePrefs({ spin: 0 }) + bump() } fn onRowsInput(ev) { onChangeRows(clampRows(parseInt(ev.target.value))) } fn onZoomTightInput(ev) { - let v = parseFloat(ev.target.value) - setZoomTight(v) - updateZoomTight(v) + let v = clampZoomTight(parseFloat(ev.target.value)) + m.zoomTight = v + savePrefs({ zoomTight: v }) + bump() } fn onZoomFlatInput(ev) { - let v = parseFloat(ev.target.value) - setZoomFlat(v) - updateZoomFlat(v) + let v = clampZoomFlat(parseFloat(ev.target.value)) + m.zoomFlat = v + savePrefs({ zoomFlat: v }) + bump() } fn onShowCatChange(ev) { - let v = ev.target.checked - setShowCategories(v) - updateShowCategories(v) + m.showCategories = ev.target.checked + savePrefs({ showCategories: m.showCategories }) + bump() } fn onAudioChange(ev) { - let v = ev.target.checked - setAudioEnabled(v) - updateAudioEnabled(v) - if (modeRef.current.setAudioEnabled !== undefined) { - modeRef.current.setAudioEnabled(v) + m.audioEnabled = ev.target.checked + savePrefs({ audioEnabled: m.audioEnabled }) + if (m.setAudioEnabled !== null && m.setAudioEnabled !== undefined) { + m.setAudioEnabled(m.audioEnabled) } + bump() } fn onShuffleChange(ev) { - let v = ev.target.checked - setShuffleLocal(v) - updateShuffle(v) - if (modeRef.current.setShuffle !== undefined) { - modeRef.current.setShuffle(v) + m.shuffle = ev.target.checked + savePrefs({ shuffle: m.shuffle }) + if (m.setShuffle !== null && m.setShuffle !== undefined) { + m.setShuffle(m.shuffle) } + bump() } - return
    + if (open === null || open === undefined) { open = false } + + let backdropClass = "center-screen settings-backdrop" + if (!open) { backdropClass = backdropClass + " is-hidden" } + + return