From bf88a340d6f6befc274ecde8a64ee77893383b04 Mon Sep 17 00:00:00 2001 From: Gabriel Franco Date: Thu, 10 Sep 2026 14:23:44 -0300 Subject: [PATCH] feat(frontend): add theme-voting modal (countdown, live picks, settle) --- frontend/src/style.css | 42 +++++++++ frontend/src/ui/privateRoomFlow.ts | 60 ++++++++++-- frontend/src/ui/themeVote.ts | 147 +++++++++++++++++++++++++++++ 3 files changed, 241 insertions(+), 8 deletions(-) create mode 100644 frontend/src/ui/themeVote.ts diff --git a/frontend/src/style.css b/frontend/src/style.css index 952cd10..a7314ce 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -815,3 +815,45 @@ body { .private-room-error.hidden { display: none; } .private-room-transition h2 { color: var(--green); } + +/* ---------- theme-vote modal (#16: 10s countdown, live opponent pick, settle) ---------- */ + +.theme-vote-modal { text-align: center; } +.theme-vote-countdown { + font-family: var(--pixel); + font-size: 20px; + color: var(--yellow); + margin: 6px 0 14px; +} +.theme-vote-grid.theme-grid { margin: 0 0 14px; } +.theme-vote-opponent { + font-size: 12px; + color: var(--fg-dim); + margin: 0; +} + +.theme-vote-spin-name { + font-family: var(--pixel); + font-size: 22px; + color: var(--cyan); + margin: 18px 0; +} +.theme-vote-spin-name-final { color: var(--yellow); animation: pop .4s steps(4); } + +.theme-vote-agree { animation: agreeGlow 1.6s ease-out; } +.theme-vote-agree-banner { + font-size: 20px; + color: var(--green); + animation: pop .4s steps(4); +} +.theme-vote-settled-name { + font-family: var(--pixel); + font-size: 18px; + color: var(--yellow); + margin: 14px 0 0; +} +@keyframes agreeGlow { + 0% { box-shadow: 8px 8px 0 var(--ink), 0 0 0 0 rgba(95,215,95,.7); } + 40% { box-shadow: 8px 8px 0 var(--ink), 0 0 0 18px rgba(95,215,95,0); } + 100% { box-shadow: 8px 8px 0 var(--ink), 0 0 0 0 rgba(95,215,95,0); } +} diff --git a/frontend/src/ui/privateRoomFlow.ts b/frontend/src/ui/privateRoomFlow.ts index daaee87..bef4689 100644 --- a/frontend/src/ui/privateRoomFlow.ts +++ b/frontend/src/ui/privateRoomFlow.ts @@ -57,6 +57,7 @@ import type { import { MultiplayerMatch } from '../game/multiplayerMatch'; import { OpponentView } from '../game/opponentView'; import { ThemeGuessGame } from '../game/ThemeGuessGame'; +import { openThemeVote, type ThemeVoteHandle } from './themeVote'; export interface PrivateRoomFlowElements { /** Empty `.modal-overlay` mount (`#private-room-mount` in index.html) @@ -136,6 +137,16 @@ export function createPrivateRoomFlow( let match: MultiplayerMatch | null = null; const splitView = requireEl('split-view'); + let activeVoteHandle: ThemeVoteHandle | null = null; + // Set while the local theme-vote settle animation (spin/"YOU AGREE!") + // is still playing. `round:start` for the settled match can arrive + // from the server before that animation finishes (the backend fires + // it right after `vote:settled`) — in that case the payload is + // buffered here and `enterRound` runs once the animation completes, + // so the vote's own reveal is never cut short. + let voteAnimationPending = false; + let bufferedRoundStart: RoundStartMessage | null = null; + function clearTransitionTimeout(): void { if (transitionTimeoutId !== null) { window.clearTimeout(transitionTimeoutId); @@ -271,10 +282,11 @@ export function createPrivateRoomFlow( break; case 'vote-wait': - // TODO(#16): replace with real theme-vote modal (pick from - // `vote:start.themeIds`, live opponent-choice indicator via - // `vote:opponentChoice`). This ticket only needs to not hang — - // `round:start` (below) always follows `vote:settled`. + // Transient placeholder for the brief gap between `room:joined` + // and the server's `vote:start` (sent synchronously right + // after) — `client.on('vote:start', ...)` below replaces this + // mount content with the real `ui/themeVote.ts` modal as soon + // as it arrives. mount.innerHTML = `