fuzzyworld

word-waltz / commit

ui improvements

bf4044de2f0f706a92fcc30c90c32924d0c0fbbd · Pavel Safronov · 2026-03-23 22:34:09 -0700 · parents 2d2cc08fb939

ui improvements
 src/components/Scoreboard.ts | 160 +++++++++++++++++++++++++++++++++----------
 src/index.html               |  11 +--
 src/main.ts                  |  12 ++++
 src/styles/main.css          | 155 +++++++++++++++++++++++++++++------------
 4 files changed, 252 insertions(+), 86 deletions(-)

diff --git a/src/components/Scoreboard.ts b/src/components/Scoreboard.ts
index eb752f1..4dc1aa1 100644
--- a/src/components/Scoreboard.ts
+++ b/src/components/Scoreboard.ts
@@ -4,60 +4,146 @@ import type { Player, PlayerId, Round } from '../types.js';
  * Scoreboard — running score display
  *
  * Shows each player's name and cumulative score, plus the round history.
+ * The current player's name can be edited inline.
+ * The history is collapsed by default (showing last 2 rounds) and can be expanded.
  */
 
 interface ScoreboardProps {
   players: { a: Player; b: Player };
   rounds: Round[];
+  myRole: PlayerId;
+  onNameChange: (newName: string) => void;
 }
 
