word-waltz / commit
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, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"'); -} 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> - — 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 {