commit 835475ac863ff77ca1904849c6063575707ae83d Author: sittichok Ouamsiri Date: Fri Jun 12 21:55:03 2026 +0700 first commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bf4f8d9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +frontend/node_modules +frontend/dist +backend/target diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1f66c72 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,43 @@ +# ── Stage 1: build frontend ────────────────────────────────────────────────── +FROM node:22-alpine AS frontend-builder + +WORKDIR /app/frontend + +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci + +COPY frontend/ ./ +RUN npm run build # → /app/frontend/dist + + +# ── Stage 2: build backend ──────────────────────────────────────────────────── +FROM rust:1.82-alpine AS backend-builder + +# musl-dev for static linking on Alpine +RUN apk add --no-cache musl-dev + +WORKDIR /app/backend + +# Cache dependency layer — copy manifests first, then source +COPY backend/Cargo.toml backend/Cargo.lock ./ +RUN mkdir src && echo 'fn main(){}' > src/main.rs && cargo build --release && rm -rf src + +COPY backend/src ./src +# Touch main.rs so cargo knows it changed after the dummy build above +RUN touch src/main.rs && cargo build --release + + +# ── Stage 3: final runtime image ───────────────────────────────────────────── +FROM alpine:3.20 + +WORKDIR /app + +COPY --from=backend-builder /app/backend/target/release/showdown-backend ./showdown-backend +COPY --from=frontend-builder /app/frontend/dist ./dist + +ENV PORT=8080 +ENV STATIC_DIR=/app/dist + +EXPOSE 8080 + +CMD ["./showdown-backend"] diff --git a/PROTOCOL.md b/PROTOCOL.md new file mode 100644 index 0000000..d5a5bd3 --- /dev/null +++ b/PROTOCOL.md @@ -0,0 +1,78 @@ +# Showdown — room protocol + +Contract between the frontend and the backend (Rust, in `backend/`). The +design goal is **no database**: each room lives entirely in server memory and +disappears when its last player disconnects. + +## Transport + +- One WebSocket per player: `GET /ws/room/{roomId}` (upgrade). +- All frames are JSON text messages. +- The server serves the built frontend (`frontend/dist`) for every other + route, so deep links like `/room/lucky-coyote-07` load the SPA. + +## Room lifecycle + +- A room is created in memory the first time someone connects to its id. +- Presence **is** the connection: when a socket closes, that player is removed + and a new state snapshot is broadcast (this is why friends vanish when they + close the tab). +- When the last socket closes, the room is deleted. Watchers count as + connections (a TV keeps the room alive) but never as players. +- `playerId` is generated by the browser and persisted in localStorage. A + reconnect with the same `playerId` replaces the old seat (refresh keeps your + identity and your vote). + +## Client → server messages + +```jsonc +{ "type": "join", "playerId": "uuid", "name": "Billy", "emoji": "🤠" } // first message on every (re)connect +{ "type": "watch" } // alternative first message: spectator (TV display) +{ "type": "vote", "value": "5" } // value: deck card, or null to retract +{ "type": "reveal" } // anyone may reveal; ignored if no votes yet +{ "type": "reset" } // anyone may start a new round +{ "type": "deck", "cards": ["1","2","3"] } // anyone may swap the deck; implies reset +``` + +A socket that opens with `watch` instead of `join` is a **watcher**: it +receives every state snapshot but never appears in `players`, and any other +message it sends is ignored. The frontend's TV view (`/room/{id}/tv`) connects +this way. + +## Server → client messages + +The server replies to **every** change (join, leave, vote, reveal, reset, +deck) by broadcasting one full-state snapshot to every socket in the room. +Snapshots are small (≤ ~16 players), so no deltas are needed. + +```jsonc +{ + "type": "state", + "state": { + "roomId": "lucky-coyote-07", + "deck": ["0","1","2","3","5","8","13","21","34","?","☕"], + "revealed": false, + "round": 3, + "players": [ + { "id": "uuid", "name": "Billy", "emoji": "🤠", "voted": true, "vote": null } + ] + } +} +``` + +Rules the server must enforce: + +- `vote` is hidden (`null`) in snapshots until `revealed` is true — only + `voted` leaks before the showdown. +- Votes are rejected while `revealed` is true. +- `reset` and `deck` increment `round`, clear all votes and set + `revealed = false`. Clients use the `round` counter to clear local UI state. +- `reveal` with zero votes is a no-op. + +## Server shape (implemented in `backend/`) + +All rooms in one `Mutex>`. Each socket is a single +async task: it registers (join/watch), subscribes to the room's broadcast +channel, then loops — relaying snapshots out and applying client messages in. +Disconnect cleanup removes the seat (and the room, when its socket count hits +zero). No persistence anywhere. diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/backend/Cargo.lock b/backend/Cargo.lock new file mode 100644 index 0000000..3cdb559 --- /dev/null +++ b/backend/Cargo.lock @@ -0,0 +1,805 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "base64", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "showdown-backend" +version = "0.1.0" +dependencies = [ + "axum", + "serde", + "serde_json", + "tokio", + "tower-http", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/backend/Cargo.toml b/backend/Cargo.toml new file mode 100644 index 0000000..0df8f79 --- /dev/null +++ b/backend/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "showdown-backend" +version = "0.1.0" +edition = "2021" + +[dependencies] +axum = { version = "0.8", features = ["ws"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "sync"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tower-http = { version = "0.6", features = ["fs"] } + +[profile.release] +opt-level = "z" +lto = true +strip = true diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..0c7460b --- /dev/null +++ b/backend/README.md @@ -0,0 +1,46 @@ +# Showdown backend + +Rust (axum + tokio). Implements `../PROTOCOL.md`: WebSocket rooms that live +entirely in memory and a static file server for the built frontend — the whole +app is one binary plus a `dist/` folder. + +## Run it + +```sh +# build the frontend first so there is something to serve +cd ../frontend && npm run build && cd ../backend + +cargo run # http://localhost:8080 +``` + +- `PORT` — listen port (default `8080`) +- `STATIC_DIR` — built frontend location (default `../frontend/dist`) + +During frontend development you can instead run `npm run dev` with +`VITE_WS=1` and Vite proxies `/ws` here. + +## How it stays small + +- **No database, by design.** A `Room` is a deck, a `revealed` flag, a round + counter and a `Vec` of players — a few hundred bytes. All rooms sit in one + `Mutex`. When the last socket of a room closes, the room is + removed; restart the server and everything is gone, which is the point. +- **One task per socket, nothing else.** Each connection is a single + `tokio::select!` loop (snapshots out, messages in, a 30s keepalive ping). + No per-room goroutine-style actors, no background jobs, no timers per room. +- **Snapshots are serialized once per change** (borrowed data, no clones) and + fanned out through a per-room `broadcast` channel with a small buffer. + +## Files + +``` +src/main.rs routes + static serving with SPA fallback (~40 lines) +src/room.rs room state and the game rules (vote/reveal/reset/deck) +src/ws.rs socket lifecycle: hello → select loop → disconnect cleanup +``` + +Rules enforced here (mirroring PROTOCOL.md): votes are hidden until reveal +and rejected after it; reveal needs at least one vote; reset/deck-change +start a fresh round; a refresh re-claims the same seat without losing the +vote; watchers (`watch` handshake — the TV view) receive everything and may +send nothing. diff --git a/backend/src/main.rs b/backend/src/main.rs new file mode 100644 index 0000000..950184c --- /dev/null +++ b/backend/src/main.rs @@ -0,0 +1,39 @@ +//! Showdown backend: WebSocket rooms held entirely in memory, plus static +//! file serving for the built frontend. No database — a room lives exactly +//! as long as someone has it open. See ../PROTOCOL.md for the wire format. + +mod room; +mod ws; + +use std::env; +use std::path::PathBuf; + +use axum::routing::get; +use axum::Router; +use tower_http::services::{ServeDir, ServeFile}; + +use crate::room::Rooms; + +#[tokio::main] +async fn main() { + let rooms = Rooms::default(); + + let static_dir = + PathBuf::from(env::var("STATIC_DIR").unwrap_or_else(|_| "../frontend/dist".into())); + // Unknown paths fall back to index.html so deep links like /room/abc work. + let spa = ServeDir::new(&static_dir).fallback(ServeFile::new(static_dir.join("index.html"))); + + let app = Router::new() + .route("/ws/room/{room_id}", get(ws::ws_handler)) + .fallback_service(spa) + .with_state(rooms); + + let port: u16 = env::var("PORT").ok().and_then(|p| p.parse().ok()).unwrap_or(8080); + let listener = tokio::net::TcpListener::bind(("0.0.0.0", port)) + .await + .expect("failed to bind port"); + + println!("🤠 Showdown saloon open at http://localhost:{port}"); + println!(" serving frontend from {}", static_dir.display()); + axum::serve(listener, app).await.expect("server error"); +} diff --git a/backend/src/room.rs b/backend/src/room.rs new file mode 100644 index 0000000..bc475be --- /dev/null +++ b/backend/src/room.rs @@ -0,0 +1,214 @@ +//! Room state and rules. Everything lives in one `Mutex` — a room +//! is a few hundred bytes and is dropped the moment its last socket closes. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use serde::{Deserialize, Serialize}; +use tokio::sync::broadcast; + +pub type Rooms = Arc>>; + +pub const DEFAULT_DECK: &[&str] = &["0", "1", "2", "3", "5", "8", "13", "21", "34", "?", "☕"]; +const MAX_DECK_CARDS: usize = 15; +const MAX_CARD_CHARS: usize = 4; + +/// Everything a client may send. The first frame must be `join` or `watch`; +/// the rest only make sense from a seated player. +#[derive(Deserialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum ClientMessage { + Join { + #[serde(rename = "playerId")] + player_id: String, + name: String, + emoji: String, + }, + Watch, + Vote { value: Option }, + Reveal, + Reset, + Deck { cards: Vec }, +} + +pub struct Player { + pub id: String, + pub name: String, + pub emoji: String, + pub vote: Option, + /// Newest socket claiming this seat. A refresh re-joins with the same + /// player id and takes over the seat (keeping the vote); the old socket's + /// disconnect then sees a newer `conn` and leaves the seat alone. + pub conn: u64, +} + +pub struct Room { + pub deck: Vec, + pub revealed: bool, + pub round: u64, + /// Join order is seat order, and scrum teams are small — a Vec beats a map. + pub players: Vec, + /// Open sockets, players and watchers alike. Zero means delete the room. + pub conns: usize, + /// Every state change is serialized once and fanned out through here. + pub tx: broadcast::Sender, +} + +impl Room { + pub fn new() -> Self { + Self { + deck: DEFAULT_DECK.iter().map(|c| c.to_string()).collect(), + revealed: false, + round: 1, + players: Vec::new(), + conns: 0, + tx: broadcast::channel(32).0, + } + } + + pub fn join(&mut self, id: &str, name: &str, emoji: &str, conn: u64) { + match self.players.iter_mut().find(|p| p.id == id) { + Some(p) => { + p.name = name.to_string(); + p.emoji = emoji.to_string(); + p.conn = conn; + } + None => self.players.push(Player { + id: id.to_string(), + name: name.to_string(), + emoji: emoji.to_string(), + vote: None, + conn, + }), + } + } + + /// Free the seat unless a newer socket (a refresh) already re-claimed it. + /// Returns whether the player list actually changed. + pub fn leave(&mut self, id: &str, conn: u64) -> bool { + match self.players.iter().position(|p| p.id == id && p.conn == conn) { + Some(pos) => { + self.players.remove(pos); + true + } + None => false, + } + } + + pub fn apply(&mut self, player_id: &str, msg: ClientMessage) { + match msg { + ClientMessage::Vote { value } => { + if self.revealed { + return; // no changing your story after the showdown + } + if let Some(v) = &value { + if !self.deck.contains(v) { + return; + } + } + if let Some(p) = self.players.iter_mut().find(|p| p.id == player_id) { + p.vote = value; + } + } + ClientMessage::Reveal => { + if self.players.iter().any(|p| p.vote.is_some()) { + self.revealed = true; + } + } + ClientMessage::Reset => self.new_round(), + ClientMessage::Deck { cards } => { + if let Some(deck) = clean_deck(cards) { + self.deck = deck; + self.new_round(); + } + } + // join/watch are handshake messages, meaningless mid-session. + ClientMessage::Join { .. } | ClientMessage::Watch => {} + } + } + + fn new_round(&mut self) { + self.revealed = false; + self.round += 1; + for p in &mut self.players { + p.vote = None; + } + } + + /// One JSON snapshot of the room. Votes stay hidden until the reveal — + /// only `voted` leaks beforehand. Serializes from borrows; no clones. + pub fn snapshot_json(&self, room_id: &str) -> String { + #[derive(Serialize)] + struct PlayerOut<'a> { + id: &'a str, + name: &'a str, + emoji: &'a str, + voted: bool, + vote: Option<&'a str>, + } + + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Snapshot<'a> { + room_id: &'a str, + deck: &'a [String], + revealed: bool, + round: u64, + players: Vec>, + } + + #[derive(Serialize)] + struct StateMsg<'a> { + r#type: &'static str, + state: Snapshot<'a>, + } + + let players = self + .players + .iter() + .map(|p| PlayerOut { + id: &p.id, + name: &p.name, + emoji: &p.emoji, + voted: p.vote.is_some(), + vote: if self.revealed { p.vote.as_deref() } else { None }, + }) + .collect(); + + serde_json::to_string(&StateMsg { + r#type: "state", + state: Snapshot { + room_id, + deck: &self.deck, + revealed: self.revealed, + round: self.round, + players, + }, + }) + .expect("room state always serializes") + } + + pub fn broadcast(&self, room_id: &str) { + // Send fails only when nobody is listening, which is fine. + let _ = self.tx.send(self.snapshot_json(room_id)); + } +} + +/// Trim, dedupe and cap a proposed deck. None if fewer than two cards survive. +fn clean_deck(cards: Vec) -> Option> { + let mut out: Vec = Vec::new(); + for raw in cards { + let card = raw.trim(); + if card.is_empty() || card.chars().count() > MAX_CARD_CHARS { + continue; + } + if out.iter().any(|c| c == card) { + continue; + } + out.push(card.to_string()); + if out.len() == MAX_DECK_CARDS { + break; + } + } + (out.len() >= 2).then_some(out) +} diff --git a/backend/src/ws.rs b/backend/src/ws.rs new file mode 100644 index 0000000..8e75789 --- /dev/null +++ b/backend/src/ws.rs @@ -0,0 +1,196 @@ +//! One async task per socket. The task introduces itself (join/watch), then +//! sits in a single select loop: room snapshots out, client messages in. +//! Disconnect cleanup is what makes presence work — closing the tab is +//! leaving the room. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; + +use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use tokio::sync::broadcast; +use tokio::sync::broadcast::error::RecvError; + +use crate::room::{ClientMessage, Room, Rooms}; + +/// Distinguishes the sockets of a player who refreshed; see `Player::conn`. +static NEXT_CONN: AtomicU64 = AtomicU64::new(1); + +const HELLO_TIMEOUT: Duration = Duration::from_secs(10); +const PING_INTERVAL: Duration = Duration::from_secs(30); + +enum Seat { + Player { id: String }, + Watcher, +} + +pub async fn ws_handler( + Path(room_id): Path, + State(rooms): State, + ws: WebSocketUpgrade, +) -> Response { + if !valid_room_id(&room_id) { + return (StatusCode::BAD_REQUEST, "bad room id").into_response(); + } + ws.on_upgrade(move |socket| handle_socket(socket, room_id, rooms)) +} + +fn valid_room_id(id: &str) -> bool { + !id.is_empty() + && id.len() <= 64 + && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') +} + +async fn handle_socket(mut socket: WebSocket, room_id: String, rooms: Rooms) { + // 1. The first frame must introduce the socket: join (player) or watch (TV). + let hello = match tokio::time::timeout(HELLO_TIMEOUT, socket.recv()).await { + Ok(Some(Ok(Message::Text(text)))) => text, + _ => return, + }; + let seat = match serde_json::from_str::(&hello) { + Ok(ClientMessage::Join { player_id, name, emoji }) => { + if player_id.is_empty() || player_id.len() > 64 { + return; + } + let conn_id = NEXT_CONN.fetch_add(1, Ordering::Relaxed); + register_player( + &rooms, + &room_id, + &player_id, + &clean_text(&name, 16, "Outlaw"), + &clean_text(&emoji, 8, "🤠"), + conn_id, + ); + (Seat::Player { id: player_id }, conn_id) + } + Ok(ClientMessage::Watch) => { + let conn_id = NEXT_CONN.fetch_add(1, Ordering::Relaxed); + register_watcher(&rooms, &room_id, conn_id); + (Seat::Watcher, conn_id) + } + _ => return, + }; + let (seat, conn_id) = seat; + + // 2. Subscribe to snapshots and send this socket the current one. + let (mut rx, snapshot) = { + let mut map = rooms.lock().unwrap(); + let room = map.get_mut(&room_id).expect("registered above"); + (room.tx.subscribe(), room.snapshot_json(&room_id)) + }; + + // 3. The whole session is one loop; whenever it ends, fall through to cleanup. + serve(&mut socket, &room_id, &rooms, &seat, &mut rx, snapshot).await; + + // 4. Disconnect: free the seat, tell the others, drop the room if empty. + cleanup(&rooms, &room_id, &seat, conn_id); +} + +fn register_player(rooms: &Rooms, room_id: &str, id: &str, name: &str, emoji: &str, conn: u64) { + let mut map = rooms.lock().unwrap(); + let room = map.entry(room_id.to_string()).or_insert_with(Room::new); + room.conns += 1; + room.join(id, name, emoji, conn); + room.broadcast(room_id); +} + +fn register_watcher(rooms: &Rooms, room_id: &str, _conn: u64) { + let mut map = rooms.lock().unwrap(); + let room = map.entry(room_id.to_string()).or_insert_with(Room::new); + room.conns += 1; +} + +async fn serve( + socket: &mut WebSocket, + room_id: &str, + rooms: &Rooms, + seat: &Seat, + rx: &mut broadcast::Receiver, + first_snapshot: String, +) { + if socket.send(Message::Text(first_snapshot.into())).await.is_err() { + return; + } + let mut ping = tokio::time::interval(PING_INTERVAL); + ping.tick().await; // the first tick is immediate; skip it + + loop { + tokio::select! { + // A state change somewhere in the room — relay the snapshot. + update = rx.recv() => { + let json = match update { + Ok(json) => json, + // This socket fell behind; skip straight to the freshest state. + Err(RecvError::Lagged(_)) => match current_snapshot(rooms, room_id) { + Some(json) => json, + None => return, + }, + Err(RecvError::Closed) => return, + }; + if socket.send(Message::Text(json.into())).await.is_err() { + return; + } + } + + // A frame from this client. + frame = socket.recv() => { + let text = match frame { + Some(Ok(Message::Text(text))) => text, + Some(Ok(_)) => continue, // pongs etc. — ignore + _ => return, // closed or errored + }; + let Seat::Player { id } = seat else { + continue; // watchers are read-only + }; + let Ok(msg) = serde_json::from_str::(&text) else { + continue; // malformed frame — ignore + }; + let mut map = rooms.lock().unwrap(); + if let Some(room) = map.get_mut(room_id) { + room.apply(id, msg); + room.broadcast(room_id); + } + } + + // Keepalive so idle rooms survive proxies and NAT timeouts. + _ = ping.tick() => { + if socket.send(Message::Ping(Default::default())).await.is_err() { + return; + } + } + } + } +} + +fn current_snapshot(rooms: &Rooms, room_id: &str) -> Option { + rooms.lock().unwrap().get(room_id).map(|r| r.snapshot_json(room_id)) +} + +fn cleanup(rooms: &Rooms, room_id: &str, seat: &Seat, conn_id: u64) { + let mut map = rooms.lock().unwrap(); + let Some(room) = map.get_mut(room_id) else { + return; + }; + room.conns -= 1; + if room.conns == 0 { + // Last one out: the whole room vanishes. No database, no leftovers. + map.remove(room_id); + return; + } + if let Seat::Player { id } = seat { + if room.leave(id, conn_id) { + room.broadcast(room_id); + } + } +} + +fn clean_text(raw: &str, max_chars: usize, fallback: &str) -> String { + let trimmed: String = raw.trim().chars().take(max_chars).collect(); + if trimmed.is_empty() { + fallback.to_string() + } else { + trimmed + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9799220 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +services: + showdown: + build: + context: . + dockerfile: Dockerfile + ports: + - "8080:8080" + environment: + PORT: 8080 + STATIC_DIR: /app/dist + restart: unless-stopped diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..e5537be --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.local diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..ecff559 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,53 @@ +# Showdown frontend + +Game-like scrum poker. React + TypeScript + Vite, with the +[Motion](https://motion.dev) library for spring-physics animation. + +## Run it + +```sh +npm install +npm run dev +``` + +In dev there is no backend yet, so the app runs in **demo mode**: a local mock +room where three western bots join, think, and vote — every animation and flow +(deal-in, card flips, consensus confetti, deck editing) is exercisable solo. +You'll see a `demo` chip in the top bar. + +- `VITE_WS=1 npm run dev` — talk to a real backend on `localhost:8080` + (the Vite proxy forwards `/ws`) instead of the mock. +- `npm run build` — typecheck + production build to `dist/`. Production builds + always use the real WebSocket at `/ws/room/{id}` on the same host. + +## TV display + +The 📺 button in a room pops up `/room/{id}/tv` — a view-only big-screen +spectator display (cast it to a TV): giant room code and join URL, oversized +seats and cards, no controls, plus a fullscreen toggle. It connects as a +`watch` socket in production; in demo mode it mirrors your game tab over a +BroadcastChannel (or runs a self-playing bot loop if no game tab is open). + +## Mobile + +On screens ≤700px the hand switches from the overlapping fan to a flat +wrapped grid of full-size cards (~60–84px wide), so every card is a +comfortable tap target. + +## Layout + +``` +src/ + lib/connection.ts WsConnection (real, reconnecting) + MockConnection (bots) + lib/session.ts localStorage profile + stable playerId + room id words + lib/decks.ts presets + custom deck parsing + lib/router.ts 30-line history router (/ and /room/:id) + pages/Home.tsx create room: name, avatar, deck choice + pages/Room.tsx the game: top bar, table, hand, deck modal, confetti + components/ Table, PlayerSeat, Cards (face/back/flip), CardHand, + Results, Confetti, ProfileForm, DeckModal, Logo + index.css the whole "toy poker table" design system +``` + +The wire protocol the mock implements is the same one the Rust backend in +`../backend` speaks — see `../PROTOCOL.md`. diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..37ff425 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,21 @@ + + + + + + + + + + + + Showdown — Scrum Poker + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..c0fb4a9 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1775 @@ +{ + "name": "showdown-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "showdown-frontend", + "version": "0.1.0", + "dependencies": { + "motion": "^12.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "~5.7.2", + "vite": "^6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", + "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", + "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", + "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", + "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", + "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", + "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", + "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", + "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", + "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz", + "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz", + "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz", + "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz", + "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz", + "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz", + "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz", + "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz", + "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz", + "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz", + "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz", + "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz", + "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz", + "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz", + "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz", + "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz", + "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.36", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.36.tgz", + "integrity": "sha512-lVq/Df7LXlO79MVaaUHztSwWiG9oXoWHlgvNS51v8Dpd4+G4/VIy6qYePTw31nAVls33nUtnfezYeLkYAak9dg==", + "dev": true, + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "dev": true, + "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" + } + ] + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "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==", + "dev": true + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.372", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.372.tgz", + "integrity": "sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==", + "dev": true + }, + "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, + "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, + "engines": { + "node": ">=6" + } + }, + "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, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/framer-motion": { + "version": "12.40.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.40.0.tgz", + "integrity": "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==", + "dependencies": { + "motion-dom": "^12.40.0", + "motion-utils": "^12.39.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.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 + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/motion": { + "version": "12.40.0", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.40.0.tgz", + "integrity": "sha512-yjrHUrBFW6kQvjJwRsoiPSAhC5tRwRqNGJWmiJ4CrGnbKp0V88AdzkhBmDoqIsIPfarOe0Uddd37Xq43/gIocA==", + "dependencies": { + "framer-motion": "^12.40.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "12.40.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.40.0.tgz", + "integrity": "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==", + "dependencies": { + "motion-utils": "^12.39.0" + } + }, + "node_modules/motion-utils": { + "version": "12.39.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz", + "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==" + }, + "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 + }, + "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==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "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 + }, + "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, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "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" + } + ], + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", + "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.61.1", + "@rollup/rollup-android-arm64": "4.61.1", + "@rollup/rollup-darwin-arm64": "4.61.1", + "@rollup/rollup-darwin-x64": "4.61.1", + "@rollup/rollup-freebsd-arm64": "4.61.1", + "@rollup/rollup-freebsd-x64": "4.61.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", + "@rollup/rollup-linux-arm-musleabihf": "4.61.1", + "@rollup/rollup-linux-arm64-gnu": "4.61.1", + "@rollup/rollup-linux-arm64-musl": "4.61.1", + "@rollup/rollup-linux-loong64-gnu": "4.61.1", + "@rollup/rollup-linux-loong64-musl": "4.61.1", + "@rollup/rollup-linux-ppc64-gnu": "4.61.1", + "@rollup/rollup-linux-ppc64-musl": "4.61.1", + "@rollup/rollup-linux-riscv64-gnu": "4.61.1", + "@rollup/rollup-linux-riscv64-musl": "4.61.1", + "@rollup/rollup-linux-s390x-gnu": "4.61.1", + "@rollup/rollup-linux-x64-gnu": "4.61.1", + "@rollup/rollup-linux-x64-musl": "4.61.1", + "@rollup/rollup-openbsd-x64": "4.61.1", + "@rollup/rollup-openharmony-arm64": "4.61.1", + "@rollup/rollup-win32-arm64-msvc": "4.61.1", + "@rollup/rollup-win32-ia32-msvc": "4.61.1", + "@rollup/rollup-win32-x64-gnu": "4.61.1", + "@rollup/rollup-win32-x64-msvc": "4.61.1", + "fsevents": "~2.3.2" + } + }, + "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==" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "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==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..2208be9 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,23 @@ +{ + "name": "showdown-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "motion": "^12.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "~5.7.2", + "vite": "^6.0.0" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..ccc29da --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,57 @@ +import { usePath } from "./lib/router"; +import { Home } from "./pages/Home"; +import { Room } from "./pages/Room"; +import { TvRoom } from "./pages/Tv"; + +export default function App() { + const path = usePath(); + const roomMatch = path.match(/^\/room\/([\w-]+?)(\/tv)?\/?$/); + return ( + <> + + {roomMatch ? ( + roomMatch[2] ? ( + + ) : ( + + ) + ) : ( + + )} + + ); +} + +const SUITS = [ + { ch: "♠", x: 6, y: 16, s: 90, d: 7, delay: 0 }, + { ch: "♥", x: 86, y: 12, s: 70, d: 9, delay: 1.2 }, + { ch: "♦", x: 12, y: 74, s: 80, d: 8, delay: 0.6 }, + { ch: "♣", x: 88, y: 70, s: 100, d: 10, delay: 2 }, + { ch: "★", x: 48, y: 88, s: 60, d: 6.5, delay: 1.6 }, + { ch: "♠", x: 70, y: 42, s: 50, d: 11, delay: 0.3 }, + { ch: "♥", x: 26, y: 40, s: 45, d: 9.5, delay: 2.4 }, +]; + +function BgScene() { + return ( +
+
+ {SUITS.map((s, i) => ( + + {s.ch} + + ))} +
+
+ ); +} diff --git a/frontend/src/components/CardHand.tsx b/frontend/src/components/CardHand.tsx new file mode 100644 index 0000000..783d9a7 --- /dev/null +++ b/frontend/src/components/CardHand.tsx @@ -0,0 +1,66 @@ +import { useEffect, useState } from "react"; +import { motion } from "motion/react"; +import { CardFace } from "./Cards"; + +interface HandProps { + deck: string[]; + myVote: string | null; + locked: boolean; + onPick(value: string): void; +} + +function useMediaQuery(query: string): boolean { + const [match, setMatch] = useState(() => matchMedia(query).matches); + useEffect(() => { + const mq = matchMedia(query); + const onChange = () => setMatch(mq.matches); + mq.addEventListener("change", onChange); + return () => mq.removeEventListener("change", onChange); + }, [query]); + return match; +} + +export function CardHand({ deck, myVote, locked, onPick }: HandProps) { + // Desktop gets the overlapping fan; phones get a flat wrapped grid with + // full-size tap targets (layout switch lives in the matching media query). + const fan = useMediaQuery("(min-width: 701px)"); + const n = deck.length; + return ( +
+
+ {deck.map((value, i) => { + const rot = fan ? (i - (n - 1) / 2) * 3 : 0; + const selected = value === myVote; + return ( + + onPick(value)} + aria-pressed={selected} + aria-label={`Vote ${value}`} + > + + + + ); + })} +
+
+ ); +} diff --git a/frontend/src/components/Cards.tsx b/frontend/src/components/Cards.tsx new file mode 100644 index 0000000..737f68b --- /dev/null +++ b/frontend/src/components/Cards.tsx @@ -0,0 +1,56 @@ +import { motion } from "motion/react"; + +export type CardSize = "lg" | "sm" | "xs"; + +export function CardFace({ + value, + size, + selected = false, +}: { + value: string; + size: CardSize; + selected?: boolean; +}) { + const long = [...value].length >= 3; + return ( +
+ {value} + + ★ + + {value} + {value} +
+ ); +} + +export function CardBack({ size }: { size: CardSize }) { + return ( +
+ + ★ + +
+ ); +} + +/** A seat-sized card that flips face-up when the round is revealed. */ +export function FlipCard({ revealed, value, delay }: { revealed: boolean; value: string; delay: number }) { + return ( +
+ +
+ +
+
+ +
+
+
+ ); +} diff --git a/frontend/src/components/Confetti.tsx b/frontend/src/components/Confetti.tsx new file mode 100644 index 0000000..e832364 --- /dev/null +++ b/frontend/src/components/Confetti.tsx @@ -0,0 +1,63 @@ +import { useMemo } from "react"; +import { motion } from "motion/react"; + +const COLORS = ["#ffc233", "#ff5c4d", "#fff4dc", "#3ddc97", "#58c7f3"]; + +interface Piece { + id: number; + left: number; + delay: number; + duration: number; + color: string; + size: number; + rotate: number; + drift: number; + star: boolean; +} + +export function Confetti({ count = 90 }: { count?: number }) { + const pieces = useMemo( + () => + Array.from({ length: count }, (_, i) => ({ + id: i, + left: Math.random() * 100, + delay: Math.random() * 0.5, + duration: 2.4 + Math.random() * 1.8, + color: COLORS[i % COLORS.length], + size: 7 + Math.random() * 8, + rotate: Math.random() * 720 - 360, + drift: 20 + Math.random() * 50, + star: Math.random() < 0.14, + })), + [count], + ); + + return ( +
+ {pieces.map((p) => ( + + {p.star ? "★" : ""} + + ))} +
+ ); +} diff --git a/frontend/src/components/DeckModal.tsx b/frontend/src/components/DeckModal.tsx new file mode 100644 index 0000000..9c6d7b9 --- /dev/null +++ b/frontend/src/components/DeckModal.tsx @@ -0,0 +1,75 @@ +import { useMemo, useState } from "react"; +import { motion } from "motion/react"; +import { DECK_PRESETS, parseCustomDeck } from "../lib/decks"; +import { CardFace } from "./Cards"; + +interface DeckModalProps { + current: string[]; + onSave(cards: string[]): void; + onClose(): void; +} + +export function DeckModal({ current, onSave, onClose }: DeckModalProps) { + const [raw, setRaw] = useState(current.join(", ")); + const parsed = useMemo(() => parseCustomDeck(raw), [raw]); + + return ( + + e.stopPropagation()} + > +

🃏 Pick your deck

+
+ {DECK_PRESETS.map((d) => ( + + ))} +
+ +
+ {(parsed ?? []).map((v) => ( + + ))} +
+

Changing the deck starts a fresh round for everyone.

+
+ + +
+
+
+ ); +} + +function sameDeck(a: string[], b: string[] | null): boolean { + return b !== null && a.length === b.length && a.every((v, i) => v === b[i]); +} diff --git a/frontend/src/components/Logo.tsx b/frontend/src/components/Logo.tsx new file mode 100644 index 0000000..fc2976c --- /dev/null +++ b/frontend/src/components/Logo.tsx @@ -0,0 +1,26 @@ +import { motion } from "motion/react"; + +const LETTERS = [..."SHOWDOWN"]; +const TILTS = [-5, 3, -2, 4, -3, 2, -4, 5]; + +export function Logo() { + return ( +
+ + ★ + + {LETTERS.map((ch, i) => ( + + {ch} + + ))} +
+ ); +} diff --git a/frontend/src/components/PlayerSeat.tsx b/frontend/src/components/PlayerSeat.tsx new file mode 100644 index 0000000..b66e91c --- /dev/null +++ b/frontend/src/components/PlayerSeat.tsx @@ -0,0 +1,52 @@ +import { motion } from "motion/react"; +import type { Player } from "../types"; +import { FlipCard } from "./Cards"; + +interface SeatProps { + player: Player; + isMe: boolean; + revealed: boolean; + /** Seat position, in percent of the table zone. */ + x: number; + y: number; + /** Top-half seats render their card below the avatar, toward the felt. */ + flip: boolean; + flipDelay: number; +} + +export function PlayerSeat({ player, isMe, revealed, x, y, flip, flipDelay }: SeatProps) { + const left = `${x}%`; + const top = `${y}%`; + return ( + +
+ {player.voted ? ( +
+ +
+ ) : ( +
+ {revealed ? ( + + ) : ( + + + + + + )} +
+ )} +
+
{player.emoji}
+
{isMe ? "you" : player.name}
+
+ ); +} diff --git a/frontend/src/components/ProfileForm.tsx b/frontend/src/components/ProfileForm.tsx new file mode 100644 index 0000000..c3feeb9 --- /dev/null +++ b/frontend/src/components/ProfileForm.tsx @@ -0,0 +1,65 @@ +import { useState } from "react"; +import type { ReactNode } from "react"; +import type { Profile } from "../types"; + +const AVATARS = ["🤠", "🐴", "🌵", "🦅", "🐺", "🦊", "🐻", "🐍", "⭐", "🦬"]; + +interface ProfileFormProps { + title: string; + cta: string; + initial?: Profile | null; + onSubmit(profile: Profile): void; + /** Extra fields (e.g. the deck picker on the home page). */ + children?: ReactNode; +} + +export function ProfileForm({ title, cta, initial, onSubmit, children }: ProfileFormProps) { + const [name, setName] = useState(initial?.name ?? ""); + const [emoji, setEmoji] = useState( + () => initial?.emoji ?? AVATARS[Math.floor(Math.random() * AVATARS.length)], + ); + + return ( +
{ + e.preventDefault(); + const trimmed = name.trim().slice(0, 16); + if (trimmed) onSubmit({ name: trimmed, emoji }); + }} + > +

{title}

+ +
+ Pick your face +
+ {AVATARS.map((a) => ( + + ))} +
+
+ {children} + +
+ ); +} diff --git a/frontend/src/components/Results.tsx b/frontend/src/components/Results.tsx new file mode 100644 index 0000000..f2fb06b --- /dev/null +++ b/frontend/src/components/Results.tsx @@ -0,0 +1,70 @@ +import { motion } from "motion/react"; +import type { Variants } from "motion/react"; +import type { RoomState } from "../types"; +import { CardFace } from "./Cards"; + +interface ResultsProps { + state: RoomState; + consensus: boolean; + /** Omitted in spectator (TV) mode — the button is hidden. */ + onReset?(): void; +} + +const pop: Variants = { + hidden: { opacity: 0, scale: 0.5, y: 10 }, + show: { opacity: 1, scale: 1, y: 0, transition: { type: "spring", stiffness: 400, damping: 20 } }, +}; + +export function Results({ state, consensus, onReset }: ResultsProps) { + const votes = state.players.map((p) => p.vote).filter((v): v is string => v !== null); + const numeric = votes.map(Number).filter((x) => Number.isFinite(x)); + const avg = numeric.length > 0 ? Math.round((numeric.reduce((a, b) => a + b, 0) / numeric.length) * 10) / 10 : null; + + const dist = (() => { + const counts = new Map(); + for (const v of votes) counts.set(v, (counts.get(v) ?? 0) + 1); + return [...counts.entries()].sort( + (a, b) => b[1] - a[1] || state.deck.indexOf(a[0]) - state.deck.indexOf(b[0]), + ); + })(); + + return ( + + {consensus && ( + + 🤝 Consensus! + + )} + {avg !== null ? ( + + average + {avg} + + ) : ( + dist.length > 0 && ( + + the call + {dist[0][0]} + + ) + )} + + {dist.map(([value, count]) => ( + + ×{count} + + ))} + + {onReset && ( + + 🔄 New round + + )} + + ); +} diff --git a/frontend/src/components/Table.tsx b/frontend/src/components/Table.tsx new file mode 100644 index 0000000..99b6135 --- /dev/null +++ b/frontend/src/components/Table.tsx @@ -0,0 +1,113 @@ +import { useMemo } from "react"; +import { AnimatePresence, motion } from "motion/react"; +import type { RoomState } from "../types"; +import { PlayerSeat } from "./PlayerSeat"; +import { Results } from "./Results"; + +interface TableProps { + state: RoomState; + playerId: string; + consensus: boolean; + copied?: boolean; + /** View-only (TV display): no reveal/reset/invite controls. */ + spectator?: boolean; + onReveal?(): void; + onReset?(): void; + onInvite?(): void; +} + +export function Table({ state, playerId, consensus, copied, spectator, onReveal, onReset, onInvite }: TableProps) { + // Rotate the seating order so the local player always sits at the bottom. + const ordered = useMemo(() => { + const idx = state.players.findIndex((p) => p.id === playerId); + if (idx <= 0) return state.players; + return [...state.players.slice(idx), ...state.players.slice(0, idx)]; + }, [state.players, playerId]); + + // TV seats are much larger, so they sit on a tighter ring to stay on screen. + const rx = spectator ? 41 : 44; + const ry = spectator ? 38 : 43; + const n = Math.max(ordered.length, 1); + const seats = ordered.map((player, i) => { + const angle = (Math.PI * (90 + (i * 360) / n)) / 180; + const sin = Math.sin(angle); + return { + player, + x: 50 + rx * Math.cos(angle), + y: 50 + ry * sin, + flip: sin < -0.25, + }; + }); + + const votedCount = state.players.filter((p) => p.voted).length; + const allVoted = state.players.length > 0 && votedCount === state.players.length; + const lonely = spectator ? state.players.length === 0 : state.players.length <= 1; + const joinUrl = `${location.host}/room/${state.roomId}`; + + return ( +
+
+
+ {state.revealed ? ( + + ) : lonely ? ( + spectator ? ( + <> +
Waitin’ for the posse…
+
join at {joinUrl}
+ + ) : ( + <> +
It’s lonely out here, partner
+
Rustle up a posse with the invite link
+ + + ) + ) : ( + <> + + {votedCount} + /{state.players.length} + +
+ {votedCount === 0 ? "Place your bets!" : allVoted ? "Everyone’s in!" : "Waitin’ on the rest…"} +
+ {!spectator && ( + + )} + + )} +
+
+ + {seats.map((s, i) => ( + + ))} + +
+ ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..4ec2098 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,1308 @@ +/* ================================================================ + SHOWDOWN — wild-west toy poker table + felt green · cream stickers · ink outlines · coral & gold + ================================================================ */ + +:root { + --felt-950: #07211c; + --felt-900: #0b2e27; + --felt-700: #14443a; + --felt-600: #1a5547; + --rim: #7c4a23; + --rim-dark: #5d3517; + --cream: #fff4dc; + --cream-dim: #f3e4c2; + --paper: #fffdf6; + --ink: #2b1d12; + --coral: #ff5c4d; + --coral-dark: #d93f31; + --gold: #ffc233; + --gold-dark: #e0a014; + --mint: #3ddc97; + --sky: #58c7f3; + + --font-display: "Lilita One", "Arial Black", sans-serif; + --font-body: "Nunito", system-ui, sans-serif; + + --card-lg-w: clamp(64px, 8.5vw, 86px); + --card-sm-w: clamp(42px, 9vw, 50px); + --card-xs-w: 28px; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html { + height: 100%; +} + +body { + font-family: var(--font-body); + color: var(--cream); + background: var(--felt-900); + overflow-x: clip; + -webkit-font-smoothing: antialiased; + -webkit-tap-highlight-color: transparent; +} + +button { + font-family: inherit; + color: inherit; +} + +/* ---------------------------------------------------------------- + Background scene + ---------------------------------------------------------------- */ + +.bg { + position: fixed; + inset: 0; + z-index: -10; + background: radial-gradient(120% 90% at 50% 0%, var(--felt-700) 0%, var(--felt-900) 48%, var(--felt-950) 100%); +} + +.bg::after { + content: ""; + position: absolute; + inset: 0; + background-image: radial-gradient(rgba(255, 244, 220, 0.05) 1.2px, transparent 1.3px); + background-size: 26px 26px; +} + +.bg__spotlight { + position: absolute; + inset: 0; + background: radial-gradient(60% 50% at 50% 38%, rgba(255, 232, 170, 0.1), transparent 70%); +} + +.bg__suit { + position: absolute; + color: rgba(255, 244, 220, 0.05); + line-height: 1; + user-select: none; + animation: floaty ease-in-out infinite alternate; +} + +.bg__noise { + position: absolute; + inset: 0; + background-image: url("data:image/svg+xml;utf8,"); +} + +/* ---------------------------------------------------------------- + Buttons — chunky stickers + ---------------------------------------------------------------- */ + +.btn { + font-family: var(--font-display); + font-size: 1rem; + letter-spacing: 0.5px; + color: var(--ink); + background: var(--cream); + border: 3px solid var(--ink); + border-radius: 14px; + padding: 10px 22px; + cursor: pointer; + box-shadow: 0 4px 0 var(--ink); + transition: transform 0.08s ease, box-shadow 0.08s ease, background 0.15s ease; + touch-action: manipulation; + user-select: none; +} + +.btn:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 6px 0 var(--ink); +} + +.btn:active:not(:disabled) { + transform: translateY(3px); + box-shadow: 0 1px 0 var(--ink); +} + +.btn--gold { + background: var(--gold); +} + +.btn--coral { + background: var(--coral); + color: var(--cream); + text-shadow: 0 2px 0 rgba(0, 0, 0, 0.25); +} + +.btn--ghost { + background: rgba(0, 0, 0, 0.25); + color: var(--cream); + border-color: rgba(255, 244, 220, 0.35); + box-shadow: 0 4px 0 rgba(0, 0, 0, 0.35); +} + +.btn--big { + font-size: 1.35rem; + padding: 14px 32px; + border-radius: 18px; +} + +.btn--small { + font-size: 0.85rem; + padding: 6px 12px; + border-radius: 10px; + border-width: 2.5px; + box-shadow: 0 3px 0 rgba(0, 0, 0, 0.35); +} + +.btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.btn--party { + animation: party-pulse 0.9s ease-in-out infinite; +} + +/* ---------------------------------------------------------------- + Form bits — wanted-poster style + ---------------------------------------------------------------- */ + +.poster { + position: relative; + width: min(420px, 92vw); + background: var(--cream); + border: 4px solid var(--ink); + border-radius: 20px; + box-shadow: 10px 10px 0 rgba(0, 0, 0, 0.4); + padding: clamp(18px, 4vw, 28px); + rotate: -1deg; + display: flex; + flex-direction: column; + gap: 16px; + color: var(--ink); +} + +.poster::after { + content: ""; + position: absolute; + inset: 8px; + border: 2px dashed rgba(43, 29, 18, 0.22); + border-radius: 13px; + pointer-events: none; +} + +.poster__title { + font-family: var(--font-display); + font-size: clamp(1.4rem, 4vw, 1.8rem); + text-align: center; + letter-spacing: 1px; +} + +.poster__cta { + margin-top: 4px; +} + +.field { + display: flex; + flex-direction: column; + gap: 7px; +} + +.field__label { + font-weight: 900; + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 1.5px; + color: rgba(43, 29, 18, 0.6); +} + +.input { + font-family: var(--font-body); + font-weight: 800; + font-size: 1.05rem; + padding: 10px 14px; + border: 3px solid var(--ink); + border-radius: 12px; + background: var(--paper); + color: var(--ink); + outline: none; + width: 100%; +} + +.input:focus { + box-shadow: 0 0 0 3px var(--gold); +} + +.emoji-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 8px; +} + +.emoji-opt { + font-size: 1.5rem; + aspect-ratio: 1; + display: grid; + place-items: center; + background: var(--paper); + border: 3px solid rgba(43, 29, 18, 0.18); + border-radius: 12px; + cursor: pointer; + transition: transform 0.12s ease, background 0.12s ease; +} + +.emoji-opt:hover { + transform: scale(1.12) rotate(-4deg); +} + +.emoji-opt--on { + border-color: var(--ink); + background: var(--gold); + box-shadow: 0 3px 0 var(--ink); +} + +.deck-pills { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.pill { + font-weight: 900; + font-size: 0.85rem; + padding: 7px 13px; + border-radius: 99px; + border: 2.5px solid rgba(43, 29, 18, 0.25); + background: var(--paper); + cursor: pointer; + transition: transform 0.1s ease; +} + +.pill:hover { + transform: translateY(-1px); +} + +.pill--on { + background: var(--coral); + color: var(--cream); + border-color: var(--ink); + box-shadow: 0 3px 0 var(--ink); +} + +.deck-preview { + display: flex; + gap: 4px; + flex-wrap: wrap; + min-height: calc(var(--card-xs-w) * 1.4); +} + +/* ---------------------------------------------------------------- + Home page + ---------------------------------------------------------------- */ + +.home { + min-height: 100vh; + min-height: 100dvh; + display: flex; + flex-direction: column; + align-items: center; + gap: clamp(22px, 4vh, 44px); + padding: clamp(24px, 7vh, 64px) 16px 32px; +} + +.hero { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 14px; +} + +.hero-cards { + position: absolute; + inset: 0; + z-index: 0; +} + +.hero-card { + position: absolute; + top: -26px; + filter: drop-shadow(0 10px 14px rgba(0, 0, 0, 0.35)); +} + +.hero-card--l { + left: -7%; +} + +.hero-card--r { + right: -7%; +} + +.logo-big { + position: relative; + z-index: 1; + display: flex; + font-family: var(--font-display); + font-size: clamp(2.7rem, 11vw, 6rem); + line-height: 1.1; + color: var(--cream); +} + +.logo-big__letter { + display: inline-block; + text-shadow: 0.045em 0.045em 0 var(--ink), 0.1em 0.1em 0 rgba(0, 0, 0, 0.3); +} + +.logo-big__star { + position: absolute; + left: 50%; + top: 50%; + translate: -50% -54%; + z-index: -1; + font-size: 2em; + color: var(--gold); + opacity: 0.25; + animation: spin-slow 24s linear infinite; +} + +.tagline { + position: relative; + z-index: 1; + font-weight: 800; + color: rgba(255, 244, 220, 0.85); + font-size: clamp(0.95rem, 2.5vw, 1.2rem); + text-align: center; +} + +.footnote { + margin-top: auto; + color: rgba(255, 244, 220, 0.5); + font-weight: 700; + font-size: 0.8rem; + text-align: center; + max-width: 420px; +} + +/* ---------------------------------------------------------------- + Join gate + ---------------------------------------------------------------- */ + +.gate { + min-height: 100vh; + min-height: 100dvh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 30px; + padding: 24px 16px; +} + +.gate .logo-big { + font-size: clamp(2rem, 8vw, 3.6rem); +} + +/* ---------------------------------------------------------------- + Room layout & top bar + ---------------------------------------------------------------- */ + +.room { + height: 100vh; + height: 100dvh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 10px clamp(10px, 3vw, 22px); + flex: 0 0 auto; +} + +.topbar__logo { + font-family: var(--font-display); + color: var(--cream); + text-decoration: none; + display: flex; + gap: 7px; + align-items: center; + font-size: 1.05rem; + letter-spacing: 1.5px; + text-shadow: 0 2px 0 rgba(0, 0, 0, 0.35); +} + +.topbar__star { + color: var(--gold); + display: inline-block; + animation: spin-slow 14s linear infinite; +} + +.room-pill { + font-weight: 800; + font-size: 0.9rem; + background: rgba(0, 0, 0, 0.3); + color: var(--cream); + border: 2px solid rgba(255, 244, 220, 0.3); + border-radius: 99px; + padding: 7px 14px; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease, transform 0.1s ease; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 45vw; +} + +.room-pill:hover { + transform: scale(1.04); +} + +.room-pill--copied { + background: var(--mint); + color: var(--ink); + border-color: var(--ink); +} + +.topbar__right { + display: flex; + align-items: center; + gap: 10px; +} + +.demo-chip { + font-weight: 900; + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 1px; + background: var(--gold); + color: var(--ink); + padding: 3px 8px; + border-radius: 99px; + border: 2px solid var(--ink); +} + +.status-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex: 0 0 auto; +} + +.status-dot--online { + background: var(--mint); +} + +.status-dot--demo { + background: var(--gold); +} + +.status-dot--offline { + background: var(--coral); + animation: blink 1.1s ease-in-out infinite; +} + +.status-dot--connecting { + background: var(--cream-dim); + animation: blink 1.1s ease-in-out infinite; +} + +.toast { + position: fixed; + top: 62px; + left: 50%; + translate: -50%; + background: var(--coral); + color: var(--cream); + border: 3px solid var(--ink); + border-radius: 12px; + padding: 8px 16px; + font-weight: 900; + font-size: 0.9rem; + box-shadow: 0 4px 0 var(--ink); + z-index: 60; + animation: drop-in 0.3s ease; + white-space: nowrap; +} + +.loading { + flex: 1; + display: grid; + place-items: center; + font-family: var(--font-display); + font-size: 1.2rem; + color: rgba(255, 244, 220, 0.8); + animation: blink 1.6s ease-in-out infinite; +} + +/* ---------------------------------------------------------------- + The table + ---------------------------------------------------------------- */ + +.table-zone { + position: relative; + flex: 1; + min-height: 0; + width: min(100% - 16px, 1000px); + margin: 0 auto; +} + +.table-felt { + position: absolute; + inset: 13% 15%; + border-radius: 50%; + background: radial-gradient(70% 70% at 50% 40%, var(--felt-600) 0%, var(--felt-700) 55%, #103a31 100%); + box-shadow: + 0 0 0 10px var(--rim), + 0 0 0 15px var(--rim-dark), + 0 24px 50px rgba(0, 0, 0, 0.5), + inset 0 0 70px rgba(0, 0, 0, 0.35); +} + +.table-felt::after { + content: ""; + position: absolute; + inset: 16px; + border: 3px dashed rgba(255, 244, 220, 0.14); + border-radius: 50%; + pointer-events: none; +} + +.table-center { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + text-align: center; + padding: 10%; +} + +.center-title { + font-family: var(--font-display); + color: var(--cream); + font-size: clamp(1rem, 3vw, 1.6rem); + text-shadow: 0 3px 0 rgba(0, 0, 0, 0.3); +} + +.center-sub { + color: rgba(255, 244, 220, 0.75); + font-weight: 700; + font-size: clamp(0.78rem, 2vw, 0.95rem); +} + +.center-sub--url { + font-family: var(--font-display); + letter-spacing: 0.5px; + color: var(--gold); + word-break: break-all; +} + +.center-count { + font-family: var(--font-display); + font-size: clamp(2rem, 6vw, 3.2rem); + line-height: 1; + color: var(--gold); + text-shadow: 0 4px 0 rgba(0, 0, 0, 0.35); +} + +.center-count__total { + font-size: 0.55em; + color: rgba(255, 244, 220, 0.6); +} + +/* ---------------------------------------------------------------- + Seats + ---------------------------------------------------------------- */ + +.seat { + position: absolute; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + width: max-content; +} + +.seat--flip { + flex-direction: column-reverse; +} + +.seat__avatar { + width: clamp(40px, 8vw, 54px); + aspect-ratio: 1; + border-radius: 50%; + background: var(--cream); + border: 3px solid var(--ink); + display: grid; + place-items: center; + font-size: clamp(20px, 4vw, 28px); + box-shadow: 0 4px 0 rgba(0, 0, 0, 0.35); +} + +.seat--me .seat__avatar { + box-shadow: 0 0 0 3px var(--gold), 0 4px 0 rgba(0, 0, 0, 0.35); +} + +.seat__name { + font-weight: 800; + font-size: clamp(10px, 2vw, 12.5px); + color: var(--cream); + background: rgba(0, 0, 0, 0.35); + padding: 2px 9px; + border-radius: 99px; + max-width: 96px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.seat--me .seat__name { + background: var(--gold); + color: var(--ink); +} + +.seat__cardspot { + height: calc(var(--card-sm-w) * 1.4); + display: flex; + align-items: center; + justify-content: center; +} + +.seat__waiting { + width: var(--card-sm-w); + height: 100%; + border: 2.5px dashed rgba(255, 244, 220, 0.35); + border-radius: 9px; + display: grid; + place-items: center; +} + +.seat__novote { + font-family: var(--font-display); + color: rgba(255, 244, 220, 0.45); +} + +.dots { + display: flex; + gap: 4px; +} + +.dots span { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--cream); + opacity: 0.8; + animation: dot-bounce 1s ease-in-out infinite; +} + +.dots span:nth-child(2) { + animation-delay: 0.15s; +} + +.dots span:nth-child(3) { + animation-delay: 0.3s; +} + +.wobble { + animation: wobble 2.6s ease-in-out infinite; +} + +/* ---------------------------------------------------------------- + Cards + ---------------------------------------------------------------- */ + +.cardface { + position: relative; + aspect-ratio: 5 / 7; + background: var(--cream); + border: 3px solid var(--ink); + border-radius: 12px; + box-shadow: 0 4px 0 rgba(0, 0, 0, 0.35); + display: flex; + align-items: center; + justify-content: center; + font-family: var(--font-display); + color: var(--ink); + user-select: none; + overflow: hidden; +} + +.cardface--lg { + width: var(--card-lg-w); + font-size: calc(var(--card-lg-w) * 0.34); +} + +.cardface--sm { + width: var(--card-sm-w); + font-size: calc(var(--card-sm-w) * 0.36); + border-width: 2.5px; + border-radius: 9px; +} + +.cardface--xs { + width: var(--card-xs-w); + font-size: calc(var(--card-xs-w) * 0.4); + border-width: 2px; + border-radius: 6px; + box-shadow: 0 2px 0 rgba(0, 0, 0, 0.3); +} + +.cardface__value { + position: relative; + z-index: 1; + font-size: 1.5em; +} + +.cardface__value--long { + font-size: 0.95em; +} + +.cardface__star { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 2.4em; + color: var(--gold); + opacity: 0.32; +} + +.cardface__corner { + position: absolute; + font-size: 0.55em; + line-height: 1; +} + +.cardface__corner--tl { + top: 7%; + left: 11%; +} + +.cardface__corner--br { + bottom: 7%; + right: 11%; + transform: rotate(180deg); +} + +.cardface--sm .cardface__corner, +.cardface--xs .cardface__corner { + display: none; +} + +.cardface--selected { + background: #fff9e8; + border-color: var(--gold-dark); + box-shadow: 0 0 0 3px var(--gold), 0 6px 0 rgba(0, 0, 0, 0.35); +} + +.cardface--selected .cardface__star { + opacity: 0.55; +} + +.cardback { + aspect-ratio: 5 / 7; + border-radius: 12px; + border: 3px solid var(--ink); + background-color: var(--coral); + background-image: + repeating-linear-gradient(45deg, rgba(255, 244, 220, 0.22) 0 2px, transparent 2px 7px), + repeating-linear-gradient(-45deg, rgba(255, 244, 220, 0.22) 0 2px, transparent 2px 7px); + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 4px 0 rgba(0, 0, 0, 0.35); + user-select: none; +} + +.cardback--lg { + width: var(--card-lg-w); + font-size: calc(var(--card-lg-w) * 0.3); +} + +.cardback--sm { + width: var(--card-sm-w); + font-size: calc(var(--card-sm-w) * 0.3); + border-width: 2.5px; + border-radius: 9px; +} + +.cardback--xs { + width: var(--card-xs-w); + font-size: 9px; + border-width: 2px; + border-radius: 6px; +} + +.cardback__star { + display: grid; + place-items: center; + width: 2em; + height: 2em; + background: var(--coral-dark); + border: 2px solid var(--cream); + border-radius: 50%; + color: var(--cream); + font-size: 1em; +} + +/* Flip rig for seat cards */ + +.flip { + width: var(--card-sm-w); + aspect-ratio: 5 / 7; + perspective: 600px; +} + +.flip__inner { + position: relative; + width: 100%; + height: 100%; + transform-style: preserve-3d; +} + +.flip__face { + position: absolute; + inset: 0; + -webkit-backface-visibility: hidden; + backface-visibility: hidden; +} + +.flip__face--front { + transform: rotateY(180deg); +} + +.flip .cardface, +.flip .cardback { + width: 100%; + height: 100%; +} + +/* ---------------------------------------------------------------- + Hand + ---------------------------------------------------------------- */ + +.hand { + flex: 0 0 auto; + display: flex; + justify-content: center; + padding: 0 8px calc(10px + env(safe-area-inset-bottom)); +} + +.hand__scroller { + display: flex; + max-width: 100%; + overflow-x: auto; + overscroll-behavior-x: contain; + padding: 34px 26px 8px; + scrollbar-width: none; +} + +.hand__scroller::-webkit-scrollbar { + display: none; +} + +.hand__slot { + position: relative; + flex: 0 0 auto; +} + +.hand__slot + .hand__slot { + margin-left: -12px; +} + +.hand__card { + display: block; + background: none; + border: none; + padding: 0; + cursor: pointer; + touch-action: manipulation; +} + +.hand--locked .hand__scroller { + opacity: 0.55; + pointer-events: none; + filter: saturate(0.7); + transition: opacity 0.3s ease; +} + +/* ---------------------------------------------------------------- + Results + ---------------------------------------------------------------- */ + +.results { + display: flex; + flex-direction: column; + align-items: center; + gap: 9px; +} + +.consensus-banner { + font-family: var(--font-display); + background: var(--mint); + color: var(--ink); + border: 3px solid var(--ink); + border-radius: 14px; + padding: 6px 16px; + box-shadow: 0 4px 0 var(--ink); + rotate: -2deg; + font-size: clamp(0.95rem, 3vw, 1.35rem); +} + +.results__avg-wrap { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} + +.results__avg-label { + font-weight: 900; + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 2.5px; + color: rgba(255, 244, 220, 0.65); +} + +.results__avg { + font-family: var(--font-display); + font-size: clamp(2.4rem, 8vw, 3.8rem); + line-height: 1; + color: var(--cream); + text-shadow: 0.04em 0.04em 0 var(--gold-dark), 0.09em 0.09em 0 rgba(0, 0, 0, 0.35); +} + +.dist { + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: center; +} + +.dist__chip { + display: flex; + align-items: center; + gap: 5px; + background: rgba(0, 0, 0, 0.28); + border-radius: 10px; + padding: 4px 8px 4px 4px; + color: var(--cream); + font-weight: 900; + font-size: 0.85rem; +} + +/* ---------------------------------------------------------------- + Modal + ---------------------------------------------------------------- */ + +.overlay { + position: fixed; + inset: 0; + background: rgba(7, 33, 28, 0.7); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 50; + padding: 16px; +} + +.modal { + width: min(480px, 100%); + max-height: 86dvh; + overflow-y: auto; + background: var(--cream); + border: 4px solid var(--ink); + border-radius: 20px; + box-shadow: 10px 10px 0 rgba(0, 0, 0, 0.35); + padding: 22px; + color: var(--ink); + display: flex; + flex-direction: column; + gap: 14px; +} + +.modal__title { + font-family: var(--font-display); + font-size: 1.5rem; +} + +.modal__hint { + font-weight: 700; + font-size: 0.8rem; + color: rgba(43, 29, 18, 0.6); +} + +.modal__actions { + display: flex; + gap: 10px; + justify-content: flex-end; +} + +/* ---------------------------------------------------------------- + TV / spectator display + ---------------------------------------------------------------- */ + +.tv { + --card-sm-w: clamp(54px, 6vw, 88px); + --card-xs-w: 36px; + height: 100vh; + height: 100dvh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.tvbar { + position: relative; + z-index: 5; + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 12px; + padding: 14px clamp(14px, 3vw, 28px) 4px; +} + +.tvbar__logo { + font-family: var(--font-display); + display: flex; + align-items: center; + gap: 8px; + font-size: 1.2rem; + letter-spacing: 1.5px; + text-shadow: 0 2px 0 rgba(0, 0, 0, 0.35); +} + +.tvbar__room { + text-align: center; +} + +.tvbar__code { + font-family: var(--font-display); + font-size: clamp(1.5rem, 3.5vw, 2.4rem); + line-height: 1.05; + color: var(--gold); + text-shadow: 0 3px 0 rgba(0, 0, 0, 0.35); +} + +.tvbar__url { + font-weight: 800; + font-size: clamp(0.72rem, 1.4vw, 0.95rem); + color: rgba(255, 244, 220, 0.6); +} + +.tvbar__right { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; +} + +.tv .table-zone { + width: min(100% - 24px, 1500px); + margin: clamp(36px, 6vh, 72px) auto clamp(28px, 5vh, 56px); +} + +.tv .seat__avatar { + width: clamp(52px, 5.5vw, 72px); + font-size: clamp(26px, 3vw, 38px); +} + +.tv .seat__name { + font-size: clamp(12px, 1.4vw, 16px); + max-width: 150px; +} + +.tv .center-title { + font-size: clamp(1.3rem, 3.5vw, 2.4rem); +} + +.tv .center-sub { + font-size: clamp(0.9rem, 1.8vw, 1.2rem); +} + +.tv .center-count { + font-size: clamp(2.6rem, 7vw, 5rem); +} + +.tv .results__avg { + font-size: clamp(3rem, 8vw, 5.5rem); +} + +.tv .consensus-banner { + font-size: clamp(1.2rem, 2.6vw, 2rem); +} + +.tv .dist__chip { + font-size: 1rem; +} + +/* ---------------------------------------------------------------- + Confetti + ---------------------------------------------------------------- */ + +.confetti { + position: fixed; + inset: 0; + pointer-events: none; + z-index: 80; + overflow: hidden; +} + +.confetti__piece { + position: absolute; + top: 0; + border-radius: 2px; + line-height: 1; +} + +/* ---------------------------------------------------------------- + Keyframes + ---------------------------------------------------------------- */ + +@keyframes floaty { + from { + transform: translateY(-12px) rotate(-6deg); + } + to { + transform: translateY(14px) rotate(7deg); + } +} + +@keyframes spin-slow { + to { + transform: rotate(360deg); + } +} + +@keyframes dot-bounce { + 0%, + 60%, + 100% { + transform: translateY(0); + } + 30% { + transform: translateY(-5px); + } +} + +@keyframes wobble { + 0%, + 100% { + transform: rotate(-2.5deg); + } + 50% { + transform: rotate(2.5deg); + } +} + +@keyframes party-pulse { + 0%, + 100% { + transform: scale(1); + } + 50% { + transform: scale(1.06); + } +} + +@keyframes blink { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.35; + } +} + +@keyframes drop-in { + from { + transform: translateY(-16px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +/* ---------------------------------------------------------------- + Responsive tweaks + ---------------------------------------------------------------- */ + +/* Phones: flat wrapped grid of big tap targets instead of the fan. */ +@media (max-width: 700px) { + :root { + --card-lg-w: clamp(60px, 16.5vw, 84px); + --card-sm-w: clamp(44px, 10.5vw, 52px); + } + + .topbar__word, + .btn-label { + display: none; + } + + .table-felt { + inset: 10% 8%; + } + + .table-felt::after { + inset: 10px; + } + + .seat__avatar { + width: clamp(46px, 11.5vw, 54px); + font-size: clamp(22px, 5.5vw, 28px); + } + + .btn--big { + font-size: 1.15rem; + padding: 12px 26px; + } + + .hand { + padding: 0 8px calc(8px + env(safe-area-inset-bottom)); + } + + .hand__scroller { + flex-wrap: wrap; + justify-content: center; + gap: 10px 8px; + padding: 14px 8px 6px; + max-height: 44dvh; + overflow-y: auto; + overflow-x: hidden; + } + + .hand__slot + .hand__slot { + margin-left: 0; + } + + .tvbar { + grid-template-columns: auto 1fr auto; + } + + .tvbar__logo span:last-child { + display: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .bg__suit, + .topbar__star, + .logo-big__star, + .wobble, + .btn--party, + .loading { + animation: none; + } +} diff --git a/frontend/src/lib/connection.ts b/frontend/src/lib/connection.ts new file mode 100644 index 0000000..a092112 --- /dev/null +++ b/frontend/src/lib/connection.ts @@ -0,0 +1,336 @@ +import type { ClientMessage, ConnStatus, Profile, RoomState, ServerMessage } from "../types"; +import { DEFAULT_DECK } from "./decks"; + +export interface RoomConnection { + send(msg: ClientMessage): void; + close(): void; +} + +export interface ConnectionHandlers { + onState(state: RoomState): void; + onStatus(status: ConnStatus): void; +} + +export interface ConnectionOptions { + /** View-only (TV display): receives state but never takes a seat. */ + spectator?: boolean; +} + +/** + * In dev there is no Go backend yet, so the app runs a local mock room with + * bot players. Set VITE_WS=1 to test against a real backend during + * development. Production builds always use the real WebSocket. + */ +export function createConnection( + roomId: string, + playerId: string, + profile: Profile, + handlers: ConnectionHandlers, + options: ConnectionOptions = {}, +): RoomConnection { + const useMock = import.meta.env.DEV && import.meta.env.VITE_WS !== "1"; + if (options.spectator) { + return useMock + ? new MockSpectatorConnection(roomId, handlers) + : new WsConnection(roomId, playerId, profile, handlers, true); + } + return useMock + ? new MockConnection(roomId, playerId, profile, handlers) + : new WsConnection(roomId, playerId, profile, handlers); +} + +/* ------------------------------------------------------------------ */ +/* Real WebSocket client — matches PROTOCOL.md for the Go backend. */ +/* ------------------------------------------------------------------ */ + +class WsConnection implements RoomConnection { + private ws: WebSocket | null = null; + private queue: ClientMessage[] = []; + private closed = false; + private attempts = 0; + + constructor( + private roomId: string, + private playerId: string, + private profile: Profile, + private handlers: ConnectionHandlers, + private spectator = false, + ) { + this.connect(); + } + + private connect(): void { + this.handlers.onStatus("connecting"); + const proto = location.protocol === "https:" ? "wss" : "ws"; + const ws = new WebSocket(`${proto}://${location.host}/ws/room/${encodeURIComponent(this.roomId)}`); + this.ws = ws; + + ws.onopen = () => { + this.attempts = 0; + this.handlers.onStatus("online"); + const hello: ClientMessage = this.spectator + ? { type: "watch" } + : { type: "join", playerId: this.playerId, name: this.profile.name, emoji: this.profile.emoji }; + ws.send(JSON.stringify(hello)); + for (const msg of this.queue.splice(0)) { + ws.send(JSON.stringify(msg)); + } + }; + + ws.onmessage = (ev) => { + try { + const msg = JSON.parse(ev.data) as ServerMessage; + if (msg.type === "state") this.handlers.onState(msg.state); + } catch { + /* malformed frame — ignore */ + } + }; + + ws.onclose = () => { + if (this.closed) return; + this.handlers.onStatus("offline"); + const backoff = Math.min(10_000, 1_000 * 2 ** this.attempts++); + setTimeout(() => { + if (!this.closed) this.connect(); + }, backoff); + }; + } + + send(msg: ClientMessage): void { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(msg)); + } else { + this.queue.push(msg); + } + } + + close(): void { + this.closed = true; + this.ws?.close(); + } +} + +/* ------------------------------------------------------------------ */ +/* Mock room with bot players, for developing the UI without a server. */ +/* ------------------------------------------------------------------ */ + +interface MockPlayer { + id: string; + name: string; + emoji: string; + vote: string | null; + bot: boolean; +} + +const BOTS: Array> = [ + { id: "bot-1", name: "Calamity Jane", emoji: "🐴" }, + { id: "bot-2", name: "Doc Holliday", emoji: "🦅" }, + { id: "bot-3", name: "Wild Bill", emoji: "🌵" }, + { id: "bot-4", name: "Annie Oakley", emoji: "⭐" }, +]; + +const channelName = (roomId: string) => `showdown:room:${roomId}`; + +class MockConnection implements RoomConnection { + private deck = [...DEFAULT_DECK]; + private revealed = false; + private round = 1; + private players: MockPlayer[] = []; + private timers = new Set>(); + /** Index in the deck the bots loosely agree on, re-rolled every round. */ + private botTargetIndex = 0; + private disposed = false; + private revealScheduled = false; + /** Mirrors state to spectator tabs (the TV view) while in demo mode. */ + private channel: BroadcastChannel | null = null; + + constructor( + private roomId: string, + playerId: string, + profile: Profile, + private handlers: ConnectionHandlers, + /** Self-running mode for a standalone TV demo: bots only, auto reveal/reset. */ + private autopilot = false, + ) { + if (!autopilot && typeof BroadcastChannel !== "undefined") { + this.channel = new BroadcastChannel(channelName(roomId)); + this.channel.onmessage = (e) => { + if (e.data?.type === "sync") this.emit(); + }; + } + if (!autopilot) { + this.players.push({ id: playerId, name: profile.name, emoji: profile.emoji, vote: null, bot: false }); + } + this.rollBotTarget(); + handlers.onStatus("demo"); + this.emit(); + const bots = autopilot ? BOTS : BOTS.slice(0, 3); + bots.forEach((bot, i) => { + this.after(1200 + i * 1400, () => { + this.players.push({ ...bot, vote: null, bot: true }); + this.emit(); + this.scheduleBotVote(bot.id); + }); + }); + } + + send(msg: ClientMessage): void { + switch (msg.type) { + case "join": + case "watch": + break; + case "vote": { + if (this.revealed) break; + const me = this.players.find((p) => !p.bot); + if (me) me.vote = msg.value; + break; + } + case "reveal": + if (this.players.some((p) => p.vote !== null)) this.revealed = true; + break; + case "reset": + this.startNewRound(); + break; + case "deck": + this.deck = msg.cards; + this.startNewRound(); + break; + } + this.emit(); + } + + close(): void { + this.disposed = true; + for (const t of this.timers) clearTimeout(t); + this.timers.clear(); + this.channel?.close(); + this.channel = null; + } + + private startNewRound(): void { + this.revealed = false; + this.revealScheduled = false; + this.round += 1; + this.rollBotTarget(); + for (const p of this.players) { + p.vote = null; + if (p.bot) this.scheduleBotVote(p.id); + } + } + + private rollBotTarget(): void { + const numericCount = this.deck.filter((c) => Number.isFinite(Number(c))).length || this.deck.length; + this.botTargetIndex = Math.floor(Math.random() * Math.min(numericCount, this.deck.length)); + } + + private scheduleBotVote(botId: string): void { + this.after(1500 + Math.random() * 4500, () => { + const bot = this.players.find((p) => p.id === botId); + if (!bot || this.revealed || bot.vote !== null) return; + bot.vote = this.pickBotVote(); + this.emit(); + this.maybeAutopilot(); + }); + } + + /** Standalone TV demo loop: reveal once everyone voted, then start over. */ + private maybeAutopilot(): void { + if (!this.autopilot || this.revealed || this.revealScheduled) return; + if (this.players.length < 2 || !this.players.every((p) => p.vote !== null)) return; + this.revealScheduled = true; + this.after(1800, () => { + if (this.revealed) return; + this.revealed = true; + this.emit(); + this.after(6000, () => { + this.startNewRound(); + this.emit(); + }); + }); + } + + private pickBotVote(): string { + const roll = Math.random(); + let idx = this.botTargetIndex; + if (roll > 0.9 && this.deck.includes("?")) return "?"; + if (roll > 0.6) idx += Math.random() > 0.5 ? 1 : -1; + idx = Math.max(0, Math.min(this.deck.length - 1, idx)); + return this.deck[idx]; + } + + private after(ms: number, fn: () => void): void { + const t = setTimeout(() => { + this.timers.delete(t); + if (!this.disposed) fn(); + }, ms); + this.timers.add(t); + } + + private emit(): void { + const state: RoomState = { + roomId: this.roomId, + deck: [...this.deck], + revealed: this.revealed, + round: this.round, + players: this.players.map((p) => ({ + id: p.id, + name: p.name, + emoji: p.emoji, + voted: p.vote !== null, + vote: this.revealed ? p.vote : null, + })), + }; + this.handlers.onState(state); + this.channel?.postMessage({ type: "state", state }); + } +} + +/** + * Spectator in demo mode: mirrors the mock room of an open game tab via + * BroadcastChannel. If no game tab answers, falls back to a self-running + * bots-only demo so the TV is never blank. + */ +class MockSpectatorConnection implements RoomConnection { + private channel: BroadcastChannel | null = null; + private fallback: MockConnection | null = null; + private fallbackTimer: ReturnType | null = null; + private hasHost = false; + + constructor(roomId: string, handlers: ConnectionHandlers) { + handlers.onStatus("connecting"); + if (typeof BroadcastChannel !== "undefined") { + this.channel = new BroadcastChannel(channelName(roomId)); + this.channel.onmessage = (e) => { + if (e.data?.type !== "state") return; + this.hasHost = true; + if (this.fallbackTimer) { + clearTimeout(this.fallbackTimer); + this.fallbackTimer = null; + } + if (this.fallback) { + this.fallback.close(); + this.fallback = null; + } + handlers.onStatus("demo"); + handlers.onState(e.data.state as RoomState); + }; + this.channel.postMessage({ type: "sync" }); + } + this.fallbackTimer = setTimeout(() => { + if (!this.hasHost) { + this.fallback = new MockConnection(roomId, "", { name: "", emoji: "" }, handlers, true); + } + }, 900); + } + + send(): void { + /* view-only */ + } + + close(): void { + if (this.fallbackTimer) clearTimeout(this.fallbackTimer); + this.fallback?.close(); + this.channel?.close(); + this.channel = null; + } +} diff --git a/frontend/src/lib/decks.ts b/frontend/src/lib/decks.ts new file mode 100644 index 0000000..b5568cc --- /dev/null +++ b/frontend/src/lib/decks.ts @@ -0,0 +1,42 @@ +export interface DeckPreset { + id: string; + label: string; + cards: string[]; +} + +export const DECK_PRESETS: DeckPreset[] = [ + { + id: "fibonacci", + label: "Fibonacci", + cards: ["0", "1", "2", "3", "5", "8", "13", "21", "34", "?", "☕"], + }, + { + id: "tshirt", + label: "T-Shirt", + cards: ["XS", "S", "M", "L", "XL", "XXL", "?", "☕"], + }, + { + id: "powers", + label: "Powers of 2", + cards: ["1", "2", "4", "8", "16", "32", "64", "?", "☕"], + }, +]; + +export const DEFAULT_DECK = DECK_PRESETS[0].cards; + +const MAX_CARDS = 15; +const MAX_CARD_LEN = 4; + +/** Parse a comma/space separated custom deck. Returns null if unusable. */ +export function parseCustomDeck(raw: string): string[] | null { + const seen = new Set(); + const cards: string[] = []; + for (const piece of raw.split(/[,\s]+/)) { + const v = piece.trim(); + if (!v || [...v].length > MAX_CARD_LEN || seen.has(v)) continue; + seen.add(v); + cards.push(v); + if (cards.length === MAX_CARDS) break; + } + return cards.length >= 2 ? cards : null; +} diff --git a/frontend/src/lib/game.ts b/frontend/src/lib/game.ts new file mode 100644 index 0000000..67cb127 --- /dev/null +++ b/frontend/src/lib/game.ts @@ -0,0 +1,7 @@ +import type { RoomState } from "../types"; + +export function isConsensus(state: RoomState | null): boolean { + if (!state?.revealed) return false; + const votes = state.players.map((p) => p.vote).filter((v): v is string => v !== null); + return votes.length >= 2 && votes.every((v) => v === votes[0]); +} diff --git a/frontend/src/lib/router.ts b/frontend/src/lib/router.ts new file mode 100644 index 0000000..6833cef --- /dev/null +++ b/frontend/src/lib/router.ts @@ -0,0 +1,16 @@ +import { useEffect, useState } from "react"; + +export function navigate(to: string): void { + history.pushState({}, "", to); + dispatchEvent(new PopStateEvent("popstate")); +} + +export function usePath(): string { + const [path, setPath] = useState(location.pathname); + useEffect(() => { + const onPop = () => setPath(location.pathname); + addEventListener("popstate", onPop); + return () => removeEventListener("popstate", onPop); + }, []); + return path; +} diff --git a/frontend/src/lib/session.ts b/frontend/src/lib/session.ts new file mode 100644 index 0000000..9c4af32 --- /dev/null +++ b/frontend/src/lib/session.ts @@ -0,0 +1,65 @@ +import type { Profile } from "../types"; + +const PROFILE_KEY = "showdown:profile"; +const PLAYER_ID_KEY = "showdown:pid"; +const PENDING_DECK_KEY = "showdown:pendingDeck"; + +export function loadProfile(): Profile | null { + try { + const raw = localStorage.getItem(PROFILE_KEY); + if (!raw) return null; + const p = JSON.parse(raw); + if (typeof p?.name === "string" && typeof p?.emoji === "string" && p.name) { + return { name: p.name, emoji: p.emoji }; + } + } catch { + /* corrupted storage — treat as signed out */ + } + return null; +} + +export function saveProfile(p: Profile): void { + localStorage.setItem(PROFILE_KEY, JSON.stringify(p)); +} + +/** Stable per-browser identity so a refresh re-claims the same seat. */ +export function getPlayerId(): string { + let id = localStorage.getItem(PLAYER_ID_KEY); + if (!id) { + id = + typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : `p-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + localStorage.setItem(PLAYER_ID_KEY, id); + } + return id; +} + +/** The deck chosen on the home page, applied right after the room creator joins. */ +export function stashPendingDeck(cards: string[]): void { + sessionStorage.setItem(PENDING_DECK_KEY, JSON.stringify(cards)); +} + +export function takePendingDeck(): string[] | null { + const raw = sessionStorage.getItem(PENDING_DECK_KEY); + if (!raw) return null; + sessionStorage.removeItem(PENDING_DECK_KEY); + try { + const cards = JSON.parse(raw); + if (Array.isArray(cards) && cards.every((c) => typeof c === "string")) { + return cards; + } + } catch { + /* ignore */ + } + return null; +} + +const ADJECTIVES = ["dusty", "rowdy", "lucky", "sneaky", "golden", "wild", "lone", "rusty", "swift", "fancy"]; +const NOUNS = ["cactus", "saloon", "wagon", "sheriff", "coyote", "mustang", "nugget", "lasso", "spur", "tumbleweed"]; + +export function generateRoomId(): string { + const pick = (arr: string[]) => arr[Math.floor(Math.random() * arr.length)]; + const num = String(Math.floor(Math.random() * 90) + 10); + return `${pick(ADJECTIVES)}-${pick(NOUNS)}-${num}`; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..76c0253 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { MotionConfig } from "motion/react"; +import App from "./App"; +import "./index.css"; + +createRoot(document.getElementById("root")!).render( + + + + + , +); diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx new file mode 100644 index 0000000..586703c --- /dev/null +++ b/frontend/src/pages/Home.tsx @@ -0,0 +1,113 @@ +import { useState } from "react"; +import { motion } from "motion/react"; +import type { Profile } from "../types"; +import { DECK_PRESETS, DEFAULT_DECK, parseCustomDeck } from "../lib/decks"; +import { generateRoomId, loadProfile, saveProfile, stashPendingDeck } from "../lib/session"; +import { navigate } from "../lib/router"; +import { Logo } from "../components/Logo"; +import { ProfileForm } from "../components/ProfileForm"; +import { CardBack, CardFace } from "../components/Cards"; + +export function Home() { + const [deckId, setDeckId] = useState(DECK_PRESETS[0].id); + const [customRaw, setCustomRaw] = useState("1, 2, 3, 5, 8"); + + const chosenDeck = + deckId === "custom" + ? parseCustomDeck(customRaw) + : (DECK_PRESETS.find((d) => d.id === deckId)?.cards ?? DEFAULT_DECK); + + const create = (profile: Profile) => { + saveProfile(profile); + stashPendingDeck(chosenDeck ?? DEFAULT_DECK); + navigate(`/room/${generateRoomId()}`); + }; + + return ( +
+
+ + + + Saddle up. Point stories. Settle the score. + +
+ + + +
+ Deck +
+ {DECK_PRESETS.map((d) => ( + + ))} + +
+ {deckId === "custom" && ( + setCustomRaw(e.target.value)} + placeholder="1, 2, 3, 5, 8" + /> + )} +
+ {(chosenDeck ?? []).map((v) => ( + + ))} +
+
+
+
+ +

+ No sign-up. No database. Rooms vanish like tumbleweed when everyone leaves. +

+
+ ); +} + +function HeroCards() { + return ( +
+ + + + + + +
+ ); +} diff --git a/frontend/src/pages/Room.tsx b/frontend/src/pages/Room.tsx new file mode 100644 index 0000000..5afeadb --- /dev/null +++ b/frontend/src/pages/Room.tsx @@ -0,0 +1,153 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { AnimatePresence } from "motion/react"; +import type { ConnStatus, Profile, RoomState } from "../types"; +import { createConnection } from "../lib/connection"; +import type { RoomConnection } from "../lib/connection"; +import { getPlayerId, loadProfile, saveProfile, takePendingDeck } from "../lib/session"; +import { isConsensus } from "../lib/game"; +import { navigate } from "../lib/router"; +import { Table } from "../components/Table"; +import { CardHand } from "../components/CardHand"; +import { DeckModal } from "../components/DeckModal"; +import { Confetti } from "../components/Confetti"; +import { Logo } from "../components/Logo"; +import { ProfileForm } from "../components/ProfileForm"; + +export function Room({ roomId }: { roomId: string }) { + const playerId = useMemo(getPlayerId, []); + const [profile, setProfile] = useState(loadProfile); + const [state, setState] = useState(null); + const [status, setStatus] = useState("connecting"); + const [myVote, setMyVote] = useState(null); + const [deckOpen, setDeckOpen] = useState(false); + const [copied, setCopied] = useState(false); + const connRef = useRef(null); + + useEffect(() => { + if (!profile) return; + const conn = createConnection(roomId, playerId, profile, { + onState: setState, + onStatus: setStatus, + }); + connRef.current = conn; + const pending = takePendingDeck(); + if (pending) conn.send({ type: "deck", cards: pending }); + return () => { + conn.close(); + connRef.current = null; + }; + }, [roomId, profile, playerId]); + + // A new round (reset or deck change) clears the local selection. + const round = state?.round; + useEffect(() => { + setMyVote(null); + }, [round]); + + const consensus = useMemo(() => isConsensus(state), [state]); + + const castVote = (value: string) => { + if (!state || state.revealed) return; + const next = myVote === value ? null : value; + setMyVote(next); + connRef.current?.send({ type: "vote", value: next }); + }; + + const openTv = () => { + const url = `${location.origin}/room/${roomId}/tv`; + if (!window.open(url, "showdown-tv", "popup=yes,width=1280,height=800")) { + window.open(url, "_blank"); + } + }; + + const copyInvite = async () => { + try { + await navigator.clipboard.writeText(location.href); + } catch { + /* clipboard unavailable — the room code in the URL still works */ + } + setCopied(true); + setTimeout(() => setCopied(false), 1600); + }; + + if (!profile) { + return ( +
+ + { + saveProfile(p); + setProfile(p); + }} + /> +
+ ); + } + + return ( +
+
+ { + e.preventDefault(); + navigate("/"); + }} + > + + SHOWDOWN + + +
+ {status === "demo" && demo} + + + +
+
+ + {status === "offline" &&
Lost connection — wranglin’ it back…
} + + {state ? ( + <> + connRef.current?.send({ type: "reveal" })} + onReset={() => connRef.current?.send({ type: "reset" })} + onInvite={copyInvite} + /> + + + ) : ( +
Shufflin’ the deck…
+ )} + + + {deckOpen && state && ( + setDeckOpen(false)} + onSave={(cards) => { + connRef.current?.send({ type: "deck", cards }); + setDeckOpen(false); + }} + /> + )} + + + {consensus && state && } + + ); +} diff --git a/frontend/src/pages/Tv.tsx b/frontend/src/pages/Tv.tsx new file mode 100644 index 0000000..b1674ed --- /dev/null +++ b/frontend/src/pages/Tv.tsx @@ -0,0 +1,70 @@ +import { useEffect, useMemo, useState } from "react"; +import type { ConnStatus, RoomState } from "../types"; +import { createConnection } from "../lib/connection"; +import { isConsensus } from "../lib/game"; +import { Table } from "../components/Table"; +import { Confetti } from "../components/Confetti"; + +/** Big-screen spectator view: no seat, no controls, just the showdown. */ +export function TvRoom({ roomId }: { roomId: string }) { + const [state, setState] = useState(null); + const [status, setStatus] = useState("connecting"); + const [fullscreen, setFullscreen] = useState(false); + + useEffect(() => { + const conn = createConnection( + roomId, + "tv", + { name: "TV", emoji: "📺" }, + { onState: setState, onStatus: setStatus }, + { spectator: true }, + ); + return () => conn.close(); + }, [roomId]); + + useEffect(() => { + const onChange = () => setFullscreen(Boolean(document.fullscreenElement)); + document.addEventListener("fullscreenchange", onChange); + return () => document.removeEventListener("fullscreenchange", onChange); + }, []); + + const consensus = useMemo(() => isConsensus(state), [state]); + + const toggleFullscreen = () => { + if (document.fullscreenElement) { + document.exitFullscreen().catch(() => {}); + } else { + document.documentElement.requestFullscreen().catch(() => {}); + } + }; + + return ( +
+
+
+ + SHOWDOWN +
+
+
{roomId}
+
join at {location.host}/room/{roomId}
+
+
+ {status === "demo" && demo} + + +
+
+ + {state ? ( +
+ ) : ( +
Tunin’ in…
+ )} + + {consensus && state && } + + ); +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..b7e9746 --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,40 @@ +/** + * Shared shapes for the Showdown room protocol. + * The Go backend mirrors these — see PROTOCOL.md at the repo root. + */ + +export interface Player { + id: string; + name: string; + emoji: string; + /** True once the player has picked a card this round. */ + voted: boolean; + /** The actual card. Null until the round is revealed (server hides it). */ + vote: string | null; +} + +export interface RoomState { + roomId: string; + deck: string[]; + revealed: boolean; + /** Increments on every reset / deck change. Lets clients detect new rounds. */ + round: number; + players: Player[]; +} + +export type ClientMessage = + | { type: "join"; playerId: string; name: string; emoji: string } + | { type: "watch" } + | { type: "vote"; value: string | null } + | { type: "reveal" } + | { type: "reset" } + | { type: "deck"; cards: string[] }; + +export type ServerMessage = { type: "state"; state: RoomState }; + +export type ConnStatus = "connecting" | "online" | "offline" | "demo"; + +export interface Profile { + name: string; + emoji: string; +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..37848b2 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "moduleDetection": "force", + "useDefineForClassFields": true, + "skipLibCheck": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..feb0b44 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + // Forwarded to the Go backend once it exists. + "/ws": { + target: "ws://localhost:8080", + ws: true, + }, + }, + }, +});