+const COLLAPSED_COUNT = 2;
+
 export function mountScoreboard(container: HTMLElement, props: ScoreboardProps): () => void {
-  const { players, rounds } = props;
+  const { players, rounds, myRole, onNameChange } = props;
 
   const root = document.createElement('div');
   root.className = 'scoreboard';
 
-  function playerCard(id: PlayerId): string {
-    const p = players[id];
-    const name = p.name || (id === 'a' ? 'Player A' : 'Player B');
-    return `
-      <div class="scoreboard__player scoreboard__player--${id}">
-        <span class="scoreboard__name">${escHtml(name)}</span>
-        <span class="scoreboard__score">${p.score}</span>
-      </div>`;
-  }
+  const completedRounds = rounds.filter((r) => r.guesses.length > 0);
+  let expanded = false;
+
+  function render(): void {
+    root.innerHTML = '';
+
+    // --- Players row ---
+    const playersEl = document.createElement('div');
+    playersEl.className = 'scoreboard__players';
+
+    (['a', 'b'] as PlayerId[]).forEach((id, i) => {
+      if (i === 1) {
+        const vs = document.createElement('span');
+        vs.className = 'scoreboard__vs';
+        vs.textContent = 'vs';
+        playersEl.appendChild(vs);
+      }
+
+      const p: Player = players[id];
+      const displayName = p.name || (id === 'a' ? 'Player A' : 'Player B');
+      const card = document.createElement('div');
+      card.className = 'scoreboard__player';
+
+      if (id === myRole) {
+        // Editable name
+        const nameRow = document.createElement('div');
+        nameRow.className = 'scoreboard__name-row';
+
+        const nameEl = document.createElement('span');
+        nameEl.className = 'scoreboard__name';
+        nameEl.textContent = displayName;
+        nameRow.appendChild(nameEl);
+
+        const editBtn = document.createElement('button');
+        editBtn.className = 'scoreboard__edit-btn';
+        editBtn.title = 'Change name';
+        editBtn.textContent = '✎';
+        editBtn.addEventListener('click', () => {
+          const row = nameEl.parentElement!;
+          // Replace with inline input
+          const input = document.createElement('input');
+          input.type = 'text';
+          input.className = 'scoreboard__name-input';
+          input.value = p.name;
+          input.maxLength = 20;
+          row.innerHTML = '';
+          row.appendChild(input);
+          input.focus();
+          input.select();
+
+          function commit() {
+            const val = input.value.trim();
+            if (val && val !== p.name) onNameChange(val);
+            else render(); // revert
+          }
+          input.addEventListener('blur', commit);
+          input.addEventListener('keydown', (e) => {
+            if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
+            if (e.key === 'Escape') { input.removeEventListener('blur', commit); render(); }
+          });
+        });
+        nameRow.appendChild(editBtn);
+        card.appendChild(nameRow);
+      } else {
+        const nameEl = document.createElement('span');
+        nameEl.className = 'scoreboard__name';
+        nameEl.textContent = displayName;
+        card.appendChild(nameEl);
+      }
 
-  const completedRounds = rounds.filter((r) => r.solved !== undefined && r.guesses.length > 0);
-
-  const historyRows = completedRounds.map((r) => {
-    const solver: PlayerId = r.challenger === 'a' ? 'b' : 'a';
-    const solverName = players[solver].name || (solver === 'a' ? 'Player A' : 'Player B');
-    const result = r.solved
-      ? `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('');
-
-  root.innerHTML = `
-    <div class="scoreboard__players">
-      ${playerCard('a')}
-      <span class="scoreboard__vs">vs</span>
-      ${playerCard('b')}
-    </div>
-    ${completedRounds.length > 0 ? `<ol class="scoreboard__history">${historyRows}</ol>` : ''}
-  `;
+      const scoreEl = document.createElement('span');
+      scoreEl.className = 'scoreboard__score';
+      scoreEl.textContent = String(p.score);
+      card.appendChild(scoreEl);
+      playersEl.appendChild(card);
+    });
 
+    root.appendChild(playersEl);
+
+    // --- History ---
+    if (completedRounds.length === 0) return;
+
+    const visible = expanded
+      ? completedRounds
+      : completedRounds.slice(-COLLAPSED_COUNT);
+
+    const historyEl = document.createElement('div');
+    historyEl.className = 'scoreboard__history-wrap';
+
+    if (completedRounds.length > COLLAPSED_COUNT) {
+      const toggle = document.createElement('button');
+      toggle.className = 'scoreboard__toggle';
+      toggle.textContent = expanded
+        ? '▲ Hide older rounds'
+        : `▼ Show all ${completedRounds.length} rounds`;
+      toggle.addEventListener('click', () => { expanded = !expanded; render(); });
+      historyEl.appendChild(toggle);
+    }
+
+    const ol = document.createElement('ol');
+    ol.className = 'scoreboard__history';
+    ol.start = expanded ? 1 : completedRounds.length - visible.length + 1;
+    visible.forEach((r) => {
+      const solver: PlayerId = r.challenger === 'a' ? 'b' : 'a';
+      const solverName = players[solver].name || (solver === 'a' ? 'Player A' : 'Player B');
+      const result = r.solved
+        ? `solved "${r.word}" in ${r.guesses.length} ${r.guesses.length === 1 ? 'guess' : 'guesses'} (+${r.score})`
+        : `failed to solve "${r.word}"`;
+      const li = document.createElement('li');
+      li.className = 'scoreboard__round';
+      li.textContent = `${solverName}: ${result}`;
+      ol.appendChild(li);
+    });
+    historyEl.appendChild(ol);
+    root.appendChild(historyEl);
+  }
+
+  render();
   container.appendChild(root);
 
   return function cleanup() {
     container.removeChild(root);
   };
 }
-
-function escHtml(str: string): string {
-  return str
-    .replace(/&/g, '&amp;')
-    .replace(/</g, '&lt;')
-    .replace(/>/g, '&gt;')
-    .replace(/"/g, '&quot;');
-}
diff --git a/src/index.html b/src/index.html
index 130a23b..c02748b 100644
--- a/src/index.html
+++ b/src/index.html
@@ -18,13 +18,14 @@
             <img src="./assets/logo.png" alt="" class="header__logo" />
             <span class="header__title">Word Waltz</span>
         </a>
+        <nav class="header__links">
+            <a href="https://www.flaticon.com/search?author_id=296&style_id=1001&type=standard" target="_blank"
+                rel="noopener" title="Icons by Freepik on Flaticon">Icons</a>
+            <a href="https://gitlab.com/PavelSafronov/word-waltz" target="_blank" rel="noopener"
+                title="Source code on GitLab">GitLab</a>
+        </nav>
     </header>
     <div id="app"></div>
-    <footer class="site-footer">
-        Open source on
-        <a href="https://gitlab.com/PavelSafronov/word-waltz" target="_blank" rel="noopener">GitLab</a>
-        &mdash; a private two-player Wordle duel. Share the link with one friend to play.
-    </footer>
     <script type="module" src="./main.ts"></script>
 </body>
 
diff --git a/src/main.ts b/src/main.ts
index 6e939e3..a7f6609 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -96,6 +96,18 @@ function render(
     const cleanupScoreboard = mountScoreboard(scoreboardEl, {
         players: state.players,
         rounds: state.rounds,
+        myRole,
+        onNameChange: async (newName: string) => {
+            saveName(newName);
+            await saveState(gameId, key, {
+                ...state,
+                players: {
+                    ...state.players,
+                    [myRole]: { ...state.players[myRole], name: newName },
+                },
+                updatedAt: Date.now(),
+            });
+        },
     });
 
     const mainEl = document.createElement('div');
diff --git a/src/styles/main.css b/src/styles/main.css
index 30c1d7f..949b37c 100644
--- a/src/styles/main.css
+++ b/src/styles/main.css
@@ -22,7 +22,12 @@
     --color-accent: #538d4e;
     --color-error: #c0392b;
 
-    --tile-size: clamp(44px, 11vw, 62px);
+    /* tile size: the min of a width-based cap and a height-based cap.
+       Height formula: (100dvh minus everything except tiles) / 6.
+       ~360px = header(60) + app-pads(22) + scoreboard(67) + app-gap(10)
+                + board-message(22) + board-gaps(16) + keyboard(approx 130).
+       A small safety margin is already included. */
+    --tile-size: min(clamp(30px, 11vw, 62px), max(20px, calc((100dvh - 360px) / 6)));
     --tile-gap: 6px;
     --radius: 4px;
     --font-mono: 'Courier New', Courier, monospace;
@@ -41,7 +46,8 @@ body {
     font-family: system-ui, -apple-system, sans-serif;
     background: var(--color-bg);
     color: var(--color-text);
-    min-height: 100dvh;
+    height: 100dvh;
+    overflow: hidden;
     display: flex;
     flex-direction: column;
 }
@@ -50,9 +56,10 @@ body {
 header {
     display: flex;
     align-items: center;
-    padding: 10px 20px;
+    padding: 8px 16px;
     border-bottom: 1px solid var(--color-border);
     background: var(--color-surface);
+    gap: 12px;
 }
 
 #home-link {
@@ -61,6 +68,7 @@ header {
     gap: 10px;
     text-decoration: none;
     color: inherit;
+    flex: 1;
 }
 
 #home-link:hover .header__title {
@@ -68,30 +76,52 @@ header {
 }
 
 .header__logo {
-    height: 40px;
-    width: 40px;
+    height: 36px;
+    width: 36px;
     object-fit: contain;
     border-radius: 6px;
+    flex-shrink: 0;
 }
 
 .header__title {
-    font-size: 1.4rem;
+    font-size: 1.2rem;
     font-weight: 700;
     letter-spacing: 0.05em;
     text-transform: uppercase;
 }
 
+.header__links {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    flex-shrink: 0;
+}
+
+.header__links a {
+    font-size: 0.78rem;
+    color: var(--color-muted);
+    text-decoration: none;
+    white-space: nowrap;
+}
+
+.header__links a:hover {
+    color: var(--color-accent);
+    text-decoration: underline;
+}
+
 /* --- App container ---------------------------------------- */
 #app {
     flex: 1;
+    min-height: 0;
     display: flex;
     flex-direction: column;
     align-items: center;
-    padding: 20px 16px 32px;
-    gap: 20px;
+    padding: 10px 12px 12px;
+    gap: 10px;
     max-width: 520px;
     width: 100%;
     margin-inline: auto;
+    overflow: hidden;
 }
 
 /* --- Shared utilities ------------------------------------- */
@@ -112,10 +142,11 @@ header {
 /* --- Scoreboard ------------------------------------------- */
 .scoreboard {
     width: 100%;
+    flex-shrink: 0;
     background: var(--color-surface);
     border: 1px solid var(--color-border);
     border-radius: var(--radius);
-    padding: 12px 16px;
+    padding: 8px 12px;
 }
 
 .scoreboard__players {
@@ -147,21 +178,70 @@ header {
 }
 
 .scoreboard__score {
-    font-size: 2rem;
+    font-size: 1.6rem;
     font-weight: 700;
     line-height: 1;
 }
 
 .scoreboard__history {
-    margin-top: 10px;
-    padding-top: 10px;
-    border-top: 1px solid var(--color-border);
-    list-style: none;
+    list-style: decimal;
+    padding-left: 20px;
     display: flex;
     flex-direction: column;
+    gap: 3px;
+}
+
+.scoreboard__history-wrap {
+    margin-top: 8px;
+    padding-top: 8px;
+    border-top: 1px solid var(--color-border);
+}
+
+.scoreboard__toggle {
+    background: none;
+    border: none;
+    padding: 0;
+    font-size: 0.75rem;
+    color: var(--color-muted);
+    cursor: pointer;
+    margin-bottom: 4px;
+}
+
+.scoreboard__toggle:hover {
+    color: var(--color-text);
+}
+
+.scoreboard__name-row {
+    display: flex;
+    align-items: center;
     gap: 4px;
 }
 
+.scoreboard__edit-btn {
+    background: none;
+    border: none;
+    padding: 0;
+    font-size: 0.8rem;
+    cursor: pointer;
+    color: var(--color-muted);
+    line-height: 1;
+    flex-shrink: 0;
+}
+
+.scoreboard__edit-btn:hover {
+    color: var(--color-text);
+}
+
+.scoreboard__name-input {
+    font-size: 0.9rem;
+    font-weight: 600;
+    border: 1px solid var(--color-accent);
+    border-radius: 2px;
+    padding: 1px 4px;
+    width: 90px;
+    outline: none;
+}
+
 .scoreboard__round {
     font-size: 0.8rem;
     color: var(--color-muted);
@@ -172,8 +252,9 @@ header {
     display: flex;
     flex-direction: column;
     align-items: center;
-    gap: 16px;
+    gap: 8px;
     width: 100%;
+    overflow: hidden;
 }
 
 .wordle-board__grid {
@@ -195,7 +276,7 @@ header {
     display: flex;
     align-items: center;
     justify-content: center;
-    font-size: clamp(1.2rem, 4vw, 2rem);
+    font-size: calc(var(--tile-size) * 0.55);
     font-weight: 700;
     text-transform: uppercase;
     font-family: var(--font-mono);
@@ -238,28 +319,27 @@ header {
 .keyboard {
     display: flex;
     flex-direction: column;
+    align-items: center;
     gap: 8px;
     width: 100%;
-    max-width: 500px;
 }
 
 .keyboard__row {
     display: flex;
     justify-content: center;
-    gap: clamp(4px, 1.2vw, 6px);
+    gap: 6px;
 }
 
 .keyboard__key {
-    height: clamp(44px, 11vw, 58px);
-    min-width: 0;
-    padding: 0 2px;
-    flex: 1;
-    max-width: 44px;
+    width: clamp(30px, 8.5vw, 43px);
+    height: clamp(38px, 6dvh, 58px);
+    flex: none;
+    padding: 0;
     border: none;
     border-radius: var(--radius);
     background: var(--color-key-bg);
     color: var(--color-key-text);
-    font-size: 0.85rem;
+    font-size: clamp(0.7rem, 2.2vw, 0.85rem);
     font-weight: 700;
     cursor: pointer;
     text-transform: uppercase;
@@ -268,10 +348,8 @@ header {
 }
 
 .keyboard__key--wide {
-    min-width: 0;
-    max-width: 66px;
-    flex: 1.5;
-    font-size: clamp(0.6rem, 2vw, 0.75rem);
+    width: clamp(46px, 13vw, 65px);
+    font-size: clamp(0.6rem, 1.8vw, 0.75rem);
 }
 
 .keyboard__key:active {
@@ -462,26 +540,15 @@ header {
 
 /* --- Main view -------------------------------------------- */
 .main-view {
+    flex: 1;
+    min-height: 0;
     display: flex;
     flex-direction: column;
     align-items: center;
-    gap: 16px;
+    justify-content: center;
+    gap: 8px;
     width: 100%;
-}
-
-/* --- Footer ----------------------------------------------- */
-.site-footer {
-    text-align: center;
-    font-size: 0.8rem;
-    color: var(--color-muted);
-    padding: 16px;
-    border-top: 1px solid var(--color-border);
-    margin-top: auto;
-}
-
-.site-footer a {
-    color: var(--color-accent);
-    text-decoration: none;
+    overflow: hidden;
 }
 
 .site-footer a:hover {