word-waltz / commit
got things mostly working
firebase.json | 5 ++++ firestore.rules | 11 +++++++ src/components/Scoreboard.ts | 4 +-- src/index.html | 5 +++- src/main.ts | 68 +++++++++++++++++++++++++++++++++++++------- src/styles/main.css | 24 ++++++++++++++-- 6 files changed, 100 insertions(+), 17 deletions(-) diff --git a/firebase.json b/firebase.json new file mode 100644 index 0000000..201ec0a --- /dev/null +++ b/firebase.json @@ -0,0 +1,5 @@ +{ + "firestore": { + "rules": "firestore.rules" + } +} \ No newline at end of file diff --git a/firestore.rules b/firestore.rules new file mode 100644 index 0000000..b0711d4 --- /dev/null +++ b/firestore.rules @@ -0,0 +1,11 @@ +rules_version = '2'; +service cloud.firestore { + match /databases/{database}/documents { + match /games/{gameId} { + // Confidentiality is enforced by client-side AES-GCM-256 encryption. + // The encryption key lives only in the URL fragment and is never sent + // to the server, so Firestore only ever sees opaque ciphertext. + allow read, write: if true; + } + } +} diff --git a/src/components/Scoreboard.ts b/src/components/Scoreboard.ts index 05c28d3..eb752f1 100644 --- a/src/components/Scoreboard.ts +++ b/src/components/Scoreboard.ts @@ -33,8 +33,8 @@ export function mountScoreboard(container: HTMLElement, props: ScoreboardProps): const solver: PlayerId = r.challenger === 'a' ? 'b' : 'a'; const solverName = players[solver].name || (solver === 'a' ? 'Player A' : 'Player B'); const result = r.solved - ? `solved in ${r.guesses.length} ${r.guesses.length === 1 ? 'guess' : 'guesses'} (+${r.score})` - : `failed (the word was ${r.word})`; + ? `solved "${r.word}" in ${r.guesses.length} ${r.guesses.length === 1 ? 'guess' : 'guesses'} (+${r.score})` + : `failed to solve "${r.word}"`; return `<li class="scoreboard__round">${escHtml(solverName)}: ${escHtml(result)}</li>`; }).join(''); diff --git a/src/index.html b/src/index.html index c33cb03..4d58285 100644 --- a/src/index.html +++ b/src/index.html @@ -14,7 +14,10 @@ <body> <header> - <h1>Word Waltz</h1> + <a id="home-link" href="#" aria-label="Word Waltz — back to start"> + <img src="./assets/logo.png" alt="" class="header__logo" /> + <span class="header__title">Word Waltz</span> + </a> </header> <div id="app"></div> <script type="module" src="./main.ts"></script> diff --git a/src/main.ts b/src/main.ts index 827cb65..ef45c94 100644 --- a/src/main.ts +++ b/src/main.ts @@ -25,6 +25,10 @@ import type { GameState, PlayerId, Round } from './types.js'; // --------------------------------------------------------------------------- const roleKey = (gameId: string) => `word-waltz:player:${gameId}`; +const NAME_KEY = 'word-waltz:name'; + +function getSavedName(): string { return localStorage.getItem(NAME_KEY) ?? ''; } +function saveName(name: string): void { localStorage.setItem(NAME_KEY, name); } function getRole(gameId: string): PlayerId | null { return localStorage.getItem(roleKey(gameId)) as PlayerId | null; @@ -64,6 +68,7 @@ async function saveState(gameId: string, key: CryptoKey, state: GameState): Prom // --------------------------------------------------------------------------- let cleanupView: (() => void) | null = null; +let unsubscribeGame: (() => void) | null = null; function render( app: HTMLElement, @@ -91,7 +96,18 @@ function render( let cleanupMain: (() => void) | null = null; - if (state.turn === 'pick') { + // Before the opponent has joined, always show the share dialog so Player A + // can send the invite link first. Once Player B sets their name (received + // via real-time subscription) the view re-renders into the normal flow. + if (state.players.b.name === '') { + const shareEl = document.createElement('div'); + mainEl.appendChild(shareEl); + cleanupMain = mountShareDialog(shareEl, { shareUrl }); + const waiting = document.createElement('p'); + waiting.className = 'status'; + waiting.textContent = 'Share the link above with your opponent, then wait for them to join…'; + mainEl.appendChild(waiting); + } else if (state.turn === 'pick') { const challenger = nextChallenger(state); if (challenger === myRole) { @@ -144,10 +160,7 @@ function render( }, }); } else { - // Challenger waits: show the share link in case opponent hasn't joined yet - const shareEl = document.createElement('div'); - mainEl.appendChild(shareEl); - cleanupMain = mountShareDialog(shareEl, { shareUrl }); + // Challenger waits while their opponent solves const waiting = document.createElement('p'); waiting.className = 'status'; waiting.textContent = 'Waiting for your opponent to solve the word…'; @@ -171,7 +184,7 @@ function showNewGameScreen(app: HTMLElement): void { <p>Start a new game and share the link with a friend.</p> <form id="new-game-form"> <label>Your name - <input type="text" id="player-name" maxlength="20" required autocomplete="off" /> + <input type="text" id="player-name" maxlength="20" required autocomplete="off" value="${getSavedName()}" /> </label> <button type="submit">Create game</button> </form> @@ -187,6 +200,7 @@ function showNewGameScreen(app: HTMLElement): void { const btn = app.querySelector<HTMLButtonElement>('button')!; btn.disabled = true; btn.textContent = 'Creating…'; + saveName(name); try { const gameId = crypto.randomUUID(); @@ -211,7 +225,7 @@ function showNewGameScreen(app: HTMLElement): void { // errors (e.g. database not created, network issue) instead of // hanging on "Creating…" forever. const timeout = new Promise<never>((_, reject) => - setTimeout(() => reject(new Error('Firestore write timed out — check that the Firestore database exists in the Firebase console and that security rules allow writes.')), 10_000), + setTimeout(() => reject(new Error('Timed out connecting to Firestore. Make sure your Firestore security rules allow read/write (see the Rules tab in the Firebase console).')), 8_000), ); await Promise.race([createGame(gameId, envelope), timeout]); @@ -223,7 +237,11 @@ function showNewGameScreen(app: HTMLElement): void { console.error('Game creation failed:', err); btn.disabled = false; btn.textContent = 'Create game'; - const msg = err instanceof Error ? err.message : 'Failed to create game. Please try again.'; + // Surface Firestore permission-denied errors clearly + const raw = err instanceof Error ? err.message : String(err); + const msg = raw.toLowerCase().includes('permission') + ? 'Permission denied by Firestore. Open the Firebase console → Firestore → Rules and set: allow read, write: if true; then publish.' + : raw || 'Failed to create game. Please try again.'; showError(app, msg); } }); @@ -242,7 +260,7 @@ function promptPlayerBName( <p>You've been invited to play! Enter your name to join.</p> <form id="join-form"> <label>Your name - <input type="text" id="player-name" maxlength="20" required autocomplete="off" /> + <input type="text" id="player-name" maxlength="20" required autocomplete="off" value="${getSavedName()}" /> </label> <button type="submit">Join game</button> </form> @@ -251,7 +269,7 @@ function promptPlayerBName( app.querySelector('#join-form')!.addEventListener('submit', (e) => { e.preventDefault(); const name = app.querySelector<HTMLInputElement>('#player-name')!.value.trim(); - if (name) onName(name); + if (name) { saveName(name); onName(name); } }); } @@ -333,7 +351,8 @@ async function bootstrap(): Promise<void> { render(app, state, role, gameId, cryptoKey, shareUrl); - subscribeToGame(gameId, async (newEnvelope) => { + unsubscribeGame?.(); + unsubscribeGame = subscribeToGame(gameId, async (newEnvelope) => { try { const newState = JSON.parse(await decrypt(newEnvelope, cryptoKey)) as GameState; render(app, newState, role, gameId, cryptoKey, shareUrl); @@ -343,6 +362,33 @@ async function bootstrap(): Promise<void> { }); } +// Home link — clear the fragment and re-show the new-game screen without a +// full page reload. This works regardless of the deployment base path. +document.getElementById('home-link')?.addEventListener('click', (e) => { + e.preventDefault(); + unsubscribeGame?.(); + unsubscribeGame = null; + cleanupView?.(); + cleanupView = null; + history.replaceState(null, '', window.location.pathname); + const app = document.getElementById('app'); + if (app) showNewGameScreen(app); +}); + +// Re-run bootstrap whenever the fragment changes (e.g. after game creation +// navigates to the game URL — a hash-only change doesn't reload the page). +window.addEventListener('hashchange', () => { + unsubscribeGame?.(); + unsubscribeGame = null; + cleanupView?.(); + cleanupView = null; + bootstrap().catch((err: unknown) => { + console.error('Bootstrap failed:', err); + const app = document.getElementById('app'); + if (app) showError(app, 'Something went wrong loading the game. Please refresh.'); + }); +}); + bootstrap().catch((err: unknown) => { console.error('Bootstrap failed:', err); const app = document.getElementById('app'); diff --git a/src/styles/main.css b/src/styles/main.css index 5a6be04..4acf2fa 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -50,13 +50,31 @@ body { header { display: flex; align-items: center; - gap: 12px; - padding: 12px 20px; + padding: 10px 20px; border-bottom: 1px solid var(--color-border); background: var(--color-surface); } -header h1 { +#home-link { + display: flex; + align-items: center; + gap: 10px; + text-decoration: none; + color: inherit; +} + +#home-link:hover .header__title { + text-decoration: underline; +} + +.header__logo { + height: 40px; + width: 40px; + object-fit: contain; + border-radius: 6px; +} + +.header__title { font-size: 1.4rem; font-weight: 700; letter-spacing: 0.05em;