fuzzyworld

word-waltz / commit

suggested fixes

e86661e4d73d8ff78f45488da440ee4df4332073 · Pavel Safronov · 2026-03-23 23:32:15 -0700 · parents 94a747e4f6ce

suggested fixes
 src/components/Keyboard.ts    |  6 ++++--
 src/components/ShareDialog.ts | 19 +++++++++++++++++--
 src/components/WordleBoard.ts | 14 +++++++++-----
 src/main.ts                   | 20 ++++++++++++++++----
 src/styles/main.css           |  2 +-
 src/utils/shareLink.ts        |  1 +
 6 files changed, 48 insertions(+), 14 deletions(-)

diff --git a/src/components/Keyboard.ts b/src/components/Keyboard.ts
index f680006..8252799 100644
--- a/src/components/Keyboard.ts
+++ b/src/components/Keyboard.ts
@@ -27,11 +27,11 @@ export function mountKeyboard(container: HTMLElement, props: KeyboardProps): ()
 
     if (rowStr.startsWith('ENTER')) {
       // Bottom row: Enter — middle letters — Backspace
-      appendKey(rowEl, 'Enter', 'Enter', onKey, true);
+      appendKey(rowEl, 'Enter', 'Enter', onKey, true, 'Submit guess');
       for (const ch of rowStr.slice('ENTER'.length, -1)) {
         appendKey(rowEl, ch, ch, onKey, false);
       }
-      appendKey(rowEl, '⌫', 'Backspace', onKey, true);
+      appendKey(rowEl, '⌫', 'Backspace', onKey, true, 'Delete letter');
     } else {
       for (const ch of rowStr) {
         appendKey(rowEl, ch, ch, onKey, false);
@@ -64,12 +64,14 @@ function appendKey(
   key: string,
   onKey: (k: string) => void,
   wide: boolean,
+  ariaLabel?: string,
 ): void {
   const btn = document.createElement('button');
   btn.type = 'button';
   btn.className = 'keyboard__key' + (wide ? ' keyboard__key--wide' : '');
   btn.textContent = label;
   btn.dataset['key'] = key;
+  if (ariaLabel) btn.setAttribute('aria-label', ariaLabel);
   btn.addEventListener('click', () => onKey(key));
   row.appendChild(btn);
 }
diff --git a/src/components/ShareDialog.ts b/src/components/ShareDialog.ts
index 2940aa7..700e888 100644
--- a/src/components/ShareDialog.ts
+++ b/src/components/ShareDialog.ts
@@ -29,11 +29,21 @@ export function mountShareDialog(container: HTMLElement, props: ShareDialogProps
   copyBtn.className = 'share-dialog__copy';
   copyBtn.type = 'button';
   copyBtn.textContent = 'Copy link';
+  let resetTimer: ReturnType<typeof setTimeout> | null = null;
   copyBtn.addEventListener('click', () => {
+    if (copyBtn.disabled) return;
+    copyBtn.disabled = true;
     navigator.clipboard.writeText(shareUrl).then(() => {
       copyBtn.textContent = 'Copied!';
-      setTimeout(() => { copyBtn.textContent = 'Copy link'; }, 2000);
-    }).catch(() => { copyBtn.textContent = 'Copy failed'; });
+    }).catch(() => {
+      copyBtn.textContent = 'Copy failed';
+    }).finally(() => {
+      if (resetTimer !== null) clearTimeout(resetTimer);
+      resetTimer = setTimeout(() => {
+        copyBtn.textContent = 'Copy link';
+        copyBtn.disabled = false;
+      }, 2000);
+    });
   });
 
   root.appendChild(canvas);
@@ -43,6 +53,11 @@ export function mountShareDialog(container: HTMLElement, props: ShareDialogProps
 
   QRCode.toCanvas(canvas, shareUrl, { width: 220, margin: 2 }).catch((err: unknown) => {
     console.error('QR generation failed:', err);
+    canvas.style.display = 'none';
+    const qrError = document.createElement('p');
+    qrError.className = 'share-dialog__qr-error';
+    qrError.textContent = 'QR code unavailable — copy the link below.';
+    root.insertBefore(qrError, linkEl);
   });
 
   return function cleanup() {
diff --git a/src/components/WordleBoard.ts b/src/components/WordleBoard.ts
index 3a468b9..5708506 100644
--- a/src/components/WordleBoard.ts
+++ b/src/components/WordleBoard.ts
@@ -179,18 +179,22 @@ export function mountWordleBoard(container: HTMLElement, props: WordleBoardProps
   }
 
   let endTimer: ReturnType<typeof setTimeout> | null = null;
+  let destroyed = false;
 
   function scheduleRoundEnd(solved: boolean): void {
     endTimer = setTimeout(() => {
-      onRoundEnd({
-        solved,
-        guesses,
-        score: calculateScore(guesses.length, solved),
-      });
+      if (!destroyed) {
+        onRoundEnd({
+          solved,
+          guesses,
+          score: calculateScore(guesses.length, solved),
+        });
+      }
     }, 1600);
   }
 
   return function cleanup() {
+    destroyed = true;
     if (endTimer !== null) clearTimeout(endTimer);
     cleanupKeyboard();
     container.removeChild(root);
diff --git a/src/main.ts b/src/main.ts
index a7f6609..88d1055 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -158,6 +158,13 @@ function render(
 
         if (solver === myRole) {
             const round = state.rounds[state.currentRound];
+            if (!round) {
+                // currentRound is temporarily out of bounds (e.g. between write
+                // and the real-time subscription delivering the new round).
+                mainEl.innerHTML = '<p class="status">Loading…</p>';
+                cleanupView = () => { cleanupScoreboard(); };
+                return;
+            }
             cleanupMain = mountWordleBoard(mainEl, {
                 secretWord: round.word,
                 onRoundEnd: async (result) => {
@@ -241,7 +248,7 @@ function showNewGameScreen(app: HTMLElement): void {
 
             const envelope = await encrypt(JSON.stringify(initialState), cryptoKey);
 
-            // Race the Firestore write against a 10 s timeout so we surface
+            // Race the Firestore write against an 8 s timeout so we surface
             // errors (e.g. database not created, network issue) instead of
             // hanging on "Creating…" forever.
             const timeout = new Promise<never>((_, reject) =>
@@ -298,7 +305,11 @@ function promptPlayerBName(
 // ---------------------------------------------------------------------------
 
 function showError(app: HTMLElement, message: string): void {
-    app.innerHTML = `<p class="error">${message}</p>`;
+    app.innerHTML = '';
+    const p = document.createElement('p');
+    p.className = 'error';
+    p.textContent = message;
+    app.appendChild(p);
 }
 
 // ---------------------------------------------------------------------------
@@ -386,8 +397,9 @@ async function bootstrap(): Promise<void> {
             }
             wasMyTurn = myTurnNow;
             render(app, newState, role, gameId, cryptoKey, shareUrl);
-        } catch {
-            // Silently ignore — could be a stale snapshot during writes
+        } catch (err) {
+            console.error('Failed to decrypt game update:', err);
+            showError(app, 'Could not decrypt the latest game update — the link may be corrupted.');
         }
     });
 }
diff --git a/src/styles/main.css b/src/styles/main.css
index aea535e..f9e5a66 100644
--- a/src/styles/main.css
+++ b/src/styles/main.css
@@ -399,7 +399,7 @@ header {
 }
 
 .word-picker__input {
-    width: 160px;
+    width: min(100%, 160px);
     font-size: 1.4rem;
     font-family: var(--font-mono);
     font-weight: 700;
diff --git a/src/utils/shareLink.ts b/src/utils/shareLink.ts
index cd303ef..f45447a 100644
--- a/src/utils/shareLink.ts
+++ b/src/utils/shareLink.ts
@@ -11,6 +11,7 @@
 
 // Production URL used when generating share links from localhost so that QR
 // codes are scannable from devices that can't reach localhost.
+// If you deploy to a different host, update this constant.
 const PRODUCTION_URL = 'https://pavelsafronov.gitlab.io/word-waltz/';
 
 function shareBase(): string {