Tally Score Tracker
FleetingA 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…" 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()">↻</button>
<button class="icon-btn" id="new-game-btn" title="New Game"
@click="$store.app.newGame()">▶</button>
<button class="icon-btn" id="games-btn" title="Past Games"
@click="$store.app.showPastGames = true">📋</button>
<button class="icon-btn" title="Scoreboard"
@click="$store.app.showScoreboard = true">🏆</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">🎲</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)">↩</button>
<button class="card-action remove-btn" title="Remove"
@click="if(confirm('Remove player?')) $store.app.removePlayer(idx)">✕</button>
</div>
<div class="card-score-row">
<button class="score-btn minus"
@click="$store.app.tapScore(idx, -1)">−</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>🏆 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>📋 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)">▶</button>
<button class="game-entry-delete" @click="$store.app.deleteSavedGame(item.idx)">🗑</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()">⌫</button>
</div>
<div class="calc-actions">
<button class="calc-apply calc-sub" @click="$store.app.calcApply(-1)">−</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, '&').replace(/</g, '<'); };
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…" 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()">↻</button>
<button class="icon-btn" id="new-game-btn" title="New Game"
@click="$store.app.newGame()">▶</button>
<button class="icon-btn" id="games-btn" title="Past Games"
@click="$store.app.showPastGames = true">📋</button>
<button class="icon-btn" title="Scoreboard"
@click="$store.app.showScoreboard = true">🏆</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">🎲</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)">↩</button>
<button class="card-action remove-btn" title="Remove"
@click="if(confirm('Remove player?')) $store.app.removePlayer(idx)">✕</button>
</div>
<div class="card-score-row">
<button class="score-btn minus"
@click="$store.app.tapScore(idx, -1)">−</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>🏆 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>📋 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)">▶</button>
<button class="game-entry-delete" @click="$store.app.deleteSavedGame(item.idx)">🗑</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()">⌫</button>
</div>
<div class="calc-actions">
<button class="calc-apply calc-sub" @click="$store.app.calcApply(-1)">−</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 statesavedGames(Y.Array): archive of finished gamesmeta(Y.Map): game-level metadata (currently justgameName)
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, '&').replace(/</g, '<'); };
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())