Konubinix' opinionated web of thoughts

Tally Score Tracker

Fleeting

open

A board game score tracker PWA. Single-file app using Alpine.js for reactive UI and Yjs for CRDT persistence (IndexedDB local + y-websocket cross-device sync).

Features: add/remove players, +/- score with tap debounce, calculator bottom sheet for big score changes, undo (including mid-burst), score history preview, scoreboard ranking modal, past games archive, game name, new game prompts for game name (prefilled from previous) and preserves player names, websocket sync indicator dot.

Stack: Alpine.js 3 (via esm.sh import map), Yjs + y-indexeddb + y-websocket (esm.sh with pinned deps to avoid duplicate Yjs instances), vanilla CSS (dark theme), service worker (network-first for HTML, cache-first for CDN).

Document skeleton

The root block composes all chapters into a single HTML file via noweb references. Each <<section>> pulls in the corresponding named block defined in the chapters below.

<!DOCTYPE html>
<html lang="en">
<head>
nil
nil
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
  --bg: #1a1a2e;
  --surface: #16213e;
  --card: #0f3460;
  --accent: #e94560;
  --accent2: #533483;
  --text: #eee;
  --muted: #888;
  --green: #4ecca3;
  --red: #e94560;
  --radius: 16px;
}
html, body { height: 100%; }
body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
  background: var(--bg);
  color: var(--text);
  overflow-x: hidden;
  -webkit-tap-highlight-color: transparent;
}
header {
  position: sticky; top: 0; z-index: 10;
  background: var(--surface);
  border-bottom: 1px solid rgba(255,255,255,.06);
  padding: 6px 10px;
  display: flex; align-items: center; gap: 6px;
  backdrop-filter: blur(12px);
}
header h1 { font-size: 1.1rem; font-weight: 800; letter-spacing: -.5px; flex-shrink: 0; }
header h1 span { color: var(--accent); }
.sync-dot {
  width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
  background: var(--muted); transition: background .3s;
}
body[data-ws="connected"] .sync-dot { background: var(--green); }
body[data-ws="denied"] .sync-dot { background: var(--accent); }
.sync-dot.syncing { animation: pulse .4s ease-out; }
@keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(78,204,163,.6); } 100% { box-shadow: 0 0 0 6px transparent; } }
.game-name-input {
  flex: 1; min-width: 0;
  background: none; border: none; border-bottom: 1.5px solid rgba(255,255,255,.1);
  color: var(--text); font-family: inherit; font-size: .8rem; font-weight: 600;
  padding: 2px 2px; outline: none; transition: border-color .15s;
}
.game-name-input:focus { border-color: var(--accent); }
.game-name-input::placeholder { color: var(--muted); font-weight: 400; }
.header-actions { display: flex; gap: 4px; flex-shrink: 0; }
.icon-btn {
  background: rgba(255,255,255,.08); border: none; color: var(--text);
  width: 38px; height: 38px; border-radius: 10px; cursor: pointer;
  display: grid; place-items: center; font-size: 1rem;
  transition: background .15s; flex-shrink: 0;
}
.icon-btn:active { background: rgba(255,255,255,.18); }
.icon-btn.primary { background: var(--accent); }
.icon-btn.primary:active { background: #c23350; }
.calc-overlay {
  position: fixed; inset: 0; background: rgba(0,0,0,.5);
  z-index: 200; display: none;
  flex-direction: column; justify-content: flex-end; align-items: center;
}
.calc-overlay.open { display: flex; }
.calc-sheet {
  background: var(--surface); border-radius: 18px 18px 0 0;
  width: 100%; max-width: 420px;
  padding: 14px 12px calc(10px + env(safe-area-inset-bottom));
  animation: slideUp .2s ease-out;
}
@keyframes slideUp {
  from { transform: translateY(100%); }
  to { transform: translateY(0); }
}
.calc-header {
  display: flex; justify-content: space-between; align-items: center;
  margin-bottom: 8px; padding: 0 4px;
}
.calc-player-name { font-weight: 700; font-size: .95rem; }
.calc-current-score { font-weight: 800; font-size: .95rem; color: var(--muted); }
.calc-display {
  font-size: 2rem; font-weight: 800; text-align: right;
  padding: 8px 14px; background: rgba(255,255,255,.05); border-radius: 12px;
  margin-bottom: 8px; font-variant-numeric: tabular-nums;
  min-height: 44px; display: flex; align-items: center; justify-content: flex-end;
}
.calc-chips {
  display: flex; gap: 6px; margin-bottom: 8px;
  overflow-x: auto; padding-bottom: 2px;
}
.calc-chip {
  padding: 8px 16px; border-radius: 20px; border: 1.5px solid rgba(255,255,255,.12);
  background: transparent; color: var(--text); font-size: .85rem; cursor: pointer;
  font-weight: 600; font-family: inherit; white-space: nowrap;
  transition: background .12s;
}
.calc-chip:active { background: rgba(255,255,255,.12); }
.calc-numpad {
  display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-bottom: 8px;
}
.calc-numpad button {
  padding: 16px; border-radius: 12px; border: none;
  background: rgba(255,255,255,.07); color: var(--text);
  font-size: 1.3rem; font-weight: 600; cursor: pointer;
  font-family: inherit; transition: background .1s;
}
.calc-numpad button:active { background: rgba(255,255,255,.18); }
.calc-numpad .calc-fn { color: var(--muted); font-size: 1.1rem; }
.calc-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.calc-apply {
  padding: 18px; border-radius: 14px; border: none;
  font-size: 1.5rem; font-weight: 800; cursor: pointer; color: #fff;
  font-family: inherit; transition: transform .08s;
}
.calc-apply:active { transform: scale(.95); }
.calc-sub { background: var(--red); }
.calc-add { background: var(--green); }
#players {
  padding: 8px 10px 16px;
  display: flex; flex-direction: column; gap: 8px;
}
.player-card {
  background: var(--card);
  border-radius: 14px;
  padding: 10px 10px 6px;
  position: relative;
  overflow: hidden;
  animation: slideIn .2s ease-out;
}
@keyframes slideIn {
  from { opacity: 0; transform: translateY(8px); }
  to { opacity: 1; transform: translateY(0); }
}
.player-card .color-stripe {
  position: absolute; left: 0; top: 0; bottom: 0; width: 4px;
}
.card-top {
  display: flex; align-items: center; gap: 6px;
  padding-left: 8px; margin-bottom: 6px;
}
.player-name {
  flex: 1; min-width: 0;
  font-size: .85rem; font-weight: 600;
  background: none; border: none; color: var(--text);
  font-family: inherit; outline: none; padding: 2px 0;
}
.player-name::placeholder { color: var(--muted); }
.card-action {
  background: rgba(255,255,255,.06); border: none; color: var(--muted);
  width: 32px; height: 32px; border-radius: 8px; cursor: pointer;
  display: grid; place-items: center; font-size: .8rem;
  flex-shrink: 0; transition: background .12s;
}
.card-action:active { background: rgba(255,255,255,.15); }
.card-action.remove-btn:active { background: var(--red); color: #fff; }
.card-score-row {
  display: flex; align-items: center; gap: 8px;
  padding-left: 8px;
}
.score-btn {
  width: 56px; height: 52px; border-radius: 12px; border: none;
  font-size: 1.5rem; font-weight: 700; cursor: pointer;
  display: grid; place-items: center; color: #fff;
  transition: transform .08s; flex-shrink: 0;
}
.score-btn:active { transform: scale(.92); }
.score-btn.minus { background: var(--red); }
.score-btn.plus { background: var(--green); }
.player-score {
  flex: 1; font-size: 2.2rem; font-weight: 800;
  text-align: center; font-variant-numeric: tabular-nums;
  line-height: 1; cursor: pointer;
  min-height: 52px; display: flex; align-items: center; justify-content: center;
}
.player-card .history-preview {
  font-size: .65rem; color: var(--muted);
  padding: 4px 0 0 8px;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.modal-overlay {
  position: fixed; inset: 0; background: rgba(0,0,0,.6);
  display: none; place-items: center; z-index: 100;
  backdrop-filter: blur(4px);
}
.modal-overlay.open { display: grid; }
.modal {
  background: var(--surface); border-radius: 20px;
  padding: 28px; width: min(400px, 92vw);
  animation: popIn .2s ease-out;
  max-height: 85vh; overflow-y: auto;
}
@keyframes popIn {
  from { opacity: 0; transform: scale(.9); }
  to { opacity: 1; transform: scale(1); }
}
.modal h2 { margin-bottom: 16px; font-size: 1.2rem; }
.modal-scores {
  display: flex; flex-direction: column; gap: 10px;
  max-height: 50vh; overflow-y: auto;
}
.modal-row {
  display: flex; justify-content: space-between; align-items: center;
  padding: 10px 14px; background: rgba(255,255,255,.05); border-radius: 12px;
}
.modal-row .rank { font-weight: 800; font-size: 1.1rem; margin-right: 12px; }
.modal-row .rank.gold { color: #ffd700; }
.modal-row .rank.silver { color: #c0c0c0; }
.modal-row .rank.bronze { color: #cd7f32; }
.modal-name { font-weight: 600; }
.modal-score { font-weight: 800; font-size: 1.2rem; }
.modal-close {
  margin-top: 16px; width: 100%; padding: 14px; border-radius: 12px;
  background: var(--accent); border: none; color: #fff;
  font-size: .95rem; font-weight: 700; cursor: pointer; font-family: inherit;
}
.empty-state {
  text-align: center; padding: 60px 20px; color: var(--muted);
}
.empty-state .emoji { font-size: 3rem; margin-bottom: 12px; }
.empty-state p { font-size: .95rem; line-height: 1.5; }
.game-entry {
  padding: 14px; background: rgba(255,255,255,.05); border-radius: 12px;
  margin-bottom: 8px;
}
.game-entry-header {
  display: flex; justify-content: space-between; align-items: center;
  margin-bottom: 6px;
}
.game-entry-name { font-weight: 700; font-size: .95rem; }
.game-entry-date { font-size: .7rem; color: var(--muted); }
.game-entry-players { font-size: .8rem; color: var(--muted); line-height: 1.5; }
.game-entry-winner { color: #ffd700; font-weight: 600; }
.game-entry-load, .game-entry-delete {
  background: none; border: none; color: var(--muted); cursor: pointer;
  font-size: .85rem; padding: 4px 8px; border-radius: 6px;
  transition: background .15s, color .15s;
}
.game-entry-load:hover { background: var(--green); color: #fff; }
.game-entry-delete:hover { background: var(--red); color: #fff; }
.no-games { text-align: center; color: var(--muted); padding: 20px; font-size: .9rem; }
</style>
</head>
<body x-data>

<header>
  <h1><span>T</span>ally</h1>
  <div class="sync-dot" title="Offline"></div>
  <input type="text" id="game-name" class="game-name-input" placeholder="Game name&#x2026;" spellcheck="false"
    x-model="$store.app.gameName" @input="$store.app.syncGameName()">
  <div class="header-actions">
    <button class="icon-btn" id="reset-btn" title="Reset Scores"
      @click="if(confirm('Reset all scores to 0?')) $store.app.resetAllScores()">&#x21bb;</button>
    <button class="icon-btn" id="new-game-btn" title="New Game"
      @click="$store.app.newGame()">&#x25b6;</button>
    <button class="icon-btn" id="games-btn" title="Past Games"
      @click="$store.app.showPastGames = true">&#x1f4cb;</button>
    <button class="icon-btn" title="Scoreboard"
      @click="$store.app.showScoreboard = true">&#x1f3c6;</button>
    <button class="icon-btn primary" id="add-btn" title="Add Player"
      @click="$store.app.addPlayer()">+</button>
  </div>
</header>

<div id="players">
  <div class="empty-state" id="empty-msg" x-show="$store.app.players.length === 0">
    <div class="emoji">&#x1f3b2;</div>
    <p>No players yet.<br>Add players to start tracking scores!</p>
  </div>
  <template x-for="(p, idx) in $store.app.players" :key="idx">
    <div class="player-card" :data-yindex="idx">
      <div class="color-stripe" :style="'background:' + $store.app.colorFor(idx)"></div>
      <div class="card-top">
        <input class="player-name" spellcheck="false" :value="p.name"
          @focus="$el.select()" @input="$store.app.syncPlayerName(idx, $el.value)">
        <button class="card-action undo-btn" title="Undo"
          @click="$store.app.undoScore(idx)">&#x21a9;</button>
        <button class="card-action remove-btn" title="Remove"
          @click="if(confirm('Remove player?')) $store.app.removePlayer(idx)">&#x2715;</button>
      </div>
      <div class="card-score-row">
        <button class="score-btn minus"
          @click="$store.app.tapScore(idx, -1)">&#x2212;</button>
        <div class="player-score" x-text="p.score"
          @click="$store.app.openCalc(idx)"></div>
        <button class="score-btn plus"
          @click="$store.app.tapScore(idx, 1)">+</button>
      </div>
      <div class="history-preview" x-text="$store.app.previewFor(idx)"></div>
    </div>
  </template>
</div>

<!-- Scoreboard Modal -->
<div class="modal-overlay" id="modal-scoreboard"
  :class="{ open: $store.app.showScoreboard }"
  @click.self="$store.app.showScoreboard = false">
  <div class="modal">
    <h2>&#x1f3c6; Scoreboard</h2>
    <div class="modal-scores" id="scoreboard-list">
      <template x-for="(entry, i) in $store.app.sortedPlayers()" :key="i">
        <div class="modal-row">
          <div>
            <span class="rank" :class="i===0?'gold':i===1?'silver':i===2?'bronze':''"
              x-text="'#'+(i+1)"></span>
            <span class="modal-name" x-text="entry.name"></span>
          </div>
          <span class="modal-score" x-text="entry.score"></span>
        </div>
      </template>
    </div>
    <button class="modal-close"
      @click="$store.app.showScoreboard = false">Close</button>
  </div>
</div>

<!-- Past Games Modal -->
<div class="modal-overlay" id="modal-games"
  :class="{ open: $store.app.showPastGames }"
  @click.self="$store.app.showPastGames = false">
  <div class="modal">
    <h2>&#x1f4cb; Past Games</h2>
    <div id="past-games-list">
      <div class="no-games" x-show="$store.app.savedGames.length === 0">No saved games yet.</div>
      <template x-for="(item, i) in $store.app.savedGamesReversed()" :key="item.idx">
        <div class="game-entry" :data-game-index="item.idx">
          <div class="game-entry-header">
            <span class="game-entry-name" x-show="item.game.gameName" x-text="item.game.gameName"></span>
            <span class="game-entry-date" x-text="$store.app.formatDate(item.game.date)"></span>
            <button class="game-entry-load" @click="$store.app.loadGame(item.idx)">&#x25b6;</button>
            <button class="game-entry-delete" @click="$store.app.deleteSavedGame(item.idx)">&#x1f5d1;</button>
          </div>
          <div class="game-entry-players" x-html="$store.app.gamePlayersHTML(item.game)"></div>
        </div>
      </template>
    </div>
    <button class="modal-close"
      @click="$store.app.showPastGames = false">Close</button>
  </div>
</div>

<!-- Calculator bottom sheet -->
<div id="calc-overlay" class="calc-overlay"
  :class="{ open: $store.app.calcOpen }"
  @click.self="$store.app.calcOpen = false">
  <div class="calc-sheet">
    <div class="calc-header">
      <span id="calc-player-name" class="calc-player-name" x-text="$store.app.calcPlayerName"></span>
      <span id="calc-current-score" class="calc-current-score" x-text="$store.app.calcCurrentScore"></span>
    </div>
    <div id="calc-display" class="calc-display" x-text="$store.app.calcDisplay"></div>
    <div class="calc-chips">
      <button class="calc-chip" @click="$store.app.calcChip(1)">1</button>
      <button class="calc-chip" @click="$store.app.calcChip(2)">2</button>
      <button class="calc-chip" @click="$store.app.calcChip(5)">5</button>
      <button class="calc-chip" @click="$store.app.calcChip(10)">10</button>
      <button class="calc-chip" @click="$store.app.calcChip(20)">20</button>
      <button class="calc-chip" @click="$store.app.calcChip(50)">50</button>
      <button class="calc-chip" @click="$store.app.calcChip(100)">100</button>
    </div>
    <div class="calc-numpad">
      <button data-digit="7" @click="$store.app.calcDigit('7')">7</button>
      <button data-digit="8" @click="$store.app.calcDigit('8')">8</button>
      <button data-digit="9" @click="$store.app.calcDigit('9')">9</button>
      <button data-digit="4" @click="$store.app.calcDigit('4')">4</button>
      <button data-digit="5" @click="$store.app.calcDigit('5')">5</button>
      <button data-digit="6" @click="$store.app.calcDigit('6')">6</button>
      <button data-digit="1" @click="$store.app.calcDigit('1')">1</button>
      <button data-digit="2" @click="$store.app.calcDigit('2')">2</button>
      <button data-digit="3" @click="$store.app.calcDigit('3')">3</button>
      <button class="calc-fn" @click="$store.app.calcClear()">C</button>
      <button data-digit="0" @click="$store.app.calcDigit('0')">0</button>
      <button class="calc-fn" @click="$store.app.calcBackspace()">&#x232b;</button>
    </div>
    <div class="calc-actions">
      <button class="calc-apply calc-sub" @click="$store.app.calcApply(-1)">&#x2212;</button>
      <button class="calc-apply calc-add" @click="$store.app.calcApply(1)">+</button>
    </div>
  </div>
</div>

<script type="module">
import Alpine from 'alpinejs';
import * as Y from 'yjs';
import { IndexeddbPersistence } from 'y-indexeddb';
import { WebsocketProvider } from 'y-websocket';

var _roomName = new URLSearchParams(location.search).get('room') || 'tally';
var ydoc = new Y.Doc();
var persistence = new IndexeddbPersistence(_roomName, ydoc);
var yCurrentPlayers = ydoc.getArray('currentPlayers');
var ySavedGames = ydoc.getArray('savedGames');
var yMeta = ydoc.getMap('meta');
var COLORS = ['#e94560','#4ecca3','#533483','#f9a826','#3fc1c9','#fc5185','#8dc6ff','#6c5ce7'];

window.Alpine = Alpine;
window.ydoc = ydoc;
window.Y = Y;
window.yCurrentPlayers = yCurrentPlayers;
window.ySavedGames = ySavedGames;
window.yMeta = yMeta;

var _scoreBounce = {};
function _clearBounces() {
  Object.keys(_scoreBounce).forEach(function(k) { clearTimeout(_scoreBounce[k].timer); });
  _scoreBounce = {};
}

Alpine.store('app', {
gameName: '',
players: [],
savedGames: [],
showScoreboard: false,
showPastGames: false,
calcOpen: false,
calcDisplay: '0',
calcTargetIdx: -1,
calcPlayerName: '',
calcCurrentScore: 0,

syncFromYjs: function() {
  if (document.activeElement !== document.getElementById('game-name')) {
    this.gameName = yMeta.get('gameName') || '';
  }
  var players = [];
  for (var i = 0; i < yCurrentPlayers.length; i++) {
    var pm = yCurrentPlayers.get(i);
    players.push({
      name: pm.get('name'),
      score: pm.get('score'),
      history: JSON.parse(pm.get('history') || '[]')
    });
  }
  this.players = players;
  var saved = [];
  for (var i = 0; i < ySavedGames.length; i++) {
    saved.push(ySavedGames.get(i));
  }
  this.savedGames = saved;
},

addPlayer: function() {
  var count = yCurrentPlayers.length;
  var pm = new Y.Map();
  pm.set('name', 'Player ' + (count + 1));
  pm.set('score', 0);
  pm.set('history', '[]');
  yCurrentPlayers.push([pm]);
  this.syncFromYjs();
},

removePlayer: function(idx) {
  _clearBounces();
  yCurrentPlayers.delete(idx, 1);
  this.syncFromYjs();
},

syncPlayerName: function(idx, val) {
  var pm = yCurrentPlayers.get(idx);
  if (pm) pm.set('name', val);
  if (this.players[idx]) this.players[idx].name = val;
},

tapScore: function(idx, delta) {
  var pm = yCurrentPlayers.get(idx);
  if (!pm) return;
  var current = pm.get('score');
  var newScore = current + delta;
  pm.set('score', newScore);
  this.players[idx].score = newScore;
  var b = _scoreBounce[idx];
  if (b) {
    clearTimeout(b.timer);
  } else {
    b = { originalScore: current };
    _scoreBounce[idx] = b;
  }
  var self = this;
  b.timer = setTimeout(function() {
    var pm = yCurrentPlayers.get(idx);
    if (!pm) { delete _scoreBounce[idx]; return; }
    var hist = JSON.parse(pm.get('history') || '[]');
    hist.push(b.originalScore);
    if (hist.length > 50) hist.shift();
    pm.set('history', JSON.stringify(hist));
    if (self.players[idx]) self.players[idx].history = hist.slice();
    delete _scoreBounce[idx];
  }, 800);
},

changeScore: function(idx, delta) {
  if (_scoreBounce[idx]) {
    var b = _scoreBounce[idx];
    clearTimeout(b.timer);
    var pm2 = yCurrentPlayers.get(idx);
    if (pm2) {
      var h = JSON.parse(pm2.get('history') || '[]');
      h.push(b.originalScore);
      if (h.length > 50) h.shift();
      pm2.set('history', JSON.stringify(h));
    }
    delete _scoreBounce[idx];
  }
  var pm = yCurrentPlayers.get(idx);
  if (!pm) return;
  var current = pm.get('score');
  var newScore = current + delta;
  var hist = JSON.parse(pm.get('history') || '[]');
  hist.push(current);
  if (hist.length > 50) hist.shift();
  pm.set('history', JSON.stringify(hist));
  pm.set('score', newScore);
  if (this.players[idx]) {
    this.players[idx].score = newScore;
    this.players[idx].history = hist.slice();
  }
},

undoScore: function(idx) {
  var pm = yCurrentPlayers.get(idx);
  if (!pm) return;
  if (_scoreBounce[idx]) {
    var b = _scoreBounce[idx];
    clearTimeout(b.timer);
    var orig = b.originalScore;
    delete _scoreBounce[idx];
    pm.set('score', orig);
    if (this.players[idx]) this.players[idx].score = orig;
    return;
  }
  var hist = JSON.parse(pm.get('history') || '[]');
  if (hist.length > 0) {
    var prev = hist.pop();
    pm.set('history', JSON.stringify(hist));
    pm.set('score', prev);
    if (this.players[idx]) {
      this.players[idx].score = prev;
      this.players[idx].history = hist.slice();
    }
  }
},

resetAllScores: function() {
  _clearBounces();
  for (var i = 0; i < yCurrentPlayers.length; i++) {
    var pm = yCurrentPlayers.get(i);
    pm.set('score', 0);
    pm.set('history', '[]');
  }
  this.syncFromYjs();
},

newGame: function() {
  _clearBounces();
  var newName = prompt('Game name:', this.gameName || '');
  if (newName === null) return;
  var names = [];
  for (var i = 0; i < yCurrentPlayers.length; i++) {
    names.push(yCurrentPlayers.get(i).get('name'));
  }
  if (yCurrentPlayers.length > 0) this.saveCurrentGame();
  yCurrentPlayers.delete(0, yCurrentPlayers.length);
  yMeta.set('gameName', newName);
  this.gameName = newName;
  for (var i = 0; i < names.length; i++) {
    var pm = new Y.Map();
    pm.set('name', names[i]);
    pm.set('score', 0);
    pm.set('history', '[]');
    yCurrentPlayers.push([pm]);
  }
  this.syncFromYjs();
},

saveCurrentGame: function() {
  var players = [];
  for (var i = 0; i < yCurrentPlayers.length; i++) {
    var pm = yCurrentPlayers.get(i);
    players.push({ name: pm.get('name'), finalScore: pm.get('score'), history: pm.get('history') || '[]' });
  }
  var id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
  ySavedGames.push([{
    id: id, date: new Date().toISOString(),
    gameName: yMeta.get('gameName') || '', players: players
  }]);
  this.savedGames.push(ySavedGames.get(ySavedGames.length - 1));
},

deleteSavedGame: function(idx) {
  ySavedGames.delete(idx, 1);
  this.savedGames.splice(idx, 1);
},

loadGame: function(idx) {
  var game = this.savedGames[idx];
  if (!game) return;
  _clearBounces();
  if (yCurrentPlayers.length > 0) {
    var hasScores = false;
    for (var i = 0; i < yCurrentPlayers.length; i++) {
      if (yCurrentPlayers.get(i).get('score') !== 0) { hasScores = true; break; }
    }
    if (hasScores && !confirm('Save current game before loading?')) return;
    if (hasScores) this.saveCurrentGame();
  }
  yCurrentPlayers.delete(0, yCurrentPlayers.length);
  for (var i = 0; i < game.players.length; i++) {
    var pm = new Y.Map();
    pm.set('name', game.players[i].name);
    pm.set('score', game.players[i].finalScore);
    pm.set('history', game.players[i].history || '[]');
    yCurrentPlayers.push([pm]);
  }
  yMeta.set('gameName', game.gameName || '');
  this.showPastGames = false;
  this.syncFromYjs();
},

syncGameName: function() {
  yMeta.set('gameName', this.gameName);
},

openCalc: function(idx) {
  this.calcTargetIdx = idx;
  this.calcPlayerName = this.players[idx] ? this.players[idx].name : '';
  this.calcCurrentScore = this.players[idx] ? this.players[idx].score : 0;
  this.calcDisplay = '0';
  this.calcOpen = true;
},

calcDigit: function(d) {
  if (this.calcDisplay === '0') this.calcDisplay = '' + d;
  else if (this.calcDisplay.length < 6) this.calcDisplay += d;
},

calcClear: function() { this.calcDisplay = '0'; },

calcBackspace: function() {
  this.calcDisplay = this.calcDisplay.length <= 1 ? '0' : this.calcDisplay.slice(0, -1);
},

calcChip: function(v) { this.calcDisplay = '' + v; },

calcApply: function(direction) {
  var amount = parseInt(this.calcDisplay) || 0;
  if (amount === 0) { this.calcOpen = false; return; }
  this.changeScore(this.calcTargetIdx, amount * direction);
  this.calcOpen = false;
},

sortedPlayers: function() {
  return this.players.map(function(p) {
    return { name: p.name, score: p.score };
  }).sort(function(a, b) { return b.score - a.score; });
},

previewFor: function(idx) {
  var p = this.players[idx];
  if (!p || !p.history || p.history.length === 0) return '';
  var arrow = ' → ';
  return p.history.slice(-8).join(arrow) + arrow + p.score;
},

savedGamesReversed: function() {
  var result = [];
  for (var i = this.savedGames.length - 1; i >= 0; i--) {
    result.push({ game: this.savedGames[i], idx: i });
  }
  return result;
},

formatDate: function(dateStr) {
  var d = new Date(dateStr);
  return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
},

gamePlayersHTML: function(game) {
  var sorted = game.players.slice().sort(function(a, b) { return b.finalScore - a.finalScore; });
  var w = sorted[0] || null;
  var esc = function(s) { return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;'); };
  return game.players.map(function(p) {
    var isW = w && p.name === w.name && p.finalScore === w.finalScore;
    return (isW ? '<span class="game-entry-winner">' : '<span>') + esc(p.name) + ': ' + p.finalScore + '</span>';
  }).join(' · ');
},

colorFor: function(idx) { return COLORS[idx % 8]; }
});

Alpine.start();

nil

// Window wrappers for test compatibility
window.addPlayer = function() { Alpine.store('app').addPlayer(); };
window.newGame = function() { Alpine.store('app').newGame(); };
window.resetAllScores = function() { Alpine.store('app').resetAllScores(); };
window.renderFromYjs = function() { Alpine.store('app').syncFromYjs(); };
window.updateScoreboard = function() { Alpine.store('app').showScoreboard = true; };
window.checkEmpty = function() {};
window.saveCurrentGame = function() { Alpine.store('app').saveCurrentGame(); };
window.deleteSavedGame = function(i) { Alpine.store('app').deleteSavedGame(i); };
window.loadGame = function(i) { Alpine.store('app').loadGame(i); };
window.renderPastGames = function() { Alpine.store('app').showPastGames = true; };
window.syncGameName = function() { Alpine.store('app').syncGameName(); };

// Init
setupSync(persistence, ydoc, function() { Alpine.store('app').syncFromYjs(); });
</script>

</body>
</html>

Shared blocks head-meta and import-map are defined in PWA score apps — shared blocks and included via cross-file noweb references.

Styles

Base reset and theme variables

Dark theme with CSS custom properties. The palette is intentionally limited to keep the UI cohesive: a deep blue background (--bg, --surface, --card), a red accent, green/red for +/− actions.

* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
  --bg: #1a1a2e;
  --surface: #16213e;
  --card: #0f3460;
  --accent: #e94560;
  --accent2: #533483;
  --text: #eee;
  --muted: #888;
  --green: #4ecca3;
  --red: #e94560;
  --radius: 16px;
}
html, body { height: 100%; }
body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
  background: var(--bg);
  color: var(--text);
  overflow-x: hidden;
  -webkit-tap-highlight-color: transparent;
}

Header bar

Sticky header with blur backdrop. Contains the app title, sync indicator dot, game name input, and action buttons. The sync dot animates a green pulse on successful websocket sync.

header {
  position: sticky; top: 0; z-index: 10;
  background: var(--surface);
  border-bottom: 1px solid rgba(255,255,255,.06);
  padding: 6px 10px;
  display: flex; align-items: center; gap: 6px;
  backdrop-filter: blur(12px);
}
header h1 { font-size: 1.1rem; font-weight: 800; letter-spacing: -.5px; flex-shrink: 0; }
header h1 span { color: var(--accent); }
.sync-dot {
  width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
  background: var(--muted); transition: background .3s;
}
body[data-ws="connected"] .sync-dot { background: var(--green); }
body[data-ws="denied"] .sync-dot { background: var(--accent); }
.sync-dot.syncing { animation: pulse .4s ease-out; }
@keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(78,204,163,.6); } 100% { box-shadow: 0 0 0 6px transparent; } }
.game-name-input {
  flex: 1; min-width: 0;
  background: none; border: none; border-bottom: 1.5px solid rgba(255,255,255,.1);
  color: var(--text); font-family: inherit; font-size: .8rem; font-weight: 600;
  padding: 2px 2px; outline: none; transition: border-color .15s;
}
.game-name-input:focus { border-color: var(--accent); }
.game-name-input::placeholder { color: var(--muted); font-weight: 400; }
.header-actions { display: flex; gap: 4px; flex-shrink: 0; }
.icon-btn {
  background: rgba(255,255,255,.08); border: none; color: var(--text);
  width: 38px; height: 38px; border-radius: 10px; cursor: pointer;
  display: grid; place-items: center; font-size: 1rem;
  transition: background .15s; flex-shrink: 0;
}
.icon-btn:active { background: rgba(255,255,255,.18); }
.icon-btn.primary { background: var(--accent); }
.icon-btn.primary:active { background: #c23350; }

Calculator bottom sheet

The calculator slides up from the bottom (slideUp animation) over a semi-transparent overlay. It provides quick-pick chips (1, 2, 5, 10…) and a numpad for entering arbitrary amounts, then apply as + or −.

.calc-overlay {
  position: fixed; inset: 0; background: rgba(0,0,0,.5);
  z-index: 200; display: none;
  flex-direction: column; justify-content: flex-end; align-items: center;
}
.calc-overlay.open { display: flex; }
.calc-sheet {
  background: var(--surface); border-radius: 18px 18px 0 0;
  width: 100%; max-width: 420px;
  padding: 14px 12px calc(10px + env(safe-area-inset-bottom));
  animation: slideUp .2s ease-out;
}
@keyframes slideUp {
  from { transform: translateY(100%); }
  to { transform: translateY(0); }
}
.calc-header {
  display: flex; justify-content: space-between; align-items: center;
  margin-bottom: 8px; padding: 0 4px;
}
.calc-player-name { font-weight: 700; font-size: .95rem; }
.calc-current-score { font-weight: 800; font-size: .95rem; color: var(--muted); }
.calc-display {
  font-size: 2rem; font-weight: 800; text-align: right;
  padding: 8px 14px; background: rgba(255,255,255,.05); border-radius: 12px;
  margin-bottom: 8px; font-variant-numeric: tabular-nums;
  min-height: 44px; display: flex; align-items: center; justify-content: flex-end;
}
.calc-chips {
  display: flex; gap: 6px; margin-bottom: 8px;
  overflow-x: auto; padding-bottom: 2px;
}
.calc-chip {
  padding: 8px 16px; border-radius: 20px; border: 1.5px solid rgba(255,255,255,.12);
  background: transparent; color: var(--text); font-size: .85rem; cursor: pointer;
  font-weight: 600; font-family: inherit; white-space: nowrap;
  transition: background .12s;
}
.calc-chip:active { background: rgba(255,255,255,.12); }
.calc-numpad {
  display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-bottom: 8px;
}
.calc-numpad button {
  padding: 16px; border-radius: 12px; border: none;
  background: rgba(255,255,255,.07); color: var(--text);
  font-size: 1.3rem; font-weight: 600; cursor: pointer;
  font-family: inherit; transition: background .1s;
}
.calc-numpad button:active { background: rgba(255,255,255,.18); }
.calc-numpad .calc-fn { color: var(--muted); font-size: 1.1rem; }
.calc-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.calc-apply {
  padding: 18px; border-radius: 14px; border: none;
  font-size: 1.5rem; font-weight: 800; cursor: pointer; color: #fff;
  font-family: inherit; transition: transform .08s;
}
.calc-apply:active { transform: scale(.95); }
.calc-sub { background: var(--red); }
.calc-add { background: var(--green); }

Player cards

Each player is a card with a colored left stripe, editable name, score with large +/− buttons, and a history preview trail. The score taps feel responsive thanks to the scale(.92) active transform.

#players {
  padding: 8px 10px 16px;
  display: flex; flex-direction: column; gap: 8px;
}
.player-card {
  background: var(--card);
  border-radius: 14px;
  padding: 10px 10px 6px;
  position: relative;
  overflow: hidden;
  animation: slideIn .2s ease-out;
}
@keyframes slideIn {
  from { opacity: 0; transform: translateY(8px); }
  to { opacity: 1; transform: translateY(0); }
}
.player-card .color-stripe {
  position: absolute; left: 0; top: 0; bottom: 0; width: 4px;
}
.card-top {
  display: flex; align-items: center; gap: 6px;
  padding-left: 8px; margin-bottom: 6px;
}
.player-name {
  flex: 1; min-width: 0;
  font-size: .85rem; font-weight: 600;
  background: none; border: none; color: var(--text);
  font-family: inherit; outline: none; padding: 2px 0;
}
.player-name::placeholder { color: var(--muted); }
.card-action {
  background: rgba(255,255,255,.06); border: none; color: var(--muted);
  width: 32px; height: 32px; border-radius: 8px; cursor: pointer;
  display: grid; place-items: center; font-size: .8rem;
  flex-shrink: 0; transition: background .12s;
}
.card-action:active { background: rgba(255,255,255,.15); }
.card-action.remove-btn:active { background: var(--red); color: #fff; }
.card-score-row {
  display: flex; align-items: center; gap: 8px;
  padding-left: 8px;
}
.score-btn {
  width: 56px; height: 52px; border-radius: 12px; border: none;
  font-size: 1.5rem; font-weight: 700; cursor: pointer;
  display: grid; place-items: center; color: #fff;
  transition: transform .08s; flex-shrink: 0;
}
.score-btn:active { transform: scale(.92); }
.score-btn.minus { background: var(--red); }
.score-btn.plus { background: var(--green); }
.player-score {
  flex: 1; font-size: 2.2rem; font-weight: 800;
  text-align: center; font-variant-numeric: tabular-nums;
  line-height: 1; cursor: pointer;
  min-height: 52px; display: flex; align-items: center; justify-content: center;
}
.player-card .history-preview {
  font-size: .65rem; color: var(--muted);
  padding: 4px 0 0 8px;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}

Modals

Shared overlay + modal styling for scoreboard and past-games dialogs. The popIn animation gives a subtle scale entrance.

.modal-overlay {
  position: fixed; inset: 0; background: rgba(0,0,0,.6);
  display: none; place-items: center; z-index: 100;
  backdrop-filter: blur(4px);
}
.modal-overlay.open { display: grid; }
.modal {
  background: var(--surface); border-radius: 20px;
  padding: 28px; width: min(400px, 92vw);
  animation: popIn .2s ease-out;
  max-height: 85vh; overflow-y: auto;
}
@keyframes popIn {
  from { opacity: 0; transform: scale(.9); }
  to { opacity: 1; transform: scale(1); }
}
.modal h2 { margin-bottom: 16px; font-size: 1.2rem; }
.modal-scores {
  display: flex; flex-direction: column; gap: 10px;
  max-height: 50vh; overflow-y: auto;
}
.modal-row {
  display: flex; justify-content: space-between; align-items: center;
  padding: 10px 14px; background: rgba(255,255,255,.05); border-radius: 12px;
}
.modal-row .rank { font-weight: 800; font-size: 1.1rem; margin-right: 12px; }
.modal-row .rank.gold { color: #ffd700; }
.modal-row .rank.silver { color: #c0c0c0; }
.modal-row .rank.bronze { color: #cd7f32; }
.modal-name { font-weight: 600; }
.modal-score { font-weight: 800; font-size: 1.2rem; }
.modal-close {
  margin-top: 16px; width: 100%; padding: 14px; border-radius: 12px;
  background: var(--accent); border: none; color: #fff;
  font-size: .95rem; font-weight: 700; cursor: pointer; font-family: inherit;
}

Game archive entries

Styles for past-game cards shown in the Past Games modal. The winner’s name is highlighted in gold.

.empty-state {
  text-align: center; padding: 60px 20px; color: var(--muted);
}
.empty-state .emoji { font-size: 3rem; margin-bottom: 12px; }
.empty-state p { font-size: .95rem; line-height: 1.5; }
.game-entry {
  padding: 14px; background: rgba(255,255,255,.05); border-radius: 12px;
  margin-bottom: 8px;
}
.game-entry-header {
  display: flex; justify-content: space-between; align-items: center;
  margin-bottom: 6px;
}
.game-entry-name { font-weight: 700; font-size: .95rem; }
.game-entry-date { font-size: .7rem; color: var(--muted); }
.game-entry-players { font-size: .8rem; color: var(--muted); line-height: 1.5; }
.game-entry-winner { color: #ffd700; font-weight: 600; }
.game-entry-load, .game-entry-delete {
  background: none; border: none; color: var(--muted); cursor: pointer;
  font-size: .85rem; padding: 4px 8px; border-radius: 6px;
  transition: background .15s, color .15s;
}
.game-entry-load:hover { background: var(--green); color: #fff; }
.game-entry-delete:hover { background: var(--red); color: #fff; }
.no-games { text-align: center; color: var(--muted); padding: 20px; font-size: .9rem; }

User interface

Header bar

Top bar with the app title (the “T” in accent red), a websocket sync indicator dot, an inline game-name text field, and action buttons: reset scores, new game, past games archive, scoreboard, and add player.

<header>
  <h1><span>T</span>ally</h1>
  <div class="sync-dot" title="Offline"></div>
  <input type="text" id="game-name" class="game-name-input" placeholder="Game name&#x2026;" spellcheck="false"
    x-model="$store.app.gameName" @input="$store.app.syncGameName()">
  <div class="header-actions">
    <button class="icon-btn" id="reset-btn" title="Reset Scores"
      @click="if(confirm('Reset all scores to 0?')) $store.app.resetAllScores()">&#x21bb;</button>
    <button class="icon-btn" id="new-game-btn" title="New Game"
      @click="$store.app.newGame()">&#x25b6;</button>
    <button class="icon-btn" id="games-btn" title="Past Games"
      @click="$store.app.showPastGames = true">&#x1f4cb;</button>
    <button class="icon-btn" title="Scoreboard"
      @click="$store.app.showScoreboard = true">&#x1f3c6;</button>
    <button class="icon-btn primary" id="add-btn" title="Add Player"
      @click="$store.app.addPlayer()">+</button>
  </div>
</header>

Player list

The main content area. Uses Alpine’s x-for over the reactive players array. Each card shows an editable name, undo/remove buttons, large −/+ score buttons (the score itself opens the calculator on tap), and a history breadcrumb trail.

<div id="players">
  <div class="empty-state" id="empty-msg" x-show="$store.app.players.length === 0">
    <div class="emoji">&#x1f3b2;</div>
    <p>No players yet.<br>Add players to start tracking scores!</p>
  </div>
  <template x-for="(p, idx) in $store.app.players" :key="idx">
    <div class="player-card" :data-yindex="idx">
      <div class="color-stripe" :style="'background:' + $store.app.colorFor(idx)"></div>
      <div class="card-top">
        <input class="player-name" spellcheck="false" :value="p.name"
          @focus="$el.select()" @input="$store.app.syncPlayerName(idx, $el.value)">
        <button class="card-action undo-btn" title="Undo"
          @click="$store.app.undoScore(idx)">&#x21a9;</button>
        <button class="card-action remove-btn" title="Remove"
          @click="if(confirm('Remove player?')) $store.app.removePlayer(idx)">&#x2715;</button>
      </div>
      <div class="card-score-row">
        <button class="score-btn minus"
          @click="$store.app.tapScore(idx, -1)">&#x2212;</button>
        <div class="player-score" x-text="p.score"
          @click="$store.app.openCalc(idx)"></div>
        <button class="score-btn plus"
          @click="$store.app.tapScore(idx, 1)">+</button>
      </div>
      <div class="history-preview" x-text="$store.app.previewFor(idx)"></div>
    </div>
  </template>
</div>

Scoreboard modal

Ranks all players by score (descending). Top 3 get gold/silver/bronze styling. Closes on overlay click or the close button.

<!-- Scoreboard Modal -->
<div class="modal-overlay" id="modal-scoreboard"
  :class="{ open: $store.app.showScoreboard }"
  @click.self="$store.app.showScoreboard = false">
  <div class="modal">
    <h2>&#x1f3c6; Scoreboard</h2>
    <div class="modal-scores" id="scoreboard-list">
      <template x-for="(entry, i) in $store.app.sortedPlayers()" :key="i">
        <div class="modal-row">
          <div>
            <span class="rank" :class="i===0?'gold':i===1?'silver':i===2?'bronze':''"
              x-text="'#'+(i+1)"></span>
            <span class="modal-name" x-text="entry.name"></span>
          </div>
          <span class="modal-score" x-text="entry.score"></span>
        </div>
      </template>
    </div>
    <button class="modal-close"
      @click="$store.app.showScoreboard = false">Close</button>
  </div>
</div>

Past games modal

Shows archived games in reverse chronological order. Each entry displays the game name, date, player scores, and highlights the winner in gold. Individual games can be deleted.

<!-- Past Games Modal -->
<div class="modal-overlay" id="modal-games"
  :class="{ open: $store.app.showPastGames }"
  @click.self="$store.app.showPastGames = false">
  <div class="modal">
    <h2>&#x1f4cb; Past Games</h2>
    <div id="past-games-list">
      <div class="no-games" x-show="$store.app.savedGames.length === 0">No saved games yet.</div>
      <template x-for="(item, i) in $store.app.savedGamesReversed()" :key="item.idx">
        <div class="game-entry" :data-game-index="item.idx">
          <div class="game-entry-header">
            <span class="game-entry-name" x-show="item.game.gameName" x-text="item.game.gameName"></span>
            <span class="game-entry-date" x-text="$store.app.formatDate(item.game.date)"></span>
            <button class="game-entry-load" @click="$store.app.loadGame(item.idx)">&#x25b6;</button>
            <button class="game-entry-delete" @click="$store.app.deleteSavedGame(item.idx)">&#x1f5d1;</button>
          </div>
          <div class="game-entry-players" x-html="$store.app.gamePlayersHTML(item.game)"></div>
        </div>
      </template>
    </div>
    <button class="modal-close"
      @click="$store.app.showPastGames = false">Close</button>
  </div>
</div>

Calculator sheet

Bottom sheet for entering larger score adjustments. Shows the target player name and current score, a row of quick-pick chips, a full numpad, and two large apply buttons (subtract in red, add in green).

<!-- Calculator bottom sheet -->
<div id="calc-overlay" class="calc-overlay"
  :class="{ open: $store.app.calcOpen }"
  @click.self="$store.app.calcOpen = false">
  <div class="calc-sheet">
    <div class="calc-header">
      <span id="calc-player-name" class="calc-player-name" x-text="$store.app.calcPlayerName"></span>
      <span id="calc-current-score" class="calc-current-score" x-text="$store.app.calcCurrentScore"></span>
    </div>
    <div id="calc-display" class="calc-display" x-text="$store.app.calcDisplay"></div>
    <div class="calc-chips">
      <button class="calc-chip" @click="$store.app.calcChip(1)">1</button>
      <button class="calc-chip" @click="$store.app.calcChip(2)">2</button>
      <button class="calc-chip" @click="$store.app.calcChip(5)">5</button>
      <button class="calc-chip" @click="$store.app.calcChip(10)">10</button>
      <button class="calc-chip" @click="$store.app.calcChip(20)">20</button>
      <button class="calc-chip" @click="$store.app.calcChip(50)">50</button>
      <button class="calc-chip" @click="$store.app.calcChip(100)">100</button>
    </div>
    <div class="calc-numpad">
      <button data-digit="7" @click="$store.app.calcDigit('7')">7</button>
      <button data-digit="8" @click="$store.app.calcDigit('8')">8</button>
      <button data-digit="9" @click="$store.app.calcDigit('9')">9</button>
      <button data-digit="4" @click="$store.app.calcDigit('4')">4</button>
      <button data-digit="5" @click="$store.app.calcDigit('5')">5</button>
      <button data-digit="6" @click="$store.app.calcDigit('6')">6</button>
      <button data-digit="1" @click="$store.app.calcDigit('1')">1</button>
      <button data-digit="2" @click="$store.app.calcDigit('2')">2</button>
      <button data-digit="3" @click="$store.app.calcDigit('3')">3</button>
      <button class="calc-fn" @click="$store.app.calcClear()">C</button>
      <button data-digit="0" @click="$store.app.calcDigit('0')">0</button>
      <button class="calc-fn" @click="$store.app.calcBackspace()">&#x232b;</button>
    </div>
    <div class="calc-actions">
      <button class="calc-apply calc-sub" @click="$store.app.calcApply(-1)">&#x2212;</button>
      <button class="calc-apply calc-add" @click="$store.app.calcApply(1)">+</button>
    </div>
  </div>
</div>

Application logic

Imports and CRDT setup

The Yjs document (ydoc) is the single source of truth. Three shared types live inside it:

  • currentPlayers (Y.Array of Y.Maps): the live game state
  • savedGames (Y.Array): archive of finished games
  • meta (Y.Map): game-level metadata (currently just gameName)

IndexeddbPersistence keeps a local copy in the browser’s IndexedDB so the app works fully offline. Global window assignments expose internals for the test harness.

import Alpine from 'alpinejs';
import * as Y from 'yjs';
import { IndexeddbPersistence } from 'y-indexeddb';
import { WebsocketProvider } from 'y-websocket';

var _roomName = new URLSearchParams(location.search).get('room') || 'tally';
var ydoc = new Y.Doc();
var persistence = new IndexeddbPersistence(_roomName, ydoc);
var yCurrentPlayers = ydoc.getArray('currentPlayers');
var ySavedGames = ydoc.getArray('savedGames');
var yMeta = ydoc.getMap('meta');
var COLORS = ['#e94560','#4ecca3','#533483','#f9a826','#3fc1c9','#fc5185','#8dc6ff','#6c5ce7'];

window.Alpine = Alpine;
window.ydoc = ydoc;
window.Y = Y;
window.yCurrentPlayers = yCurrentPlayers;
window.ySavedGames = ySavedGames;
window.yMeta = yMeta;

Score debounce

Rapid +/− taps within 800 ms are coalesced into a single history entry. _scoreBounce[idx] tracks the original score before the burst began. When the timer fires, one history entry is pushed (the pre-burst value). _clearBounces() cancels all pending timers — called before destructive operations (remove player, reset, new game) to avoid stale writes.

var _scoreBounce = {};
function _clearBounces() {
  Object.keys(_scoreBounce).forEach(function(k) { clearTimeout(_scoreBounce[k].timer); });
  _scoreBounce = {};
}

Alpine store

Reactive state

Alpine reactive properties that drive the UI. The players array is a denormalized snapshot of the Yjs currentPlayers — kept in sync by syncFromYjs().

gameName: '',
players: [],
savedGames: [],
showScoreboard: false,
showPastGames: false,
calcOpen: false,
calcDisplay: '0',
calcTargetIdx: -1,
calcPlayerName: '',
calcCurrentScore: 0,

CRDT synchronization

Reads the full state from Yjs shared types into Alpine reactive properties. Called on init, after local mutations, and when remote changes arrive. Skips overwriting the game-name input if it currently has focus (avoids cursor jumps while the user is typing).

syncFromYjs: function() {
  if (document.activeElement !== document.getElementById('game-name')) {
    this.gameName = yMeta.get('gameName') || '';
  }
  var players = [];
  for (var i = 0; i < yCurrentPlayers.length; i++) {
    var pm = yCurrentPlayers.get(i);
    players.push({
      name: pm.get('name'),
      score: pm.get('score'),
      history: JSON.parse(pm.get('history') || '[]')
    });
  }
  this.players = players;
  var saved = [];
  for (var i = 0; i < ySavedGames.length; i++) {
    saved.push(ySavedGames.get(i));
  }
  this.savedGames = saved;
},

Player management

CRUD for players. Each player is a Y.Map with name, score, and history (JSON-stringified array of previous scores). syncPlayerName writes directly to the Y.Map on each keystroke for real-time cross-device sync.

addPlayer: function() {
  var count = yCurrentPlayers.length;
  var pm = new Y.Map();
  pm.set('name', 'Player ' + (count + 1));
  pm.set('score', 0);
  pm.set('history', '[]');
  yCurrentPlayers.push([pm]);
  this.syncFromYjs();
},

removePlayer: function(idx) {
  _clearBounces();
  yCurrentPlayers.delete(idx, 1);
  this.syncFromYjs();
},

syncPlayerName: function(idx, val) {
  var pm = yCurrentPlayers.get(idx);
  if (pm) pm.set('name', val);
  if (this.players[idx]) this.players[idx].name = val;
},

Score operations

Three ways to change a score:

  • tapScore: +1/−1 buttons. Uses the debounce system — rapid taps accumulate into the current score immediately but only commit one history entry after 800 ms.
  • changeScore: calculator apply. Flushes any pending bounce first, then pushes a single history entry and updates the score atomically.
  • undoScore: if a bounce is in progress, cancels it and reverts to the pre-burst score. Otherwise pops the last entry from the history stack.

tapScore: function(idx, delta) {
  var pm = yCurrentPlayers.get(idx);
  if (!pm) return;
  var current = pm.get('score');
  var newScore = current + delta;
  pm.set('score', newScore);
  this.players[idx].score = newScore;
  var b = _scoreBounce[idx];
  if (b) {
    clearTimeout(b.timer);
  } else {
    b = { originalScore: current };
    _scoreBounce[idx] = b;
  }
  var self = this;
  b.timer = setTimeout(function() {
    var pm = yCurrentPlayers.get(idx);
    if (!pm) { delete _scoreBounce[idx]; return; }
    var hist = JSON.parse(pm.get('history') || '[]');
    hist.push(b.originalScore);
    if (hist.length > 50) hist.shift();
    pm.set('history', JSON.stringify(hist));
    if (self.players[idx]) self.players[idx].history = hist.slice();
    delete _scoreBounce[idx];
  }, 800);
},

changeScore: function(idx, delta) {
  if (_scoreBounce[idx]) {
    var b = _scoreBounce[idx];
    clearTimeout(b.timer);
    var pm2 = yCurrentPlayers.get(idx);
    if (pm2) {
      var h = JSON.parse(pm2.get('history') || '[]');
      h.push(b.originalScore);
      if (h.length > 50) h.shift();
      pm2.set('history', JSON.stringify(h));
    }
    delete _scoreBounce[idx];
  }
  var pm = yCurrentPlayers.get(idx);
  if (!pm) return;
  var current = pm.get('score');
  var newScore = current + delta;
  var hist = JSON.parse(pm.get('history') || '[]');
  hist.push(current);
  if (hist.length > 50) hist.shift();
  pm.set('history', JSON.stringify(hist));
  pm.set('score', newScore);
  if (this.players[idx]) {
    this.players[idx].score = newScore;
    this.players[idx].history = hist.slice();
  }
},

undoScore: function(idx) {
  var pm = yCurrentPlayers.get(idx);
  if (!pm) return;
  if (_scoreBounce[idx]) {
    var b = _scoreBounce[idx];
    clearTimeout(b.timer);
    var orig = b.originalScore;
    delete _scoreBounce[idx];
    pm.set('score', orig);
    if (this.players[idx]) this.players[idx].score = orig;
    return;
  }
  var hist = JSON.parse(pm.get('history') || '[]');
  if (hist.length > 0) {
    var prev = hist.pop();
    pm.set('history', JSON.stringify(hist));
    pm.set('score', prev);
    if (this.players[idx]) {
      this.players[idx].score = prev;
      this.players[idx].history = hist.slice();
    }
  }
},

resetAllScores: function() {
  _clearBounces();
  for (var i = 0; i < yCurrentPlayers.length; i++) {
    var pm = yCurrentPlayers.get(i);
    pm.set('score', 0);
    pm.set('history', '[]');
  }
  this.syncFromYjs();
},

Game lifecycle

“New Game” prompts for a game name (prefilled with the previous game’s name), archives the current game (scores, player names, timestamp) into ySavedGames, then recreates the same players with zeroed scores. Cancelling the prompt aborts the operation. saveCurrentGame snapshots each player’s final score into a plain object (not a Y.Map — saved games are immutable).

newGame: function() {
  _clearBounces();
  var newName = prompt('Game name:', this.gameName || '');
  if (newName === null) return;
  var names = [];
  for (var i = 0; i < yCurrentPlayers.length; i++) {
    names.push(yCurrentPlayers.get(i).get('name'));
  }
  if (yCurrentPlayers.length > 0) this.saveCurrentGame();
  yCurrentPlayers.delete(0, yCurrentPlayers.length);
  yMeta.set('gameName', newName);
  this.gameName = newName;
  for (var i = 0; i < names.length; i++) {
    var pm = new Y.Map();
    pm.set('name', names[i]);
    pm.set('score', 0);
    pm.set('history', '[]');
    yCurrentPlayers.push([pm]);
  }
  this.syncFromYjs();
},

saveCurrentGame: function() {
  var players = [];
  for (var i = 0; i < yCurrentPlayers.length; i++) {
    var pm = yCurrentPlayers.get(i);
    players.push({ name: pm.get('name'), finalScore: pm.get('score'), history: pm.get('history') || '[]' });
  }
  var id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
  ySavedGames.push([{
    id: id, date: new Date().toISOString(),
    gameName: yMeta.get('gameName') || '', players: players
  }]);
  this.savedGames.push(ySavedGames.get(ySavedGames.length - 1));
},

deleteSavedGame: function(idx) {
  ySavedGames.delete(idx, 1);
  this.savedGames.splice(idx, 1);
},

loadGame: function(idx) {
  var game = this.savedGames[idx];
  if (!game) return;
  _clearBounces();
  if (yCurrentPlayers.length > 0) {
    var hasScores = false;
    for (var i = 0; i < yCurrentPlayers.length; i++) {
      if (yCurrentPlayers.get(i).get('score') !== 0) { hasScores = true; break; }
    }
    if (hasScores && !confirm('Save current game before loading?')) return;
    if (hasScores) this.saveCurrentGame();
  }
  yCurrentPlayers.delete(0, yCurrentPlayers.length);
  for (var i = 0; i < game.players.length; i++) {
    var pm = new Y.Map();
    pm.set('name', game.players[i].name);
    pm.set('score', game.players[i].finalScore);
    pm.set('history', game.players[i].history || '[]');
    yCurrentPlayers.push([pm]);
  }
  yMeta.set('gameName', game.gameName || '');
  this.showPastGames = false;
  this.syncFromYjs();
},

syncGameName: function() {
  yMeta.set('gameName', this.gameName);
},

Calculator logic

The calculator is a simple accumulator: digits build a string display (max 6 chars), chips replace it with a preset value, and the apply buttons convert the display to an integer and call changeScore with the appropriate sign.

openCalc: function(idx) {
  this.calcTargetIdx = idx;
  this.calcPlayerName = this.players[idx] ? this.players[idx].name : '';
  this.calcCurrentScore = this.players[idx] ? this.players[idx].score : 0;
  this.calcDisplay = '0';
  this.calcOpen = true;
},

calcDigit: function(d) {
  if (this.calcDisplay === '0') this.calcDisplay = '' + d;
  else if (this.calcDisplay.length < 6) this.calcDisplay += d;
},

calcClear: function() { this.calcDisplay = '0'; },

calcBackspace: function() {
  this.calcDisplay = this.calcDisplay.length <= 1 ? '0' : this.calcDisplay.slice(0, -1);
},

calcChip: function(v) { this.calcDisplay = '' + v; },

calcApply: function(direction) {
  var amount = parseInt(this.calcDisplay) || 0;
  if (amount === 0) { this.calcOpen = false; return; }
  this.changeScore(this.calcTargetIdx, amount * direction);
  this.calcOpen = false;
},

UI helpers

Pure functions that derive display data from the reactive state: ranking for the scoreboard, history breadcrumb trail, reverse-chronological game list, and date formatting. gamePlayersHTML returns pre-rendered HTML (used with x-html) to highlight the winner in gold.

sortedPlayers: function() {
  return this.players.map(function(p) {
    return { name: p.name, score: p.score };
  }).sort(function(a, b) { return b.score - a.score; });
},

previewFor: function(idx) {
  var p = this.players[idx];
  if (!p || !p.history || p.history.length === 0) return '';
  var arrow = ' → ';
  return p.history.slice(-8).join(arrow) + arrow + p.score;
},

savedGamesReversed: function() {
  var result = [];
  for (var i = this.savedGames.length - 1; i >= 0; i--) {
    result.push({ game: this.savedGames[i], idx: i });
  }
  return result;
},

formatDate: function(dateStr) {
  var d = new Date(dateStr);
  return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
},

gamePlayersHTML: function(game) {
  var sorted = game.players.slice().sort(function(a, b) { return b.finalScore - a.finalScore; });
  var w = sorted[0] || null;
  var esc = function(s) { return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;'); };
  return game.players.map(function(p) {
    var isW = w && p.name === w.name && p.finalScore === w.finalScore;
    return (isW ? '<span class="game-entry-winner">' : '<span>') + esc(p.name) + ': ' + p.finalScore + '</span>';
  }).join(' · ');
},

colorFor: function(idx) { return COLORS[idx % 8]; }

Sync initialization

Wires up WebSocket sync, remote-change rendering, and init via the shared setupSync helper from PWA score apps — shared blocks. The sync dot is updated via the data-ws body attribute set by the shared block.

Window API (test compatibility)

Exposes store actions as plain window functions so the Playwright test suite can call them directly via page.evaluate().

// Window wrappers for test compatibility
window.addPlayer = function() { Alpine.store('app').addPlayer(); };
window.newGame = function() { Alpine.store('app').newGame(); };
window.resetAllScores = function() { Alpine.store('app').resetAllScores(); };
window.renderFromYjs = function() { Alpine.store('app').syncFromYjs(); };
window.updateScoreboard = function() { Alpine.store('app').showScoreboard = true; };
window.checkEmpty = function() {};
window.saveCurrentGame = function() { Alpine.store('app').saveCurrentGame(); };
window.deleteSavedGame = function(i) { Alpine.store('app').deleteSavedGame(i); };
window.loadGame = function(i) { Alpine.store('app').loadGame(i); };
window.renderPastGames = function() { Alpine.store('app').showPastGames = true; };
window.syncGameName = function() { Alpine.store('app').syncGameName(); };

Sync initialization (call)

Calls the shared setupSync to wire WebSocket, remote-change rendering, init, and service worker registration.

// Init
setupSync(persistence, ydoc, function() { Alpine.store('app').syncFromYjs(); });

Shared blocks sw and manifest are defined in PWA score apps — shared blocks and loaded via the Library of Babel (see local variables at end of file).

Tests

Playwright tests for the Tally score-keeper app with Yjs persistence. Each test runs sequentially in a single browser context, building on the state left by previous tests.

"""Playwright tests for
 the Tally score-keeper app with Yjs persistence."""

import sys
from playwright.sync_api import sync_playwright

URL = "http://192.168.2.5:9682/debug/"
READY = 'body[data-ready="true"]'


def run_tests():
    with sync_playwright() as p:
        browser = p.chromium.launch()
        # Use a fresh context each time to isolate IndexedDB
        ctx = browser.new_context()
        page = ctx.new_page()

        # Clear any leftover IndexedDB from previous runs
        page.goto(URL)
        page.wait_for_selector(READY, timeout=15000)
        page.evaluate("""() => {
            yCurrentPlayers.delete(0, yCurrentPlayers.length);
            while (ySavedGames.length > 0) ySavedGames.delete(0, 1);
        }""")
        page.reload()
        page.wait_for_selector(READY, timeout=15000)

        passed = 0
        failed = 0

        def test(name, fn):
            nonlocal passed, failed
            try:
                fn()
                print(f"  PASS  {name}", flush=True)
                passed += 1
            except Exception as e:
                print(f"  FAIL  {name}: {e}", flush=True)
                failed += 1

        # ── Initial state ────────────────────────────────────

        def t_initial_empty():
            assert page.locator("#empty-msg").is_visible()
            assert page.locator(".player-card").count() == 0

        test("initial state shows empty message", t_initial_empty)

        # ── Add players ──────────────────────────────────────

        def t_add_first_player():
            page.locator("#add-btn").click()
            page.wait_for_selector(".player-card")
            assert page.locator(".player-card").count() == 1
            assert page.locator("#empty-msg").is_hidden()
            name_input = page.locator(".player-name").first
            assert name_input.input_value() == "Player 1"
            assert page.locator(".player-score").first.inner_text() == "0"

        test("add first player", t_add_first_player)

        def t_add_second_player():
            page.locator("#add-btn").click()
            page.wait_for_timeout(200)
            assert page.locator(".player-card").count() == 2
            assert page.locator(".player-name").nth(1).input_value() == "Player 2"

        test("add second player", t_add_second_player)

        def t_add_third_player():
            page.locator("#add-btn").click()
            page.wait_for_timeout(200)
            assert page.locator(".player-card").count() == 3

        test("add third player", t_add_third_player)

        # ── Yjs persistence of players ───────────────────────

        def t_yjs_player_count():
            count = page.evaluate("yCurrentPlayers.length")
            assert count == 3, f"expected 3, got {count}"

        test("yjs has 3 players", t_yjs_player_count)

        # ── Score increment/decrement (step=1) ──────────────

        def t_increment_score():
            card = page.locator(".player-card").first
            card.locator(".plus").click()
            card.locator(".plus").click()
            card.locator(".plus").click()
            page.wait_for_timeout(100)
            assert card.locator(".player-score").inner_text() == "3"

        test("increment score (step=1, 3 clicks)", t_increment_score)

        def t_yjs_score_synced():
            score = page.evaluate("yCurrentPlayers.get(0).get('score')")
            assert score == 3, f"expected 3, got {score}"

        test("yjs score synced after increment", t_yjs_score_synced)

        def t_decrement_score():
            card = page.locator(".player-card").first
            card.locator(".minus").click()
            page.wait_for_timeout(100)
            assert card.locator(".player-score").inner_text() == "2"

        test("decrement score", t_decrement_score)

        # ── Calculator ──────────────────────────────────────

        def t_calc_opens():
            card = page.locator(".player-card").first
            card.locator(".player-score").click()
            page.wait_for_selector("#calc-overlay.open", timeout=2000)
            assert page.locator("#calc-overlay").is_visible()
            # close without applying
            page.locator("#calc-overlay").click(position={"x": 10, "y": 10})
            page.wait_for_timeout(100)

        test("calculator opens on score click", t_calc_opens)

        def t_calc_add_5():
            card = page.locator(".player-card").first
            card.locator(".player-score").click()
            page.wait_for_selector("#calc-overlay.open", timeout=2000)
            page.locator('[data-digit="5"]').click()
            page.locator(".calc-add").click()
            page.wait_for_timeout(100)
            assert card.locator(".player-score").inner_text() == "7"

        test("calculator +5", t_calc_add_5)

        def t_calc_add_10():
            card = page.locator(".player-card").first
            card.locator(".player-score").click()
            page.wait_for_selector("#calc-overlay.open", timeout=2000)
            page.locator('[data-digit="1"]').click()
            page.locator('[data-digit="0"]').click()
            page.locator(".calc-add").click()
            page.wait_for_timeout(100)
            assert card.locator(".player-score").inner_text() == "17"

        test("calculator +10 via numpad", t_calc_add_10)

        def t_calc_add_3():
            card = page.locator(".player-card").first
            card.locator(".player-score").click()
            page.wait_for_selector("#calc-overlay.open", timeout=2000)
            page.locator('[data-digit="3"]').click()
            page.locator(".calc-add").click()
            page.wait_for_timeout(100)
            assert card.locator(".player-score").inner_text() == "20"

        test("calculator +3", t_calc_add_3)

        def t_calc_chip():
            card = page.locator(".player-card").first
            card.locator(".player-score").click()
            page.wait_for_selector("#calc-overlay.open", timeout=2000)
            page.locator(".calc-chip", has_text="20").click()
            page.locator(".calc-sub").click()
            page.wait_for_timeout(100)
            assert card.locator(".player-score").inner_text() == "0"

        test("calculator chip subtract", t_calc_chip)

        # ── Undo ─────────────────────────────────────────────

        def t_undo():
            card = page.locator(".player-card").first
            card.locator(".undo-btn").click()
            page.wait_for_timeout(100)
            assert card.locator(".player-score").inner_text() == "20"

        test("undo restores previous score", t_undo)

        def t_undo_again():
            card = page.locator(".player-card").first
            card.locator(".undo-btn").click()
            page.wait_for_timeout(100)
            assert card.locator(".player-score").inner_text() == "17"

        test("undo again", t_undo_again)

        # ── History preview ──────────────────────────────────

        def t_history_preview():
            card = page.locator(".player-card").first
            preview = card.locator(".history-preview")
            text = preview.inner_text()
            assert "→" in text
            assert text.strip().endswith("17")

        test("history preview shows trail", t_history_preview)

        # ── Player name editing ──────────────────────────────

        def t_rename_player():
            name_input = page.locator(".player-name").first
            name_input.fill("Alice")
            name_input.dispatch_event("input")
            page.wait_for_timeout(100)
            assert name_input.input_value() == "Alice"
            yname = page.evaluate("yCurrentPlayers.get(0).get('name')")
            assert yname == "Alice", f"yjs name: {yname}"

        test("rename player syncs to yjs", t_rename_player)

        # ── Scoreboard modal ─────────────────────────────────

        def t_scoreboard_opens():
            page.locator("header .icon-btn[title='Scoreboard']").click()
            modal = page.locator("#modal-scoreboard")
            assert modal.is_visible()

        test("scoreboard modal opens", t_scoreboard_opens)

        def t_scoreboard_content():
            rows = page.locator(".modal-row")
            assert rows.count() == 3

        test("scoreboard shows all players ranked", t_scoreboard_content)

        def t_scoreboard_ranking():
            rows = page.locator(".modal-row")
            first_name = rows.first.locator(".modal-name").inner_text()
            first_score = int(rows.first.locator(".modal-score").inner_text())
            assert first_name == "Alice"
            assert first_score == 17

        test("scoreboard ranks highest first", t_scoreboard_ranking)

        def t_scoreboard_closes():
            page.locator("#modal-scoreboard .modal-close").click()
            page.wait_for_timeout(200)
            assert page.locator("#modal-scoreboard").is_hidden()

        test("scoreboard modal closes", t_scoreboard_closes)

        # ── Persistence across reload ────────────────────────

        def t_persist_reload():
            page.reload()
            page.wait_for_selector(READY, timeout=15000)
            assert page.locator(".player-card").count() == 3
            name = page.locator(".player-name").first.input_value()
            assert name == "Alice", f"got {name}"
            score = page.locator(".player-score").first.inner_text()
            assert score == "17", f"got {score}"

        test("state persists across page reload", t_persist_reload)

        # ── Game name ──────────────────────────────────────────

        def t_set_game_name():
            name_input = page.locator("#game-name")
            name_input.fill("Friday Catan")
            name_input.dispatch_event("input")
            page.wait_for_timeout(100)
            yname = page.evaluate("yMeta.get('gameName')")
            assert yname == "Friday Catan", f"yjs game name: {yname}"

        test("set game name syncs to yjs", t_set_game_name)

        def t_game_name_persists():
            page.reload()
            page.wait_for_selector(READY, timeout=15000)
            val = page.locator("#game-name").input_value()
            assert val == "Friday Catan", f"got {val}"

        test("game name persists across reload", t_game_name_persists)

        # ── New Game ─────────────────────────────────────────

        def t_new_game_saves_and_resets():
            page.on("dialog", lambda d: d.accept())
            count_before = page.locator(".player-card").count()
            page.evaluate("newGame()")
            page.wait_for_timeout(300)
            # Players are re-created with same names but score 0
            assert page.locator(".player-card").count() == count_before
            scores = page.locator(".player-score").all_text_contents()
            assert all(s.strip() == "0" for s in scores), f"scores should all be 0, got {scores}"
            saved = page.evaluate("ySavedGames.length")
            assert saved == 1, f"expected 1 saved game, got {saved}"

        test("new game saves current and resets", t_new_game_saves_and_resets)

        def t_new_game_keeps_name():
            val = page.locator("#game-name").input_value()
            assert val == "Friday Catan", f"game name should be prefilled from previous game, got {val}"

        test("new game keeps game name from prompt", t_new_game_keeps_name)

        def t_saved_game_data():
            game = page.evaluate("ySavedGames.get(0)")
            assert "id" in game
            assert "date" in game
            assert game["gameName"] == "Friday Catan", f"gameName: {game.get('gameName')}"
            assert len(game["players"]) == 3
            alice = next(p for p in game["players"] if p["name"] == "Alice")
            assert alice["finalScore"] == 17

        test("saved game has correct data including game name", t_saved_game_data)

        # ── Past Games modal ─────────────────────────────────

        def t_past_games_modal():
            page.locator("#games-btn").click()
            page.wait_for_timeout(200)
            assert page.locator("#modal-games").is_visible()
            entries = page.locator(".game-entry")
            assert entries.count() == 1
            text = entries.first.inner_text()
            assert "Alice" in text
            assert "17" in text

        test("past games modal shows saved game", t_past_games_modal)

        def t_past_games_close():
            page.locator("#modal-games .modal-close").click()
            page.wait_for_timeout(200)
            assert page.locator("#modal-games").is_hidden()

        test("past games modal closes", t_past_games_close)

        # ── Multiple saved games ─────────────────────────────

        def t_multiple_saved_games():
            # Create a second game, save it
            page.evaluate("addPlayer()")
            page.wait_for_timeout(200)
            page.locator(".player-card").first.locator(".plus").click()
            page.wait_for_timeout(100)
            page.evaluate("newGame()")
            page.wait_for_timeout(300)
            saved = page.evaluate("ySavedGames.length")
            assert saved == 2, f"expected 2, got {saved}"

        test("multiple games can be saved", t_multiple_saved_games)

        # ── Saved games persist across reload ────────────────

        def t_saved_games_persist():
            page.reload()
            page.wait_for_selector(READY, timeout=15000)
            saved = page.evaluate("ySavedGames.length")
            assert saved == 2, f"expected 2 after reload, got {saved}"

        test("saved games persist across reload", t_saved_games_persist)

        # ── Delete saved game ────────────────────────────────

        def t_delete_saved_game():
            page.evaluate("deleteSavedGame(0)")
            page.wait_for_timeout(200)
            saved = page.evaluate("ySavedGames.length")
            assert saved == 1, f"expected 1 after delete, got {saved}"

        test("delete a saved game", t_delete_saved_game)

        # ── New game with no players does nothing ────────────

        def t_new_game_empty():
            # Remove all players first to test truly empty new game
            page.evaluate("yCurrentPlayers.delete(0, yCurrentPlayers.length)")
            page.evaluate("renderFromYjs()")
            page.wait_for_timeout(200)
            saved_before = page.evaluate("ySavedGames.length")
            page.evaluate("newGame()")
            page.wait_for_timeout(200)
            saved_after = page.evaluate("ySavedGames.length")
            assert saved_before == saved_after, "should not save empty game"

        test("new game with no players does not save", t_new_game_empty)

        # ── Remove player ────────────────────────────────────

        def t_remove_player():
            page.evaluate("addPlayer()")
            page.evaluate("addPlayer()")
            page.wait_for_timeout(200)
            count_before = page.locator(".player-card").count()
            page.locator(".player-card").last.locator(".remove-btn").click()
            page.wait_for_timeout(300)
            assert page.locator(".player-card").count() == count_before - 1
            ycount = page.evaluate("yCurrentPlayers.length")
            assert ycount == count_before - 1

        test("remove player updates yjs", t_remove_player)

        # ── Reset all scores ─────────────────────────────────

        def t_reset_scores():
            cards = page.locator(".player-card")
            cards.first.locator(".plus").click()
            cards.first.locator(".plus").click()
            page.wait_for_timeout(100)

            page.evaluate("resetAllScores()")
            page.wait_for_timeout(200)

            cards = page.locator(".player-card")
            for i in range(cards.count()):
                assert cards.nth(i).locator(".player-score").inner_text() == "0"
            yscore = page.evaluate("yCurrentPlayers.get(0).get('score')")
            assert yscore == 0

        test("reset sets all scores to 0 in yjs", t_reset_scores)

        # ── Debounce ────────────────────────────────────────

        def t_debounce_coalesces_history():
            # Reset scores first, then tap +1 five times rapidly
            page.evaluate("resetAllScores()")
            page.wait_for_timeout(200)
            card = page.locator(".player-card").first
            for _ in range(5):
                card.locator(".plus").click()
            # Score updates immediately
            page.wait_for_timeout(50)
            assert card.locator(".player-score").inner_text() == "5"
            # Wait for debounce to commit (800ms + margin)
            page.wait_for_timeout(1000)
            hist = page.evaluate("JSON.parse(yCurrentPlayers.get(0).get('history'))")
            # Should be a single entry [0], not [0,1,2,3,4]
            assert hist == [0], f"expected [0], got {hist}"

        test("rapid taps produce single history entry", t_debounce_coalesces_history)

        def t_debounce_undo_reverts_burst():
            # Score is 5 from previous test, history=[0]
            card = page.locator(".player-card").first
            card.locator(".plus").click()
            card.locator(".plus").click()
            card.locator(".plus").click()
            page.wait_for_timeout(50)
            assert card.locator(".player-score").inner_text() == "8"
            # Undo mid-burst should revert to pre-burst score
            card.locator(".undo-btn").click()
            page.wait_for_timeout(50)
            assert card.locator(".player-score").inner_text() == "5"

        test("undo mid-burst reverts to pre-burst score", t_debounce_undo_reverts_burst)

        # ── Negative scores ──────────────────────────────────

        def t_negative_score():
            page.evaluate("resetAllScores()")
            page.wait_for_timeout(200)
            card = page.locator(".player-card").first
            card.locator(".minus").click()
            card.locator(".minus").click()
            page.wait_for_timeout(100)
            assert card.locator(".player-score").inner_text() == "-2"

        test("scores can go negative", t_negative_score)

        # ── Remove all players shows empty state ─────────────

        def t_remove_all_shows_empty():
            page.evaluate("""() => {
                yCurrentPlayers.delete(0, yCurrentPlayers.length);
                renderFromYjs();
            }""")
            page.wait_for_timeout(200)
            assert page.locator("#empty-msg").is_visible()
            assert page.locator(".player-card").count() == 0

        test("removing all players shows empty state", t_remove_all_shows_empty)

        # ── Cleanup: clear test data from IndexedDB ──────────
        page.evaluate("""() => {
            while (ySavedGames.length > 0) ySavedGames.delete(0, 1);
            yCurrentPlayers.delete(0, yCurrentPlayers.length);
        }""")

        # ── Summary ──────────────────────────────────────────
        total = passed + failed
        print(f"\n{'='*40}")
        print(f"  {passed}/{total} passed, {failed} failed")
        print(f"{'='*40}")

        ctx.close()
        browser.close()
        return failed


if __name__ == "__main__":
    exit(run_tests())