Konubinix' opinionated web of thoughts

Score Counter Tally

Fleeting

Result in https://sam.konubinix.eu/tally , debug in https://sam.konubinix.eu/tally?room=debug

Board games bring people together, but keeping score on paper gets messy fast — erasing, miscounting, losing the sheet. Phone apps exist, but most don’t sync across devices, and many are bloated with ads.

We want something simple: a PWA that works like a shared spreadsheet, players as columns and rounds as rows. It should work offline, sync over WebSocket when connected, and feel native on a phone. Every design choice below follows from these goals.

This document walks through the app the way a player uses it: opening it for the first time, setting up a game, entering scores round by round, correcting mistakes, checking who’s winning, and saving the session for later. The code appears where it serves the story, not grouped by technology layer.

Table of Contents

Starting a game

You open the app on your phone. No players, no scores — just a dice emoji and a “Start Game” button. That empty state is intentional: it signals that this is a blank slate and gently points you toward the first action.

From there, “Start Game” opens the setup flow: name the session, pick players from your roster (or create new ones), and go. There is no shortcut for a lone unnamed player — a session is worth keeping only once it has a name, so naming it is the first thing you do.

What you see first

The empty state disappears as soon as the first player is added. Until then, it’s the only thing on screen.

/* ── Empty state ── */
.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}

The table area hosts both the empty state and the score table. Alpine’s x-show toggles visibility reactively based on $store.game.hasPlayers. Player columns, score rows, the entry bar, and totals are all driven by x-for loops over the reactive game store — no imperative DOM rebuild.

Under the hood, the table’s :style is bound as an object, not a string. Alpine merges an object style property by property, so it can set the table’s min-width while leaving alone the display that x-show toggles to hide it; a string :style would rewrite the whole inline style and clobber that display, leaving the hidden table on screen. The object form is the one shape where width and visibility coexist.

<div class="table-wrap" id="tableWrap">
  <div class="empty-state" id="emptyState" x-show="!$store.game.hasPlayers">
    <div class="emoji">&#x1f3b2;</div>
    <p>No players yet.</p>
    <button class="modal-close" id="btnStartGame" style="margin-top:16px;width:auto;padding:14px 32px" @click="openPickModal()">Start Game</button>
  </div>
  <table id="scoreTable" x-show="$store.game.hasPlayers" x-cloak
    :style="{ minWidth: ($store.game.colWidths.reduce(function(s,v){return s+v},0) + 28) + 'px' }">
    <thead>
      <tr id="headerRow">
        <th>
          <div class="corner-controls" x-show="$store.game.hasPlayers">
            <button class="corner-btn primary" id="btnAddPlayer" title="Add Player" @click="openAddPlayerModal()">+</button>
            <button class="corner-btn" id="btnReorder" title="Reorder" x-show="$store.game.players.length > 1" @click="openReorderModal()">&#x2195;</button>
          </div>
        </th>
        <template x-for="(p, i) in $store.game.players" :key="i">
          <th :style="'width:calc((100% - 28px) / ' + $store.game.players.length + ');min-width:' + $store.game.colWidths[i] + 'px;border-bottom:3px solid ' + p.color">
            <div class="player-header">
              <input class="player-name-input" :value="p.name" :data-pidx="i"
                :style="'width:' + Math.min(p.name.length || 1, 12) + 'ch;left:calc(50% - ' + Math.min(p.name.length || 1, 12) / 2 + 'ch * cos(55deg) + 0.375rem * sin(55deg))'"
                x-bind:placeholder="'Player ' + (i+1)" spellcheck="false"
                @input="updatePlayerName(i, $el.value)">
            </div>
            <button class="remove-player" :data-pidx="i" title="Remove"
              @click="removePlayer(i)">&#x2715;</button>
          </th>
        </template>
      </tr>
    </thead>
    <tbody id="scoreBody">
      <template x-for="(r, ri) in $store.game.rounds" :key="'r'+ri">
        <tr>
          <td x-text="ri + 1"></td>
          <template x-for="(v, pi) in r" :key="'c'+ri+'_'+pi">
            <td class="score-cell"
              :class="{ 'positive': v > 0, 'negative': v < 0 }"
              :data-round="ri" :data-player="pi"
              @click="openEdit(ri, pi)"
              x-text="v"></td>
          </template>
        </tr>
      </template>
      <tr class="entry-row" x-show="$store.game.hasPlayers">
        <td>
          <button class="entry-confirm" id="entryConfirm" title="Add round"
            :class="{ 'ends-game': $store.game.endConditionMet }"
            @click="addRound()">+</button>
        </td>
        <template x-for="(p, i) in $store.game.players" :key="'e'+i">
          <td>
            <input type="text" inputmode="none" readonly class="entry-input"
              :data-pidx="i" :id="'entry_' + i" placeholder="–"
              :data-pname="p.name"
              :value="$store.game.draft[i]"
              @click="openNumpad($el)">
          </td>
        </template>
      </tr>
    </tbody>
    <tfoot>
      <tr id="totalRow">
        <td>&#x03a3;</td>
        <template x-for="(t, i) in $store.game.liveTotals" :key="'t'+i">
          <td style="font-weight:800;font-size:1rem"
            :class="{ 'winner': $store.game.players.length > 1 && t === $store.game.liveMaxTotal && t !== 0, 'game-won': $store.game.liveWinners[i] }"
            x-text="t"></td>
        </template>
      </tr>
    </tfoot>
  </table>
</div>

Verifying the blank-slate welcome screen

A first-time visitor should land on a clean slate with a prominent “Start Game” button and no leftover data. This test wipes storage and confirms both the empty-state container and the call-to-action are visible.

@testcase
def test_empty_state(page):
    """Fresh app shows empty state with Start Game button."""
    clear_state(page)
    assert page.locator("#emptyState").is_visible()
    assert page.locator("#btnStartGame").is_visible()
    print("  PASS: empty state")

Both the empty-state container and the call-to-action must be visible.

assert page.locator("#emptyState").is_visible()
assert page.locator("#btnStartGame").is_visible()

The score table stays hidden until a game starts

The welcome screen has to be truly blank. The score table is a single element — its sticky header and totals row already in the markup, shown or hidden by x-show on whether the game has any players — so until there are players it must be fully hidden, not lingering as an empty husk behind the dice.

@testcase
def test_empty_board_hides_table(page):
    """Until a game exists the score table is hidden, not a bare husk."""
    clear_state(page)
    page.wait_for_selector("#scoreTable", state="hidden")
    print("  PASS: empty board hides table")

The empty board’s only door is Start Game

There is nothing to do on an empty board but set a game up, and a game must be named — every saved session is identified by its name. So the welcome screen offers exactly one door, Start Game, and no shortcut for dropping in a lone, nameless player.

@testcase
def test_empty_board_only_start(page):
    """The empty board exposes only Start Game --- no quick add-player."""
    clear_state(page)
    assert page.locator("#btnStartGame").is_visible()
    # the add-player control is tied to a running game, so it is absent here
    page.wait_for_selector("#btnAddPlayer", state="hidden")
    print("  PASS: empty board only start game")

The header bar

The header is always visible: app name, a sync indicator dot (grey when offline, green when connected, red when denied), an inline game-name field, and action buttons for the features described in later sections.

<header>
  <h1><span>T</span>ally</h1>
  <a class="sync-dot" id="syncDot" title="Sync status" onclick="if(document.body.getAttribute('data-ws')==='connected'&&confirm('Clear authorization?')){location.target="_blank" href='/authorizationserver/clear-cookie?resource=ywebsocket/'+_roomName}"></a>
  <input type="text" class="game-name-input" id="gameName" placeholder="Game name&#x2026;" spellcheck="false"
    @input="setGameName($el.value)">
  <span class="build-hash" title="Build">nil</span>
  <div class="header-actions">
    <button class="icon-btn" id="btnInstall" title="Install" style="display:none" @click="installApp()">&#x1f4f2;</button>
    <button class="icon-btn" id="btnScoreboard" title="Scoreboard" @click="openScoreboard()">&#x1f3c6;</button>
    <button class="icon-btn" id="btnLoad" title="Load" @click="openLoadModal()">&#x1f4c2;</button>
    <button class="icon-btn" id="btnExport" title="Export CSV" @click="exportCSV()">&#x1f4e5;</button>
    <button class="icon-btn" id="btnNewGame" title="New Game" @click="newGame()">&#x25b6;</button>
  </div>
</header>

/* ── Header ── */
header{
  background:var(--surface);border-bottom:1px solid rgba(255,255,255,.06);
  padding:6px 10px;display:flex;align-items:center;gap:6px;
  flex-shrink:0;position:sticky;top:0;z-index:10;
  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:14px;height:14px;border-radius:50%;flex-shrink:0;
  background:var(--muted);transition:background .3s;
  border:none;padding:0;cursor:pointer;
}
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)}
.build-hash{font-size:.65rem;color:var(--muted);font-family:monospace;flex-shrink:0}

Checking that the header shows all its pieces

The header is the app’s permanent control surface. This test verifies that the title, the editable game-name input, and every app-wide action button (scoreboard, load, export, new game) are present and functional on a fresh page. The per-column controls (add player, reorder) live with the table, not here.

@testcase
def test_header_bar(page):
    """Header shows app title, game name input, and action buttons."""
    clear_state(page)
    assert page.locator("header h1").inner_text() == "Tally"
    gn = page.locator("#gameName")
    assert gn.is_visible()
    gn.fill("Chess")
    assert page.input_value("#gameName") == "Chess"
    for btn_id in ["#btnScoreboard", "#btnLoad", "#btnExport", "#btnNewGame"]:
        assert page.locator(btn_id).is_visible(), f"{btn_id} should be visible"
    print("  PASS: header bar")

The app title must read “Tally”.

assert page.locator("header h1").inner_text() == "Tally"

The game-name input should be visible and accept text.

gn = page.locator("#gameName")
assert gn.is_visible()
gn.fill("Chess")
assert page.input_value("#gameName") == "Chess"

Every action button must be present.

for btn_id in ["#btnScoreboard", "#btnLoad", "#btnExport", "#btnNewGame"]:
    assert page.locator(btn_id).is_visible(), f"{btn_id} should be visible"

Picking players for a new game

Players come from a persistent roster — a list of names you build up over time so you don’t have to retype “Alice” every Friday night. The “New Game” modal shows the roster as toggleable chips: tap the names who are playing tonight, give the session a name (required, so saved games are identifiable), and hit Start. You can add new players or remove existing ones right from the same modal — each chip has a small cross to delete it, and an input field at the bottom lets you type a new name. Below the roster a “Lowest score wins” checkbox lets you flip the ranking direction for games like golf or hearts.

<!-- Pick Players for Game -->
<div class="modal-overlay" id="modalPick" :class="{ 'open': $store.app.showPick }" @click.self="$store.app.showPick = false">
  <div class="modal">
    <h2>New Game</h2>
    <input type="text" class="game-name-input" id="pickGameName" placeholder="Game name&#x2026;" spellcheck="false" style="width:100%;box-sizing:border-box;margin-bottom:12px"
      @input="onPickGameNameInput()">
    <div id="pickList" x-show="$store.game.roster.length > 0">
      <div class="pick-chips">
        <template x-for="(p, i) in $store.game.roster" :key="'pk'+i">
          <span class="pick-chip-wrap">
            <button class="pick-chip" :id="'pick_' + i" :data-pname="p.name"
              :class="{ 'selected': $store.app.pickSelected[p.name] }"
              @click="togglePick(p.name)" x-text="p.name"></button>
            <button class="pick-chip-del" :data-pname="p.name" title="Remove from roster"
              @click.stop="deleteFromRoster(i)">&#x2715;</button>
          </span>
        </template>
      </div>
    </div>
    <p id="pickEmpty" x-show="$store.game.roster.length === 0" style="color:var(--muted);text-align:center;padding:16px 0">No players in roster yet.</p>
    <div style="display:flex;gap:8px;margin-top:12px">
      <input type="text" id="rosterInput" placeholder="Add player&#x2026;" spellcheck="false"
        style="flex:1;padding:10px;border-radius:10px;border:1.5px solid rgba(255,255,255,.12);background:var(--bg);color:var(--text);font-size:.95rem;font-family:inherit"
        @keydown.enter="addToRoster()">
      <button class="modal-close" id="rosterAdd" style="width:auto;margin:0;padding:10px 20px" @click="addToRoster()">Add</button>
    </div>
    <div id="pickOrderList" class="reorder-list" data-reorder="pickOrder" x-show="$store.app.pickOrder.length > 1" style="margin-top:12px">
      <template x-for="(p, i) in $store.app.pickOrder" :key="'po'+i">
        <div class="reorder-item" :data-idx="i">
          <span class="reorder-grip" aria-label="Drag to reorder"></span>
          <span class="reorder-rank" x-text="i + 1"></span>
          <span class="reorder-name" x-text="p.name"></span>
        </div>
      </template>
    </div>
    <label style="display:flex;align-items:center;gap:8px;margin:12px 0;cursor:pointer">
      <input type="checkbox" id="pickLowWins"> Lowest score wins
    </label>
    <label style="display:block;margin:8px 0;font-size:.85rem;color:var(--muted)">Score transform (JS)
      <input type="text" id="pickScoreTransform" placeholder="e.g. total > 50 ? 25 : total"
        style="width:100%;box-sizing:border-box;margin-top:4px;padding:8px;border-radius:8px;border:1.5px solid rgba(255,255,255,.12);background:var(--bg);color:var(--text);font-family:monospace;font-size:.85rem">
    </label>
    <label style="display:block;margin:8px 0;font-size:.85rem;color:var(--muted)">End condition (JS)
      <input type="text" id="pickEndCondition" placeholder="e.g. total > 10"
        style="width:100%;box-sizing:border-box;margin-top:4px;padding:8px;border-radius:8px;border:1.5px solid rgba(255,255,255,.12);background:var(--bg);color:var(--text);font-family:monospace;font-size:.85rem">
    </label>
    <div style="margin:8px 0;display:flex;gap:8px">
      <button class="preset-btn" id="pickPresetMolkky" @click="applyPickPreset('molkky')">M&#xF6;lkky</button>
      <button class="preset-btn" id="pickPresetOdin" @click="applyPickPreset('odin')">Odin</button>
    </div>
    <button class="modal-close" id="pickStart" x-show="$store.game.roster.length > 0" @click="startGame()">Start Game</button>
    <button class="modal-close" id="pickCancel" style="background:var(--muted);margin-top:8px" @click="$store.app.showPick = false">Cancel</button>
  </div>
</div>
<!-- Add Player to current game -->
<div class="modal-overlay" id="modalAddPlayer" :class="{ 'open': $store.app.showAddPlayer }" @click.self="$store.app.showAddPlayer = false">
  <div class="modal">
    <h2>Add Player</h2>
    <template x-if="$store.app.showAddPlayer">
    <div id="addPlayerList" x-show="$store.game.availablePlayers.length > 0">
      <div class="pick-chips">
        <template x-for="(p, i) in $store.game.availablePlayers" :key="'ap'+i">
          <button class="pick-chip" :data-pname="p.name"
            @click="addPlayerFromChip(p.name)" x-text="p.name"></button>
        </template>
      </div>
    </div>
    </template>
    <div style="display:flex;gap:8px;margin-top:12px">
      <input type="text" id="addPlayerInput" placeholder="New player name&#x2026;" spellcheck="false"
        style="flex:1;padding:10px;border-radius:10px;border:1.5px solid rgba(255,255,255,.12);background:var(--bg);color:var(--text);font-size:.95rem;font-family:inherit"
        @keydown.enter="addPlayerFromInput()">
      <button class="modal-close" id="addPlayerAdd" style="width:auto;margin:0;padding:10px 20px" @click="addPlayerFromInput()">Add</button>
    </div>
    <button class="modal-close" id="addPlayerClose" style="background:var(--muted);margin-top:8px" @click="$store.app.showAddPlayer = false">Cancel</button>
  </div>
</div>

The roster is stored in yRoster (a Yjs array of {name:'...'} objects), separate from the current game’s players. The picker pre-selects whoever is already in the current game so you can quickly re-start with the same group. Adding a new player to the roster automatically selects them — you just typed their name, so you clearly want them in the game. Starting a game clears the board and creates fresh Y.Map entries in yPlayers for each selected name.

// ── Roster helpers ──
function getRoster() {
  var a = [];
  for (var i = 0; i < yRoster.length; i++) {
    var r = yRoster.get(i);
    a.push({ name: r.get ? r.get('name') : r.name });
  }
  return a;
}

function deleteFromRoster(idx) {
  var r = yRoster.get(idx); var name = r.get ? r.get('name') : r.name;
  if (!confirm('Remove ' + name + ' from roster?')) return;
  yRoster.delete(idx, 1);
  syncFromYjs();
}

function addToRoster() {
  var name = $('rosterInput').value.trim();
  if (!name) return;
  // Duplicate check
  for (var i = 0; i < yRoster.length; i++) {
    var ri = yRoster.get(i); if ((ri.get ? ri.get('name') : ri.name) === name) { alert(name + ' already exists'); return; }
  }
  yRoster.push([{ name: name }]);
  var sel = Alpine.store('app').pickSelected;
  sel[name] = true;
  Alpine.store('app').pickSelected = Object.assign({}, sel);
  var order = Alpine.store('app').pickOrder.slice();
  order.push({ name: name });
  Alpine.store('app').pickOrder = order;
  $('rosterInput').value = '';
  syncFromYjs();
  $('rosterInput').focus();
}

// ── Player picker ──
function togglePick(name) {
  var sel = Alpine.store('app').pickSelected;
  var order = Alpine.store('app').pickOrder.slice();
  if (sel[name]) {
    delete sel[name];
    order = order.filter(function(p){ return p.name !== name; });
  } else {
    sel[name] = true;
    order.push({ name: name });
  }
  Alpine.store('app').pickSelected = Object.assign({}, sel);
  Alpine.store('app').pickOrder = order;
}

function openPickModal() {
  var sel = {};
  var order = [];
  for (var i = 0; i < yPlayers.length; i++) {
    var n = yPlayers.get(i).get('name');
    sel[n] = true;
    order.push({ name: n });
  }
  Alpine.store('app').pickSelected = sel;
  Alpine.store('app').pickOrder = order;
  $('pickGameName').value = yMeta.get('gameName') || '';
  $('pickLowWins').checked = !!yMeta.get('lowWins');
  $('pickScoreTransform').value = yMeta.get('scoreTransform') || '';
  $('pickEndCondition').value = yMeta.get('endCondition') || yMeta.get('winCondition') || '';
  $('rosterInput').value = '';
  syncFromYjs();
  updatePickStartEnabled();
  Alpine.store('app').showPick = true;
}

function updatePickStartEnabled() {
  var gn = $('pickGameName').value.trim();
  $('pickStart').disabled = !gn;
}
function startGame() {
  var gn = $('pickGameName').value.trim();
  if (!gn) return;
  var names = Alpine.store('app').pickOrder.map(function(p){ return p.name; });
  if (names.length === 0) { alert('Select at least one player'); return; }
  yMeta.set('gameName', gn);
  yMeta.set('lowWins', $('pickLowWins').checked);
  yMeta.set('scoreTransform', $('pickScoreTransform').value);
  yMeta.set('endCondition', $('pickEndCondition').value);
  _cachedTransformFn = null;
  _cachedEndFn = null;
  $('gameName').value = gn;
  yPlayers.delete(0, yPlayers.length);
  yRounds.delete(0, yRounds.length);
  names.forEach(function(n) {
    var pm = new Y.Map();
    pm.set('name', n);
    yPlayers.push([pm]);
  });
  ensureDraft();
  Alpine.store('app').showPick = false;
  render();
}

Opening a new game triggers the pick modal.

def open_new_game(page):
    page.click("#btnNewGame")
    page.wait_for_selector("#modalPick.open")


def add_roster_player(page, name):
    """Add a player to the roster from the pick modal (must be open)."""
    page.fill("#rosterInput", name)
    page.click("#rosterAdd")

Click the New Game button and wait for the pick modal to appear.

page.click("#btnNewGame")
page.wait_for_selector("#modalPick.open")

Adding a roster player means filling the input and clicking Add.

page.fill("#rosterInput", name)
page.click("#rosterAdd")

Making sure removing a roster player asks for confirmation

Deleting a player from the roster is permanent, so the app shows a confirmation dialog. This test dismisses the dialog once (player stays) then accepts it (player disappears), covering both branches.

@testcase
def test_roster_deletion(page):
    """Deleting a roster player requires confirmation."""
    clear_state(page)
    add_player(page, "Alice")
    open_new_game(page)
    add_roster_player(page, "Bob")
    assert page.locator('.pick-chip[data-pname="Bob"]').is_visible()
    page.once("dialog", lambda d: d.dismiss())
    page.locator('.pick-chip-del[data-pname="Bob"]').click()
    assert page.locator('.pick-chip[data-pname="Bob"]').is_visible(), "Bob should still be in roster after dismissing"
    page.once("dialog", lambda d: d.accept())
    page.locator('.pick-chip-del[data-pname="Bob"]').click()
    page.wait_for_function('!document.querySelector(\'.pick-chip[data-pname="Bob"]\')')
    assert not page.locator('.pick-chip[data-pname="Bob"]').count(), "Bob should be removed after accepting"
    page.click("#pickCancel")
    print("  PASS: roster deletion confirmation")

Start with Alice in the game, then open the pick modal and add Bob to the roster so we have a chip to delete.

clear_state(page)
add_player(page, "Alice")
open_new_game(page)
add_roster_player(page, "Bob")
assert page.locator('.pick-chip[data-pname="Bob"]').is_visible()

Dismissing the confirmation dialog should leave Bob in the roster.

page.once("dialog", lambda d: d.dismiss())
page.locator('.pick-chip-del[data-pname="Bob"]').click()
assert page.locator('.pick-chip[data-pname="Bob"]').is_visible(), "Bob should still be in roster after dismissing"

Accepting the dialog should remove Bob’s chip entirely.

page.once("dialog", lambda d: d.accept())
page.locator('.pick-chip-del[data-pname="Bob"]').click()
page.wait_for_function('!document.querySelector(\'.pick-chip[data-pname="Bob"]\')')
assert not page.locator('.pick-chip[data-pname="Bob"]').count(), "Bob should be removed after accepting"

You cannot start without naming the game

Every saved game is identified by its name, so the “Start” button stays disabled until the user types one. This test opens the pick modal from the empty board, picks a player so Start has someone to play, and checks the button state before and after filling in a name.

@testcase
def test_new_game_requires_name(page):
    """Start Game button is disabled until the game is named."""
    clear_state(page)
    page.click("#btnStartGame")
    page.wait_for_selector("#modalPick.open")
    add_roster_player(page, "Alice")
    assert page.locator("#pickStart").is_disabled()
    page.fill("#pickGameName", "Tarot")
    assert not page.locator("#pickStart").is_disabled()
    page.click("#pickCancel")
    print("  PASS: new game requires name")

With an empty name the button should be disabled; filling one in should enable it.

assert page.locator("#pickStart").is_disabled()
page.fill("#pickGameName", "Tarot")
assert not page.locator("#pickStart").is_disabled()

Walking through a full new-game setup

This is the happy-path end-to-end test for game creation: add two players, open the new-game modal, name the game, and start. It verifies that the header reflects the chosen name and the table has the expected player columns.

@testcase
def test_new_game_flow(page):
    """Create a new game with name and selected players."""
    clear_state(page)
    add_player(page, "Alice")
    add_player(page, "Bob")
    open_new_game(page)
    page.fill("#pickGameName", "Belote")
    page.click("#pickStart")
    page.wait_for_selector("#modalPick", state="hidden")
    assert page.input_value("#gameName") == "Belote"
    names = get_player_names(page)
    assert names == ["Alice", "Bob"], f"Expected [Alice, Bob], got {names}"
    print("  PASS: new game flow")

Add two players so the roster is populated.

clear_state(page)
add_player(page, "Alice")
add_player(page, "Bob")

Current players are pre-selected. Name the game and start.

open_new_game(page)
page.fill("#pickGameName", "Belote")
page.click("#pickStart")
page.wait_for_selector("#modalPick", state="hidden")

The header should reflect the chosen name and both players should appear as columns.

assert page.input_value("#gameName") == "Belote"
names = get_player_names(page)
assert names == ["Alice", "Bob"], f"Expected [Alice, Bob], got {names}"

Adding a roster player without losing the game name

While setting up a new game a user may realise they need to add someone to the roster first. This test checks that adding a roster player inline keeps the pick modal open and preserves the game name that was already typed in.

@testcase
def test_manage_roster_returns_to_pick(page):
    """Add a player to the roster inline in the pick modal."""
    clear_state(page)
    add_player(page, "Alice")
    open_new_game(page)
    page.fill("#pickGameName", "BoardNight")
    add_roster_player(page, "Bob")
    assert page.locator('.pick-chip[data-pname="Bob"]').is_visible()
    assert "selected" in (page.locator('.pick-chip[data-pname="Bob"]').get_attribute("class") or ""), \
        "Newly added roster player should be auto-selected"
    assert page.input_value("#pickGameName") == "BoardNight", \
        f"Game name lost after adding roster player: {page.input_value('#pickGameName')}"
    page.click("#pickCancel")
    print("  PASS: manage roster returns to pick modal")

Open the pick modal and type a game name before adding a roster player.

clear_state(page)
add_player(page, "Alice")
open_new_game(page)
page.fill("#pickGameName", "BoardNight")

Add Bob to the roster inline.

add_roster_player(page, "Bob")

Bob’s chip should appear already selected and the game name should still be there.

assert page.locator('.pick-chip[data-pname="Bob"]').is_visible()
assert "selected" in (page.locator('.pick-chip[data-pname="Bob"]').get_attribute("class") or ""), \
    "Newly added roster player should be auto-selected"
assert page.input_value("#pickGameName") == "BoardNight", \
    f"Game name lost after adding roster player: {page.input_value('#pickGameName')}"

Choosing the playing order before starting

Once two or more players are selected in the pick modal, an ordered list appears below the chips. Each row has a grip handle () on the left that you drag up or down to set who goes first. The order you choose here becomes the column order in the score table.

@testcase
def test_reorder_pick_modal(page):
    """Reorder players in the pick modal before starting a game."""
    clear_state(page)
    for name in ["Alice", "Bob", "Charlie"]:
        add_player(page, name)
    open_new_game(page)
    page.fill("#pickGameName", "OrderTest")
    # All three should be selected
    page.wait_for_selector("#pickOrderList")
    # Bob is at index 1 — drag him above Alice (index 0)
    drag_reorder(page, "#pickOrderList", 1, 0)
    page.click("#pickStart")
    page.wait_for_selector("#modalPick", state="hidden")
    names = get_player_names(page)
    assert names == ["Bob", "Alice", "Charlie"], f"Expected [Bob, Alice, Charlie], got {names}"
    print("  PASS: reorder pick modal")

Add Alice, Bob, Charlie via the roster, open the pick modal with all three selected.

clear_state(page)
for name in ["Alice", "Bob", "Charlie"]:
    add_player(page, name)
open_new_game(page)
page.fill("#pickGameName", "OrderTest")
# All three should be selected
page.wait_for_selector("#pickOrderList")

Drag Bob (initially at index 1) above Alice to position 0.

# Bob is at index 1 — drag him above Alice (index 0)
drag_reorder(page, "#pickOrderList", 1, 0)
page.click("#pickStart")
page.wait_for_selector("#modalPick", state="hidden")

The game should start with Bob first, then Alice, then Charlie.

names = get_player_names(page)
assert names == ["Bob", "Alice", "Charlie"], f"Expected [Bob, Alice, Charlie], got {names}"

Pick order tracks selection changes

Toggling a player off should remove them from the order list; toggling them back on should append them at the end.

@testcase
def test_reorder_pick_tracks(page):
    """Pick order list updates when players are toggled."""
    clear_state(page)
    for name in ["Alice", "Bob"]:
        add_player(page, name)
    open_new_game(page)
    page.wait_for_selector("#pickOrderList")
    page.click('.pick-chip[data-pname="Alice"]')  # deselect
    page.click('.pick-chip[data-pname="Alice"]')  # re-select
    items = page.locator('#pickOrderList .reorder-name')
    order = [items.nth(i).inner_text() for i in range(items.count())]
    assert order == ["Bob", "Alice"], f"Expected [Bob, Alice], got {order}"
    page.click("#pickCancel")
    print("  PASS: reorder pick tracks selection")

Start with Alice and Bob selected in the pick modal.

clear_state(page)
for name in ["Alice", "Bob"]:
    add_player(page, name)
open_new_game(page)
page.wait_for_selector("#pickOrderList")

Deselect Alice, then re-select her. She should now appear after Bob.

page.click('.pick-chip[data-pname="Alice"]')  # deselect
page.click('.pick-chip[data-pname="Alice"]')  # re-select

Order list should show Bob first, then Alice.

items = page.locator('#pickOrderList .reorder-name')
order = [items.nth(i).inner_text() for i in range(items.count())]
assert order == ["Bob", "Alice"], f"Expected [Bob, Alice], got {order}"

Adding a player mid-game

Someone shows up late, or you forgot to add a player at the start. Tapping “+” in the table’s corner opens the Add Player modal, which shows roster members not already in the game as quick-tap chips, plus a text field for a brand-new name. Either way the new player is inserted into the table with zero for every past round so the columns stay aligned.

New names typed here are automatically added to the roster for next time.

// ── Add player to current game ──
function getCurrentPlayerNames() {
  var names = {};
  for (var i = 0; i < yPlayers.length; i++) names[yPlayers.get(i).get('name')] = true;
  return names;
}

function addPlayerToGame(name) {
  if (!name) return;
  var current = getCurrentPlayerNames();
  if (current[name]) return;
  var pm = new Y.Map();
  pm.set('name', name);
  yPlayers.push([pm]);
  // Committed rounds back-fill with 0 (the player wasn't there); the
  // trailing draft gets an untouched '' so it still reads as a placeholder.
  var lastIdx = yRounds.length - 1;
  for (var i = 0; i < yRounds.length; i++) {
    var r = yRounds.get(i).slice();
    r.push(i === lastIdx ? '' : 0);
    yRounds.delete(i, 1);
    yRounds.insert(i, [r]);
  }
  ensureDraft();
  // Also add to roster if not already there
  var inRoster = false;
  for (var j = 0; j < yRoster.length; j++) {
    var rj = yRoster.get(j); if ((rj.get ? rj.get('name') : rj.name) === name) { inRoster = true; break; }
  }
  if (!inRoster) yRoster.push([{ name: name }]);
}

function openAddPlayerModal() {
  syncFromYjs();
  $('addPlayerInput').value = '';
  Alpine.store('app').showAddPlayer = true;
  setTimeout(function() { $('addPlayerInput').focus(); }, 100);
}

function addPlayerFromChip(name) {
  addPlayerToGame(name);
  Alpine.store('app').showAddPlayer = false;
  render();
}

function addPlayerFromInput() {
  var name = $('addPlayerInput').value.trim();
  if (!name) return;
  addPlayerToGame(name);
  Alpine.store('app').showAddPlayer = false;
  render();
}

Adding a player through the Add Player modal is a three-step sequence.

def add_player_via_modal(page, name):
    """Type a name in the Add Player modal and click Add."""
    page.fill("#addPlayerInput", name)
    page.click("#addPlayerAdd")
    page.wait_for_selector("#modalAddPlayer", state="hidden")

First, type the player name into the input field.

page.fill("#addPlayerInput", name)

Then click the Add button to confirm.

page.click("#addPlayerAdd")

Finally, wait for the modal to close — this signals that the player was accepted and the DOM has updated.

page.wait_for_selector("#modalAddPlayer", state="hidden")

Putting players on the board is the spine of almost every test, and a test does it the way a player does. The very first name has no game to join, so it arrives through Start Game — which names the session and seeds the board; every later name drops in through the + modal. add_player checks whether any player is on the board yet — an empty board, or a game already under way — and takes the matching door.

def add_player(page, name):
    n = page.locator(".player-name-input").count()
    if n == 0:
        page.click("#btnStartGame")
        page.wait_for_selector("#modalPick.open")
        add_roster_player(page, name)
        page.fill("#pickGameName", "Game")
        page.click("#pickStart")
        page.wait_for_selector("#modalPick", state="hidden")
    else:
        page.click("#btnAddPlayer")
        page.wait_for_selector("#modalAddPlayer.open")
        add_player_via_modal(page, name)
    page.wait_for_function(
        "n => document.querySelectorAll('.player-name-input').length === n",
        arg=n + 1)

A late arrival joins after the first round

When a player joins after rounds have already been played, the app must back-fill their column with zeros. This test enters one round for Alice, adds Bob, and checks that Bob’s total is zero while Alice’s score is preserved.

@testcase
def test_add_player_mid_game(page):
    """Add a player in the middle of a game with existing rounds."""
    clear_state(page)
    add_player(page, "Alice")
    enter_score_via_numpad(page, 0, "10")
    click_numpad(page, "next")
    page.wait_for_selector("#numpad.open")
    click_numpad(page, "done")
    add_player(page, "Bob")
    names = get_player_names(page)
    assert names == ["Alice", "Bob"], f"Expected [Alice, Bob], got {names}"
    t = get_totals(page)
    assert t == [10, 0], f"Expected [10, 0], got {t}"
    print("  PASS: add player mid-game")

Add Alice and enter one round of 10 points for her.

clear_state(page)
add_player(page, "Alice")
enter_score_via_numpad(page, 0, "10")
click_numpad(page, "next")
page.wait_for_selector("#numpad.open")
click_numpad(page, "done")

Now add Bob after the first round has been committed.

add_player(page, "Bob")

Bob’s past rounds should be back-filled with zeros.

names = get_player_names(page)
assert names == ["Alice", "Bob"], f"Expected [Alice, Bob], got {names}"
t = get_totals(page)
assert t == [10, 0], f"Expected [10, 0], got {t}"

Tapping a roster chip to add a known player

The Add Player modal shows chips for roster members not already in the game. Tapping a chip should immediately add that player and close the modal, saving the user from retyping a name they’ve used before.

@testcase
def test_add_player_from_roster_chip(page):
    """Add a roster player via chip in the Add Player modal."""
    clear_state(page)
    add_player(page, "Alice")
    add_player(page, "Bob")
    open_new_game(page)
    page.fill("#pickGameName", "Test")
    page.click('.pick-chip[data-pname="Bob"]')
    page.click("#pickStart")
    page.wait_for_selector("#modalPick", state="hidden")
    assert get_player_names(page) == ["Alice"]
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    modal = page.locator("#modalAddPlayer")
    assert modal.locator('.pick-chip[data-pname="Bob"]').is_visible()
    assert not modal.locator('.pick-chip[data-pname="Alice"]').count()
    modal.locator('.pick-chip[data-pname="Bob"]').click()
    page.wait_for_selector("#modalAddPlayer", state="hidden")
    names = get_player_names(page)
    assert "Bob" in names
    print("  PASS: add player from roster chip")

Create Alice and Bob so the roster has two entries.

clear_state(page)
add_player(page, "Alice")
add_player(page, "Bob")

Start a new game with only Alice selected — deselect Bob before starting.

open_new_game(page)
page.fill("#pickGameName", "Test")
page.click('.pick-chip[data-pname="Bob"]')
page.click("#pickStart")
page.wait_for_selector("#modalPick", state="hidden")
assert get_player_names(page) == ["Alice"]

Open the Add Player modal: Bob should appear as a chip (he’s in the roster but not in the game), and Alice should be absent (already playing). Tapping Bob’s chip should add him immediately.

page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
modal = page.locator("#modalAddPlayer")
assert modal.locator('.pick-chip[data-pname="Bob"]').is_visible()
assert not modal.locator('.pick-chip[data-pname="Alice"]').count()
modal.locator('.pick-chip[data-pname="Bob"]').click()
page.wait_for_selector("#modalAddPlayer", state="hidden")
names = get_player_names(page)
assert "Bob" in names

Typing a brand-new player name

Once a game is on, the corner + is the quick way to bring in another player: type a name, press Add, and they appear as a fresh column right away.

@testcase
def test_add_player_directly(page):
    """The corner + brings a new player into a running game."""
    clear_state(page)
    add_player(page, "Alice")
    page.click("#btnAddPlayer")
    page.wait_for_selector("#modalAddPlayer.open")
    add_player_via_modal(page, "Bob")
    names = get_player_names(page)
    assert "Bob" in names, f"Expected Bob in {names}"
    print("  PASS: add player via corner +")

Tap the corner + and type “Bob”.

page.click("#btnAddPlayer")
page.wait_for_selector("#modalAddPlayer.open")
add_player_via_modal(page, "Bob")

Bob should now appear as a column header alongside Alice.

names = get_player_names(page)
assert "Bob" in names, f"Expected Bob in {names}"

Entering scores

This is the core interaction loop. Each round, every player gets a score. You tap the entry cell for a player, a numpad slides up from the bottom, you type the number, and tap the arrow to move to the next player. After the last player, the round is committed and a new entry row appears.

The numpad exists because native mobile keyboards are unreliable for fast numeric entry — they vary by OS, they cover too much screen, and they don’t offer quick +/-5/+/-10 buttons that card games need. The custom numpad gives us full control over the experience.

The score table

The table is a spreadsheet: player names as column headers (each color-coded), one row per round, and a sticky totals row at the bottom. The first column (round numbers) and the header row are sticky so they stay visible when scrolling. Score cells are color-coded: green for positive, red for negative. The leading scorer’s total glows gold.

The layout privileges information density over decoration: tight padding, thin grid lines, no rounded corners on cells. On a small phone screen every pixel counts — a leaner grid means more rows and columns visible at once, less scrolling, and faster scanning.

Alpine’s x-for loops in the HTML template render the table reactively from $store.game — the bridge function syncFromYjs() copies Yjs state into the Alpine store, and Alpine handles the DOM.

// render() is an alias for syncFromYjs() — see js-alpine-store

/* ── Table area ── */
.table-wrap{
  flex:1;overflow:auto;padding:0;
  -webkit-overflow-scrolling:touch;
}
table{
  border-collapse:collapse;
  width:100%;table-layout:fixed;
}
th,td{
  padding:4px 3px;text-align:center;
  border:1px solid rgba(255,255,255,.08);
  overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
}
th:first-child,td:first-child{
  position:sticky;left:0;z-index:2;
  background:var(--bg);min-width:28px;width:28px;
  color:var(--muted);font-size:.75rem;
}
thead th{
  position:sticky;top:0;z-index:3;
  background:var(--surface);font-size:.8rem;font-weight:700;
  vertical-align:bottom;padding:0;
}
thead th:first-child{z-index:4;background:var(--surface)}
.player-header{
  height:80px;
  position:relative;
}
.player-name-input{
  background:none;border:none;color:var(--text);
  font-family:monospace;font-size:.75rem;font-weight:700;
  outline:none;padding:0;
  position:absolute;bottom:0;
  transform-origin:bottom left;transform:rotate(-55deg);
  white-space:nowrap;overflow:hidden;
}
.player-name-input::placeholder{color:var(--muted)}
.player-name-input:focus{border-bottom:1.5px solid var(--accent)}
th.active-player{background:rgba(233,69,96,.15)}
th.active-player .player-name-input{color:#fff;font-weight:800}
.remove-player{
  font-size:.6rem;color:var(--muted);cursor:pointer;
  background:none;border:none;display:block;margin:0 auto;padding:0;
}
.remove-player:active{color:var(--red)}
.score-cell{
  cursor:pointer;font-variant-numeric:tabular-nums;
  font-weight:500;font-size:.85rem;
}
.positive{color:var(--green)}
.negative{color:var(--red)}
tfoot td{
  font-weight:800;font-size:1rem;
  border-top:2px solid rgba(255,255,255,.15);
  background:var(--surface);position:sticky;bottom:0;z-index:2;
}
tfoot td:first-child{z-index:4;background:var(--surface)}
.winner{color:#ffd700}
.game-won{color:#ffd700;text-shadow:0 0 8px #ffd700}
.preset-btn{padding:6px 14px;border-radius:8px;border:1.5px solid rgba(255,255,255,.15);background:var(--surface);color:var(--text);font-size:.8rem;cursor:pointer}

When the roster grows, fixed-width columns force the user to scroll sideways — awkward on a phone. Player names are rotated −55° in the header so they stack diagonally and columns can be narrow.

Each column only needs to be wide enough to tell its player apart from the others. colWidths() finds the shortest prefix that disambiguates each name, then converts it to pixels: number of distinguishing characters × monospace character width × cos(55°). The cosine projects the rotated text back to horizontal space. For example, among “Samuel”, “Samantha” and “Sabrina”, Samuel needs four characters (“Samu”) and Sabrina only three (“Sab”). Names longer than the prefix are simply clipped — no ellipsis.

Two helpers read the score table state for assertions.

def get_totals(page):
    """Return list of total scores from the footer row."""
    cells = page.locator("#totalRow td:not(:first-child)")
    return [int(cells.nth(i).inner_text()) for i in range(cells.count())]


def get_player_names(page):
    inputs = page.locator(".player-name-input")
    return [inputs.nth(i).input_value() for i in range(inputs.count())]

Read every cell in the totals footer, skipping the first (label) column, and return them as a list of integers.

cells = page.locator("#totalRow td:not(:first-child)")
return [int(cells.nth(i).inner_text()) for i in range(cells.count())]

Collect the current value of each player-name input in header order.

inputs = page.locator(".player-name-input")
return [inputs.nth(i).input_value() for i in range(inputs.count())]

Verifying the table shows player columns and totals

After adding two players the score table must appear with one column per player and a totals row showing zeros. This confirms the basic reactive rendering pipeline from Yjs through Alpine to the DOM.

@testcase
def test_score_table(page):
    """Score table renders player columns and a totals row."""
    clear_state(page)
    add_player(page, "Alice")
    add_player(page, "Bob")
    assert page.locator("#scoreTable").is_visible()
    names = get_player_names(page)
    assert names == ["Alice", "Bob"], f"Expected [Alice, Bob], got {names}"
    assert page.locator("#totalRow").is_visible()
    t = get_totals(page)
    assert t == [0, 0], f"Expected [0, 0], got {t}"
    print("  PASS: score table")

Add two players so the table becomes visible.

clear_state(page)
add_player(page, "Alice")
add_player(page, "Bob")

The table should be visible with both player columns and a totals row showing zeros.

assert page.locator("#scoreTable").is_visible()
names = get_player_names(page)
assert names == ["Alice", "Bob"], f"Expected [Alice, Bob], got {names}"
assert page.locator("#totalRow").is_visible()
t = get_totals(page)
assert t == [0, 0], f"Expected [0, 0], got {t}"

Player controls sit in the table’s corner

Two controls are not app-wide at all: reordering the players and adding one act on the columns themselves. So they belong with the columns, not up in the header among the app-wide actions — they live in the table’s top-left corner, the cell above the round numbers where the player columns begin. Pulling them out of the header is also what finally lets the game-name field breathe on a narrow phone.

@testcase
def test_corner_controls(page):
    """Reorder and add-player live in the table's corner, not the page header."""
    clear_state(page)
    add_player(page, "Alice")
    add_player(page, "Bob")
    assert page.locator("header #btnAddPlayer").count() == 0, \
        "add-player should no longer sit in the page header"
    assert page.locator("header #btnReorder").count() == 0, \
        "reorder should no longer sit in the page header"
    page.wait_for_selector("#headerRow th:first-child #btnAddPlayer", state="visible")
    page.wait_for_selector("#headerRow th:first-child #btnReorder", state="visible")
    print("  PASS: corner controls")

The corner cell is only as wide as the round numbers below it, so the two buttons stack rather than sit shoulder to shoulder, each shrunk to fit; the cell sheds its padding to make the room.

#headerRow th:first-child{padding:2px 1px}
.corner-controls{display:flex;flex-direction:column;align-items:center;gap:3px}
.corner-btn{
  width:24px;height:24px;border-radius:7px;border:none;cursor:pointer;
  display:grid;place-items:center;font-size:.85rem;font-weight:700;
  background:rgba(255,255,255,.08);color:var(--text);
}
.corner-btn.primary{background:var(--accent);color:#fff}
.corner-btn:active{background:rgba(255,255,255,.2)}

The numpad

Tapping any entry input opens a bottom-sheet numpad showing the current player’s name and value. The grid has digit keys, a sign toggle (+/-), backspace, clear, quick increment buttons (-10, -5, +5, +10), and two navigation keys: arrow-right (next player or commit round) and checkmark (close numpad without committing). Tapping anywhere outside the numpad also dismisses it, just like any bottom sheet.

The entry inputs are readonly so the native keyboard never appears.

<div class="numpad" id="numpad" :class="{ 'open': $store.app.showNumpad }">
  <div class="numpad-display"><span class="np-label" id="npLabel"></span><br><span id="npValue">0</span></div>
  <button class="np-del-round" id="npDelRound" style="display:none" onclick="deleteEditRound()">Delete Round</button>
  <div class="numpad-grid">
    <button class="np-btn np-quick" data-delta="-10">-10</button>
    <button class="np-btn np-quick" data-delta="-5">-5</button>
    <button class="np-btn np-quick" data-delta="+5">+5</button>
    <button class="np-btn np-quick" data-delta="+10">+10</button>
    <button class="np-btn" data-key="7">7</button>
    <button class="np-btn" data-key="8">8</button>
    <button class="np-btn" data-key="9">9</button>
    <button class="np-btn" data-key="del">&#x232b;</button>
    <button class="np-btn" data-key="4">4</button>
    <button class="np-btn" data-key="5">5</button>
    <button class="np-btn" data-key="6">6</button>
    <button class="np-btn" data-key="sign">+/&#x2212;</button>
    <button class="np-btn" data-key="1">1</button>
    <button class="np-btn" data-key="2">2</button>
    <button class="np-btn" data-key="3">3</button>
    <button class="np-btn np-green" data-key="next">&#x2192;</button>
    <button class="np-btn" data-key="0">0</button>
    <button class="np-btn" data-key="00">00</button>
    <button class="np-btn" data-key="clear">C</button>
    <button class="np-btn np-accent" data-key="done">&#x2713;</button>
  </div>
</div>

// ── Numpad ──
// One pad edits one cell (round, player) of yRounds. The trailing round
// is the draft the players are filling in; any earlier round is committed
// history. The two differ only in chrome and in what the "next" key does.
var npRound = -1; // round being edited; -1 when the pad is closed
var npPlayer = -1;
var npRaw = ''; // raw digit string (without sign)
var npNeg = false;
var npReplaceOnKey = false; // first keystroke replaces the value (committed-cell edit)
var npHistoryPushed = false; // true when a history entry was pushed for the numpad
var npClosingFromHistory = false; // guard to avoid popstate loop

function npIsDraft() { return npRound >= 0 && npRound === yRounds.length - 1; }

function npCellValue() {
  var g = Alpine.store('game');
  if (npIsDraft()) return g.draft[npPlayer];
  var r = g.rounds[npRound];
  return r ? r[npPlayer] : 0;
}

function npValue() {
  var val = npNeg && npRaw !== '' && npRaw !== '0' ? '-' + npRaw : npRaw;
  return val ? parseInt(val) : 0;
}

function npSync() {
  var val = npNeg && npRaw !== '' && npRaw !== '0' ? '-' + npRaw : npRaw;
  $('npValue').textContent = val || '\u2013';
  if (npRound < 0) return;
  if (npIsDraft()) {
    // An untouched draft cell stays a placeholder until a digit is typed.
    if (npRaw !== '') {
      var n = val ? parseInt(val) : 0;
      Alpine.store('game').draft[npPlayer] = n;
      var inp = $('entry_' + npPlayer);
      if (inp) inp.value = n;
    }
  } else {
    Alpine.store('game').rounds[npRound][npPlayer] = val ? parseInt(val) : 0;
  }
}

function syncNumpadPadding(open, scrollTarget) {
  var tw = $('tableWrap');
  var np = $('numpad');
  if (open) {
    np.style.display = 'block';
    var npH = np.offsetHeight;
    tw.style.paddingBottom = npH + 'px';
    np.style.display = '';
    if (scrollTarget) {
      requestAnimationFrame(function() {
        var twRect = tw.getBoundingClientRect();
        var elRect = scrollTarget.getBoundingClientRect();
        var visibleBottom = twRect.bottom - npH;
        if (elRect.bottom > visibleBottom) {
          tw.scrollTop += elRect.bottom - visibleBottom;
        }
        // If the entry row is being edited, scroll to bottom to show totals too
        if (scrollTarget.classList.contains('entry-input')) {
          tw.scrollTop = tw.scrollHeight;
        }
      });
    }
  } else {
    tw.style.paddingBottom = '0';
  }
}

// Tapping an entry input edits the draft cell; tapping a committed score
// cell edits that round. Both funnel through openCell.
function openNumpad(input) {
  ensureDraft();
  openCell(yRounds.length - 1, parseInt(input.dataset.pidx));
}

function openCell(round, player) {
  npRound = round;
  npPlayer = player;
  var g = Alpine.store('game');
  var draft = npIsDraft();
  npReplaceOnKey = !draft; // editing a committed value replaces it on the first key
  var cur = npCellValue();
  cur = (cur === '' || cur == null) ? 0 : cur;
  npNeg = cur < 0;
  npRaw = cur === 0 ? '' : Math.abs(cur).toString();
  var name = (g.players[player] && g.players[player].name) || '';
  document.querySelectorAll('.score-cell.active-edit').forEach(function(el) { el.classList.remove('active-edit'); });
  document.querySelectorAll('.entry-input').forEach(function(el) { el.classList.remove('active-pad'); });
  document.querySelectorAll('th.active-player').forEach(function(el) { el.classList.remove('active-player'); });
  var scrollTarget;
  if (draft) {
    $('npLabel').textContent = name;
    scrollTarget = $('entry_' + player);
    if (scrollTarget) scrollTarget.classList.add('active-pad');
    $('npDelRound').style.display = 'none';
  } else {
    $('npLabel').textContent = name + ' \u2014 R' + (round + 1);
    scrollTarget = document.querySelector('td.score-cell[data-round="' + round + '"][data-player="' + player + '"]');
    if (scrollTarget) scrollTarget.classList.add('active-edit');
    $('npDelRound').style.display = 'block';
  }
  var th = document.querySelectorAll('#headerRow th')[player + 1];
  if (th) th.classList.add('active-player');
  npSync();
  Alpine.store('app').showNumpad = true;
  if (!npHistoryPushed) { history.pushState({numpad: true}, ''); npHistoryPushed = true; }
  syncNumpadPadding(true, scrollTarget);
}

function closeNumpad() {
  if (npRound >= 0 && !npIsDraft()) {
    // A committed cell is written back to its round on close.
    var r = yRounds.get(npRound).slice();
    r[npPlayer] = npValue();
    yRounds.delete(npRound, 1);
    yRounds.insert(npRound, [r]);
    render();
  } else if (npRound >= 0) {
    flushDraft(); // the pad sliding shut settles the draft for every device
  }
  document.querySelectorAll('.score-cell.active-edit').forEach(function(el) { el.classList.remove('active-edit'); });
  document.querySelectorAll('.entry-input').forEach(function(el) { el.classList.remove('active-pad'); });
  document.querySelectorAll('th.active-player').forEach(function(el) { el.classList.remove('active-player'); });
  $('npDelRound').style.display = 'none';
  syncNumpadPadding(false);
  Alpine.store('app').showNumpad = false;
  npRound = -1;
  npPlayer = -1;
  if (npHistoryPushed && !npClosingFromHistory) { npHistoryPushed = false; history.back(); }
  else { npHistoryPushed = false; }
}

window.addEventListener('popstate', function() {
  if (Alpine.store('app').showNumpad) {
    npClosingFromHistory = true;
    closeNumpad();
    npClosingFromHistory = false;
  }
});

document.addEventListener('click', function(e) {
  if (!Alpine.store('app').showNumpad) return;
  if (e.target.closest('#numpad') || e.target.closest('.entry-input') || e.target.closest('.score-cell')) return;
  closeNumpad();
});

$('numpad').onclick = function(e) {
  var btn = e.target.closest('.np-btn');
  if (!btn) return;
  var key = btn.dataset.key;
  var delta = btn.dataset.delta;

  if (delta) {
    npReplaceOnKey = false;
    var cur = npCellValue();
    cur = ((cur === '' || cur == null) ? 0 : cur) + parseInt(delta);
    npNeg = cur < 0;
    npRaw = Math.abs(cur).toString();
    npSync();
    if (npIsDraft()) flushDraft(); // a ±N tap settles the value for every device
    return;
  }

  if (key >= '0' && key <= '9') {
    if (npReplaceOnKey) { npRaw = ''; npNeg = false; npReplaceOnKey = false; }
    npRaw += key;
    npSync();
  } else if (key === '00') {
    if (npReplaceOnKey) { npRaw = ''; npNeg = false; npReplaceOnKey = false; }
    npRaw += '00';
    npSync();
  } else if (key === 'del') {
    npReplaceOnKey = false;
    npRaw = npRaw.slice(0, -1);
    npSync();
  } else if (key === 'clear') {
    npReplaceOnKey = false;
    npRaw = '';
    npNeg = false;
    npSync();
  } else if (key === 'sign') {
    npReplaceOnKey = false;
    npNeg = !npNeg;
    npSync();
  } else if (key === 'next') {
    if (npRound < 0) return;
    if (!npIsDraft()) { closeNumpad(); return; }
    // Finalize an untouched entry to 0, then advance or commit the round.
    if (npRaw === '') {
      Alpine.store('game').draft[npPlayer] = 0;
      var inp = $('entry_' + npPlayer);
      if (inp) inp.value = 0;
    }
    var next = $('entry_' + (npPlayer + 1));
    if (next) openNumpad(next);
    else { addRound(); }
  } else if (key === 'done') {
    closeNumpad();
  }
};

/* ── Entry row (in-table) ── */
.entry-row td{padding:4px 2px}
.entry-input{
  width:100%;background:rgba(255,255,255,.08);border:1.5px solid rgba(255,255,255,.1);
  border-radius:8px;color:var(--text);font-family:inherit;font-size:1rem;
  font-weight:700;text-align:center;padding:8px 4px;outline:none;
  -moz-appearance:textfield;box-sizing:border-box;
}
.entry-input::-webkit-inner-spin-button,
.entry-input::-webkit-outer-spin-button{-webkit-appearance:none}
.entry-input.active-pad{border-color:var(--accent)}
.entry-confirm{
  width:36px;height:36px;border-radius:10px;border:none;
  background:var(--green);color:#111;font-size:1.2rem;font-weight:800;
  cursor:pointer;display:grid;place-items:center;
}
.entry-confirm:active{background:#3ab88e}
/* ── Numpad ── */
.numpad{
  position:fixed;bottom:0;left:0;right:0;z-index:150;
  background:var(--surface);border-top:1px solid rgba(255,255,255,.1);
  padding:8px;display:none;max-height:100vh;max-height:100dvh;overflow-y:auto;
}
.numpad.open{display:block}
.numpad-display{
  text-align:center;font-size:1.3rem;font-weight:800;color:var(--text);
  padding:6px;margin-bottom:6px;
}
.numpad-display .np-label{font-size:.85rem;color:var(--text);font-weight:700}
.numpad-grid{
  display:grid;grid-template-columns:repeat(4,1fr);gap:6px;
}
.np-btn{
  padding:14px 0;border:none;border-radius:10px;font-size:1.1rem;font-weight:700;
  cursor:pointer;font-family:inherit;color:var(--text);
  background:rgba(255,255,255,.08);
}
.np-btn:active{background:rgba(255,255,255,.18)}
.np-btn.np-accent{background:var(--accent);color:#fff}
.np-btn.np-green{background:var(--green);color:#111}
.np-btn.np-quick{background:rgba(255,255,255,.05);font-size:.85rem}
@media(max-height:400px){
  .numpad{padding:4px}
  .numpad-display{padding:2px;margin-bottom:2px;font-size:1rem}
  .numpad-grid{gap:3px}
  .np-btn{padding:8px 0;font-size:.95rem;border-radius:6px}
}

Two helpers drive the numpad from tests.

def enter_score_via_numpad(page, player_idx, digits):
    """Click the entry input for a player, type digits on the numpad."""
    page.click(f"#entry_{player_idx}")
    page.wait_for_selector("#numpad.open")
    for d in str(digits):
        page.click(f'.np-btn[data-key="{d}"]')


def click_numpad(page, key):
    page.click(f'.np-btn[data-key="{key}"]')

Tap the entry cell for the given player index to open the numpad.

page.click(f"#entry_{player_idx}")
page.wait_for_selector("#numpad.open")

Type each digit by clicking the corresponding numpad button.

for d in str(digits):
    page.click(f'.np-btn[data-key="{d}"]')

A single numpad key press.

page.click(f'.np-btn[data-key="{key}"]')


def drag_reorder(page, container_sel, from_idx, to_idx):
    """Drag a reorder item from one index to another via its grip handle."""
    items = page.locator(f"{container_sel} .reorder-item")
    src = items.nth(from_idx).locator(".reorder-grip")
    dst = items.nth(to_idx).locator(".reorder-grip")
    src.drag_to(dst)

Opening the numpad and typing digits

Tapping an entry input should slide up the numpad, display the current player’s name, and accept digit keys. The display must update live as buttons are pressed, and the done key must close the numpad.

@testcase
def test_numpad_entry(page):
    """Numpad opens on entry click and accepts digit input."""
    clear_state(page)
    add_player(page, "Alice")
    page.click("#entry_0")
    page.wait_for_selector("#numpad.open")
    assert page.locator("#npLabel").inner_text() == "Alice"
    page.click('.np-btn[data-key="4"]')
    page.click('.np-btn[data-key="2"]')
    assert page.locator("#npValue").inner_text() == "42"
    click_numpad(page, "done")
    page.wait_for_selector("#numpad.open", state="hidden")
    print("  PASS: numpad entry")

Tap Alice’s entry cell — the numpad should slide up and show her name.

page.click("#entry_0")
page.wait_for_selector("#numpad.open")
assert page.locator("#npLabel").inner_text() == "Alice"

Press 4 then 2 — the display should read “42”.

page.click('.np-btn[data-key="4"]')
page.click('.np-btn[data-key="2"]')
assert page.locator("#npValue").inner_text() == "42"

The done key closes the numpad.

click_numpad(page, "done")
page.wait_for_selector("#numpad.open", state="hidden")

Back button closes the numpad

On a phone the back gesture is the natural way to dismiss anything that popped up. If the numpad is open and the user swipes back, the numpad should close instead of navigating away from the app.

@testcase
def test_numpad_back(page):
    """Back button closes the numpad instead of navigating away."""
    clear_state(page)
    url_before = page.url
    add_player(page, "Alice")
    page.click("#entry_0")
    page.wait_for_selector("#numpad.open")
    page.evaluate("history.back()")
    page.wait_for_timeout(500)
    try:
        still_here = page.url == url_before
        numpad_hidden = page.query_selector("#numpad.open") is None
    except Exception:
        still_here = False
        numpad_hidden = False
    if not still_here:
        # Navigated away — restore the page for subsequent tests
        page.goto(BASE_URL)
        page.wait_for_selector("#btnAddPlayer")
        raise AssertionError("Back button navigated away instead of closing numpad")
    assert numpad_hidden, "Numpad still open after back button"
    print("  PASS: numpad back")

Tapping outside the numpad closes it

Like any bottom sheet, tapping outside the numpad should dismiss it. Users don’t have to hunt for the done button — a quick tap on the dimmed area above does the trick.

@testcase
def test_numpad_tap_outside(page):
    """Tapping outside the numpad closes it."""
    clear_state(page)
    add_player(page, "Alice")
    page.click("#entry_0")
    page.wait_for_selector("#numpad.open")
    # Click the header area, well above the numpad
    page.click("#gameName")
    page.wait_for_selector("#numpad.open", state="hidden", timeout=2000)
    print("  PASS: numpad tap outside")

Numpad does not hide the edited row or totals

When many rounds fill the table, opening the numpad must not hide the row being edited. The entry row and the totals footer must remain visible — the table should have enough bottom padding to keep them above the numpad.

@testcase
def test_numpad_visibility(page):
    """Entry row and totals stay visible above the numpad with many rounds."""
    clear_state(page)
    add_player(page, "Alice")
    add_player(page, "Bob")
    # Add 12 rounds to fill the table
    for r in range(12):
        enter_score_via_numpad(page, 0, str(r + 1))
        click_numpad(page, "next")
        page.wait_for_selector('#entry_1.active-pad')
        for d in str(r + 1):
            page.click(f'.np-btn[data-key="{d}"]')
        click_numpad(page, "next")  # commits
        page.wait_for_selector("#numpad.open")
        click_numpad(page, "done")
    # Open numpad on the entry row
    page.click("#entry_0")
    page.wait_for_selector("#numpad.open")
    page.wait_for_timeout(200)
    np_box = page.locator("#numpad").bounding_box()
    entry_box = page.locator("#entry_0").bounding_box()
    total_box = page.locator("#totalRow").bounding_box()
    assert entry_box["y"] + entry_box["height"] <= np_box["y"], \
        f"Entry row bottom ({entry_box['y'] + entry_box['height']}) overlaps numpad top ({np_box['y']})"
    assert total_box["y"] + total_box["height"] <= np_box["y"], \
        f"Total row bottom ({total_box['y'] + total_box['height']}) overlaps numpad top ({np_box['y']})"
    click_numpad(page, "done")
    page.wait_for_selector("#numpad.open", state="hidden")
    # Edit a middle row — that cell must be visible above the numpad
    cell = page.locator('td.score-cell[data-round="5"][data-player="0"]')
    cell.click()
    page.wait_for_selector("#numpad.open")
    page.wait_for_timeout(200)
    np_box = page.locator("#numpad").bounding_box()
    cell_box = cell.bounding_box()
    assert cell_box["y"] + cell_box["height"] <= np_box["y"], \
        f"Edited cell bottom ({cell_box['y'] + cell_box['height']}) overlaps numpad top ({np_box['y']})"
    click_numpad(page, "done")
    page.wait_for_selector("#numpad.open", state="hidden")
    print("  PASS: numpad visibility")

The entry row and totals footer must sit above the numpad. We check by comparing bounding boxes: the bottom edge of the element must be above the top edge of the numpad.

np_box = page.locator("#numpad").bounding_box()
entry_box = page.locator("#entry_0").bounding_box()
total_box = page.locator("#totalRow").bounding_box()
assert entry_box["y"] + entry_box["height"] <= np_box["y"], \
    f"Entry row bottom ({entry_box['y'] + entry_box['height']}) overlaps numpad top ({np_box['y']})"
assert total_box["y"] + total_box["height"] <= np_box["y"], \
    f"Total row bottom ({total_box['y'] + total_box['height']}) overlaps numpad top ({np_box['y']})"

When editing a past round, the clicked cell must be visible above the numpad.

np_box = page.locator("#numpad").bounding_box()
cell_box = cell.bounding_box()
assert cell_box["y"] + cell_box["height"] <= np_box["y"], \
    f"Edited cell bottom ({cell_box['y'] + cell_box['height']}) overlaps numpad top ({np_box['y']})"

Active player column is highlighted while entering a score

On a small screen with many players it is hard to tell whose score you are editing. When the numpad is open the active player’s column header lights up so the eye is immediately drawn to the right name. The highlight must follow the cursor when pressing “next” and disappear once the numpad closes.

@testcase
def test_active_player_highlight(page):
    """Column header highlights the player being scored."""
    clear_state(page)
    add_player(page, "Alice")
    add_player(page, "Bob")
    # Open numpad on Alice — her header must gain the highlight
    page.click("#entry_0")
    page.wait_for_selector("#numpad.open")
    ths = page.locator("#headerRow th")
    assert "active-player" in (ths.nth(1).get_attribute("class") or ""), \
        "Alice header should be highlighted"
    assert "active-player" not in (ths.nth(2).get_attribute("class") or ""), \
        "Bob header should not be highlighted"
    # Press next → highlight moves to Bob
    enter_score_via_numpad(page, 0, "5")
    click_numpad(page, "next")
    page.wait_for_selector('#entry_1.active-pad')
    assert "active-player" not in (ths.nth(1).get_attribute("class") or ""), \
        "Alice header should no longer be highlighted"
    assert "active-player" in (ths.nth(2).get_attribute("class") or ""), \
        "Bob header should now be highlighted"
    # Close numpad → no highlight
    click_numpad(page, "done")
    page.wait_for_selector("#numpad.open", state="hidden")
    assert "active-player" not in (ths.nth(1).get_attribute("class") or ""), \
        "Alice header should not be highlighted after close"
    assert "active-player" not in (ths.nth(2).get_attribute("class") or ""), \
        "Bob header should not be highlighted after close"
    print("  PASS: active player highlight")

clear_state(page)
add_player(page, "Alice")
add_player(page, "Bob")
# Open numpad on Alice — her header must gain the highlight
page.click("#entry_0")
page.wait_for_selector("#numpad.open")
ths = page.locator("#headerRow th")
assert "active-player" in (ths.nth(1).get_attribute("class") or ""), \
    "Alice header should be highlighted"
assert "active-player" not in (ths.nth(2).get_attribute("class") or ""), \
    "Bob header should not be highlighted"
# Press next → highlight moves to Bob
enter_score_via_numpad(page, 0, "5")
click_numpad(page, "next")
page.wait_for_selector('#entry_1.active-pad')
assert "active-player" not in (ths.nth(1).get_attribute("class") or ""), \
    "Alice header should no longer be highlighted"
assert "active-player" in (ths.nth(2).get_attribute("class") or ""), \
    "Bob header should now be highlighted"
# Close numpad → no highlight
click_numpad(page, "done")
page.wait_for_selector("#numpad.open", state="hidden")
assert "active-player" not in (ths.nth(1).get_attribute("class") or ""), \
    "Alice header should not be highlighted after close"
assert "active-player" not in (ths.nth(2).get_attribute("class") or ""), \
    "Bob header should not be highlighted after close"

Entering a negative score with the sign toggle

Some games penalise players with negative points. The numpad has a +/− toggle for this. This test enters a digit, flips the sign, and confirms the total reflects the negative value.

@testcase
def test_negative_score(page):
    """Enter a negative score via the sign toggle."""
    clear_state(page)
    add_player(page, "Alice")
    enter_score_via_numpad(page, 0, "7")
    click_numpad(page, "sign")
    click_numpad(page, "next")
    page.wait_for_selector("#numpad.open")
    click_numpad(page, "done")
    t = get_totals(page)
    assert t == [-7], f"Expected [-7], got {t}"
    print("  PASS: negative score")

Enter 7 then flip the sign to make it negative, and commit the round.

enter_score_via_numpad(page, 0, "7")
click_numpad(page, "sign")
click_numpad(page, "next")
page.wait_for_selector("#numpad.open")
click_numpad(page, "done")

The total should be -7.

t = get_totals(page)
assert t == [-7], f"Expected [-7], got {t}"

The in-progress round is shared, not private

The round being entered is not a private draft on one phone — it is the trailing round of the shared document, so every device sees the same half-filled row take shape. The value settles into the document the moment it is meaningful: a ±N tap, or the pad sliding shut. (Plain digit keys stay local until then, so a half-typed number never flickers the other screens.)

A write that reaches the document survives a reload, because the reload rebuilds the app from that same document (see Playing together). So we set a value, hit the settle-point, reload, and read it back.

@testcase
def test_pending_entry_syncs(page):
    """A settled in-progress entry lives in the shared doc, not local scratch."""
    clear_state(page)
    add_player(page, "Alice")
    page.click("#entry_0")
    page.wait_for_selector("#numpad.open")
    page.click('.np-btn[data-delta="+5"]')
    assert page.locator("#npValue").inner_text() == "5"
    page.reload()
    page.wait_for_selector("#btnAddPlayer")
    assert page.locator("#entry_0").input_value() == "5", \
        f"Quick-button value lost on reload: {page.locator('#entry_0').input_value()!r}"
    page.click("#entry_0")
    page.wait_for_selector("#numpad.open")
    click_numpad(page, "clear")
    for d in "12":
        page.click(f'.np-btn[data-key="{d}"]')
    click_numpad(page, "done")
    page.wait_for_selector("#numpad.open", state="hidden")
    page.reload()
    page.wait_for_selector("#btnAddPlayer")
    assert page.locator("#entry_0").input_value() == "12", \
        f"Closed-pad value lost on reload: {page.locator('#entry_0').input_value()!r}"
    print("  PASS: pending entry syncs")

A ±N tap settles a value, so it must outlive a reload.

page.click("#entry_0")
page.wait_for_selector("#numpad.open")
page.click('.np-btn[data-delta="+5"]')
assert page.locator("#npValue").inner_text() == "5"
page.reload()
page.wait_for_selector("#btnAddPlayer")
assert page.locator("#entry_0").input_value() == "5", \
    f"Quick-button value lost on reload: {page.locator('#entry_0').input_value()!r}"

Closing the pad settles too: punch in a fresh value, close, reload.

page.click("#entry_0")
page.wait_for_selector("#numpad.open")
click_numpad(page, "clear")
for d in "12":
    page.click(f'.np-btn[data-key="{d}"]')
click_numpad(page, "done")
page.wait_for_selector("#numpad.open", state="hidden")
page.reload()
page.wait_for_selector("#btnAddPlayer")
assert page.locator("#entry_0").input_value() == "12", \
    f"Closed-pad value lost on reload: {page.locator('#entry_0').input_value()!r}"

Committing a round

When the last player’s score is entered and you tap “next”, or when you tap the “+” button in the entry row, the in-progress draft becomes permanent. Since the draft already is the trailing round of yRounds, committing only coerces its untouched cells to zero, freezes it in place, and appends a fresh empty draft for the next round. A safety prompt fires if every score is zero (probably an accidental tap). After committing, the table scrolls to the bottom and the numpad opens on the first player’s input for the next round.

// ── Round management ──
function addRound(){
  var g = Alpine.store('game');
  if(yPlayers.length === 0){ alert('Add players first!'); return; }
  var n = g.players.length;
  var scores = [];
  for(var i = 0; i < n; i++) scores.push(g.draft[i] || 0);
  var anyNonZero = scores.some(function(v){ return v !== 0; });
  if(!anyNonZero && !confirm('All scores are 0. Add this round?')) return;
  closeNumpad();
  // The draft becomes a committed round; a fresh empty draft takes its place.
  var last = yRounds.length - 1;
  if(last >= 0){ yRounds.delete(last, 1); yRounds.insert(last, [scores]); }
  else { yRounds.push([scores]); }
  yRounds.push([emptyRow(n)]);
  render();
  var winners = g.liveWinners;
  var winnerNames = [];
  for(var wi = 0; wi < g.players.length; wi++){
    if(winners[wi]) winnerNames.push(g.players[wi].name);
  }
  if(winnerNames.length > 0){
    showWinToast(winnerNames);
    setTimeout(function(){ openScoreboard(); }, 300);
    return;
  }
  setTimeout(function(){
    $('tableWrap').scrollTop = $('tableWrap').scrollHeight;
    var first = $('scoreBody').querySelector('.entry-input');
    if (first) openNumpad(first);
  }, 50);
}

The entry bar markup used to be a separate fixed element at the bottom, but it’s now rendered as the last row of the table body (the .entry-row) so it scrolls with the data and the numpad sits below it.

<div class="entry-bar" id="entryBar" style="display:none"></div>

A complete round from numpad to totals

This is the core scoring workflow: tap a player’s entry cell, punch in digits on the numpad, press “next” to advance to the next player, and finish the round. The test verifies that the totals row matches the entered values.

@testcase
def test_score_entry(page):
    """Enter scores for a round and verify totals."""
    clear_state(page)
    add_player(page, "Alice")
    add_player(page, "Bob")
    enter_score_via_numpad(page, 0, "10")
    click_numpad(page, "next")
    page.wait_for_selector('#entry_1.active-pad')
    for d in "20":
        page.click(f'.np-btn[data-key="{d}"]')
    click_numpad(page, "next")  # commits the round
    page.wait_for_selector("#numpad.open")
    click_numpad(page, "done")
    t = get_totals(page)
    assert t == [10, 20], f"Expected [10, 20], got {t}"
    print("  PASS: score entry")

Start with two players.

clear_state(page)
add_player(page, "Alice")
add_player(page, "Bob")

Enter 10 for Alice, press next to advance to Bob, enter 20, and press next again to commit the round.

enter_score_via_numpad(page, 0, "10")
click_numpad(page, "next")
page.wait_for_selector('#entry_1.active-pad')
for d in "20":
    page.click(f'.np-btn[data-key="{d}"]')
click_numpad(page, "next")  # commits the round
page.wait_for_selector("#numpad.open")
click_numpad(page, "done")

Totals should reflect 10 and 20.

t = get_totals(page)
assert t == [10, 20], f"Expected [10, 20], got {t}"

Entry row resets after each round

After a round is committed the entry row must return to a clean slate so the next round starts fresh — otherwise leftover digits from the previous round create confusion.

@testcase
def test_entry_reset(page):
    """After committing a round, every entry input must be zero/empty."""
    clear_state(page)
    add_player(page, "Alice")
    add_player(page, "Bob")
    enter_score_via_numpad(page, 0, "10")
    click_numpad(page, "next")
    page.wait_for_selector('#entry_1.active-pad')
    for d in "20":
        page.click(f'.np-btn[data-key="{d}"]')
    click_numpad(page, "next")  # commits the round
    page.wait_for_selector("#numpad.open")
    click_numpad(page, "done")
    # After committing, entry inputs must show 0 or empty
    for i in range(2):
        val = page.locator(f"#entry_{i}").input_value()
        assert val in ("", "0"), f"Entry {i} not reset: got '{val}'"
    print("  PASS: entry reset")

clear_state(page)
add_player(page, "Alice")
add_player(page, "Bob")
enter_score_via_numpad(page, 0, "10")
click_numpad(page, "next")
page.wait_for_selector('#entry_1.active-pad')
for d in "20":
    page.click(f'.np-btn[data-key="{d}"]')
click_numpad(page, "next")  # commits the round
page.wait_for_selector("#numpad.open")
click_numpad(page, "done")
# After committing, entry inputs must show 0 or empty
for i in range(2):
    val = page.locator(f"#entry_{i}").input_value()
    assert val in ("", "0"), f"Entry {i} not reset: got '{val}'"

Untouched entries look different from a real zero

When a round starts, the entry cells should look visually empty — a quiet placeholder () signals “not yet entered” without being mistaken for the number zero. Once the player opens the numpad and confirms without typing anything, the entry becomes a real 0 and displays as such. The green arrow (next / commit) must still work on untouched entries, treating them as zero.

@testcase
def test_placeholder_vs_zero(page):
    """Untouched entries show placeholder; confirmed-empty shows 0."""
    clear_state(page)
    add_player(page, "Alice")
    add_player(page, "Bob")
    # Before touching anything, entries must be empty (placeholder visible)
    page.wait_for_selector("#entry_1")
    page.wait_for_function("document.getElementById('entry_0').value === ''")
    def entry_val(idx):
        return page.evaluate(f"document.getElementById('entry_{idx}').value")
    for i in range(2):
        val = entry_val(i)
        assert val == "", f"Untouched entry {i} should be empty, got '{val}'"
        ph = page.locator(f"#entry_{i}").get_attribute("placeholder")
        assert ph == "–", f"Placeholder should be '–', got '{ph}'"
    # Open numpad for Alice, type 5, press next → moves to Bob
    enter_score_via_numpad(page, 0, "5")
    click_numpad(page, "next")
    page.wait_for_selector('#entry_1.active-pad')
    # Alice's entry shows the typed score; Bob's entry still empty (numpad open but nothing typed)
    assert entry_val(0) == "5", f"Alice entry should be '5', got '{entry_val(0)}'"
    assert entry_val(1) == "", f"Bob entry should still be empty, got '{entry_val(1)}'"
    # Press next on Bob without typing → finalizes Bob to 0 and commits the round
    click_numpad(page, "next")
    page.wait_for_selector("#numpad.open")
    click_numpad(page, "done")
    # After commit, both entries reset to empty (placeholder)
    page.wait_for_function("document.getElementById('entry_0').value === '' || document.getElementById('entry_0').value === '0'")
    page.wait_for_function("document.getElementById('entry_1').value === ''")
    print("  PASS: placeholder vs zero")

clear_state(page)
add_player(page, "Alice")
add_player(page, "Bob")
# Before touching anything, entries must be empty (placeholder visible)
page.wait_for_selector("#entry_1")
page.wait_for_function("document.getElementById('entry_0').value === ''")
def entry_val(idx):
    return page.evaluate(f"document.getElementById('entry_{idx}').value")
for i in range(2):
    val = entry_val(i)
    assert val == "", f"Untouched entry {i} should be empty, got '{val}'"
    ph = page.locator(f"#entry_{i}").get_attribute("placeholder")
    assert ph == "–", f"Placeholder should be '–', got '{ph}'"
# Open numpad for Alice, type 5, press next → moves to Bob
enter_score_via_numpad(page, 0, "5")
click_numpad(page, "next")
page.wait_for_selector('#entry_1.active-pad')
# Alice's entry shows the typed score; Bob's entry still empty (numpad open but nothing typed)
assert entry_val(0) == "5", f"Alice entry should be '5', got '{entry_val(0)}'"
assert entry_val(1) == "", f"Bob entry should still be empty, got '{entry_val(1)}'"
# Press next on Bob without typing → finalizes Bob to 0 and commits the round
click_numpad(page, "next")
page.wait_for_selector("#numpad.open")
click_numpad(page, "done")
# After commit, both entries reset to empty (placeholder)
page.wait_for_function("document.getElementById('entry_0').value === '' || document.getElementById('entry_0').value === '0'")
page.wait_for_function("document.getElementById('entry_1').value === ''")

Totals update as you type

Players want instant feedback while punching in digits — the totals row should reflect what the score would be if the round were committed right now, not lag behind until you press next.

@testcase
def test_live_totals(page):
    """Totals must update in real time during numpad entry."""
    clear_state(page)
    add_player(page, "Alice")
    add_player(page, "Bob")
    # Before any entry, totals should be [0, 0]
    t = get_totals(page)
    assert t == [0, 0], f"Expected [0, 0] initially, got {t}"
    # Start entering a score for Alice without committing
    enter_score_via_numpad(page, 0, "15")
    # Totals should already reflect the pending entry
    t = get_totals(page)
    assert t == [15, 0], f"Expected [15, 0] during entry, got {t}"
    click_numpad(page, "done")
    print("  PASS: live totals")

clear_state(page)
add_player(page, "Alice")
add_player(page, "Bob")
# Before any entry, totals should be [0, 0]
t = get_totals(page)
assert t == [0, 0], f"Expected [0, 0] initially, got {t}"
# Start entering a score for Alice without committing
enter_score_via_numpad(page, 0, "15")
# Totals should already reflect the pending entry
t = get_totals(page)
assert t == [15, 0], f"Expected [15, 0] during entry, got {t}"
click_numpad(page, "done")

Correcting mistakes

Mistakes happen mid-game: you fat-finger a score, record the wrong player, or realize the whole round was bogus. The app needs to handle all of these gracefully without forcing you to restart.

Editing a score

Tap any committed score cell and it lights up with an accent outline while the numpad slides into view, pre-filled with the current value. Edit the number with the same keys you use for new entries, then press done — no modal, no context switch. A “Delete Round” button appears in the numpad when editing, in case the whole round needs to go.

Under the hood, editing replaces the value at yRounds[round][player] by splicing the round array. Deleting a round removes it from yRounds entirely.

<!-- Edit Cell: now uses inline numpad (no modal) -->

// ── Edit cell ──
function openEdit(ri, pi){
  openCell(ri, pi);
}

function deleteEditRound(){
  if(npRound < 0 || npIsDraft()) return;
  if(!confirm('Delete round ' + (npRound+1) + '?')) return;
  var ri = npRound;
  document.querySelectorAll('.score-cell.active-edit').forEach(function(el) { el.classList.remove('active-edit'); });
  $('npDelRound').style.display = 'none';
  Alpine.store('app').showNumpad = false;
  npRound = -1;
  npPlayer = -1;
  yRounds.delete(ri, 1);
  render();
}

/* ── Edit cell highlight ── */
.score-cell.active-edit{
  outline:2px solid var(--accent);
  outline-offset:-2px;
  background:rgba(224,176,255,.1);
}
.np-del-round{
  display:block;margin:0 auto 6px;padding:8px 16px;border-radius:8px;
  background:rgba(255,255,255,.08);border:none;color:var(--muted);
  font-size:.8rem;font-weight:600;cursor:pointer;font-family:inherit;
}
.np-del-round:active{color:var(--red)}

Correcting a score after the round is committed

Mistakes happen — tapping a committed score cell highlights it and opens the numpad so you can correct the value inline. This test enters a round, edits the cell to a different number via the numpad, and checks that the total updates accordingly.

@testcase
def test_edit_score(page):
    """Edit an existing score cell."""
    clear_state(page)
    add_player(page, "Alice")
    enter_score_via_numpad(page, 0, "5")
    click_numpad(page, "next")
    page.wait_for_selector("#numpad.open")
    click_numpad(page, "done")
    page.click('td.score-cell[data-round="0"][data-player="0"]')
    page.wait_for_selector("#numpad.open")
    assert page.query_selector('td.score-cell.active-edit') is not None
    click_numpad(page, "1")
    click_numpad(page, "5")
    # Total updates in real time, before committing
    t = get_totals(page)
    assert t == [15], f"Expected live total [15] during edit, got {t}"
    click_numpad(page, "done")
    page.wait_for_selector("#numpad.open", state="hidden")
    t = get_totals(page)
    assert t == [15], f"Expected [15], got {t}"
    # Tap a past cell to start editing, then tap an entry cell
    page.click('td.score-cell[data-round="0"][data-player="0"]')
    page.wait_for_selector("#numpad.open")
    assert page.query_selector('td.score-cell.active-edit') is not None
    page.click("#entry_0")
    page.wait_for_timeout(100)
    assert page.query_selector('td.score-cell.active-edit') is None, "Edit highlight not cleared when switching to entry"
    click_numpad(page, "done")
    page.wait_for_selector("#numpad.open", state="hidden")
    print("  PASS: edit score")

Add Alice and commit a round with 5 points.

clear_state(page)
add_player(page, "Alice")
enter_score_via_numpad(page, 0, "5")
click_numpad(page, "next")
page.wait_for_selector("#numpad.open")
click_numpad(page, "done")

Tap the committed cell — the numpad slides up and the cell is highlighted. The first digit replaces the old value, so typing 1 then 5 gives 15 (not 515). Before pressing done, the total should already read 15 — scores update in real time as you type.

page.click('td.score-cell[data-round="0"][data-player="0"]')
page.wait_for_selector("#numpad.open")
assert page.query_selector('td.score-cell.active-edit') is not None
click_numpad(page, "1")
click_numpad(page, "5")
# Total updates in real time, before committing
t = get_totals(page)
assert t == [15], f"Expected live total [15] during edit, got {t}"
click_numpad(page, "done")
page.wait_for_selector("#numpad.open", state="hidden")

After pressing done the total is committed and still reads 15.

t = get_totals(page)
assert t == [15], f"Expected [15], got {t}"
# Tap a past cell to start editing, then tap an entry cell
page.click('td.score-cell[data-round="0"][data-player="0"]')
page.wait_for_selector("#numpad.open")
assert page.query_selector('td.score-cell.active-edit') is not None
page.click("#entry_0")
page.wait_for_timeout(100)
assert page.query_selector('td.score-cell.active-edit') is None, "Edit highlight not cleared when switching to entry"
click_numpad(page, "done")
page.wait_for_selector("#numpad.open", state="hidden")

Removing a player

Each player column has a small “x remove” button below the name. A confirmation dialog prevents accidental taps. Removing a player deletes their Y.Map from yPlayers and splices their index out of every round (cleaning up empty rounds that result).

// ── Player management ──
function removePlayer(idx){
  var pm = yPlayers.get(idx);
  if(!confirm('Remove ' + pm.get('name') + '?')) return;
  yPlayers.delete(idx, 1);
  for(var i=0;i<yRounds.length;i++){
    var r = yRounds.get(i).slice();
    r.splice(idx, 1);
    yRounds.delete(i, 1);
    if(r.length > 0) yRounds.insert(i, [r]);
    else { i--; }
  }
  render();
}

Kicking a player out of the game

Removing a player deletes their column from the score table. A confirmation dialog prevents accidental taps. This test checks both the dialog and the DOM cleanup.

@testcase
def test_remove_player(page):
    """Remove a player from the game."""
    clear_state(page)
    add_player(page, "Alice")
    add_player(page, "Bob")
    assert len(get_player_names(page)) == 2
    page.on("dialog", lambda d: d.accept())
    page.click('button.remove-player[data-pidx="0"]')
    names = get_player_names(page)
    assert names == ["Bob"], f"Expected [Bob], got {names}"
    print("  PASS: remove player")

Start with two players.

clear_state(page)
add_player(page, "Alice")
add_player(page, "Bob")
assert len(get_player_names(page)) == 2

Accept the confirmation dialog and remove Alice — only Bob should remain.

page.on("dialog", lambda d: d.accept())
page.click('button.remove-player[data-pidx="0"]')
names = get_player_names(page)
assert names == ["Bob"], f"Expected [Bob], got {names}"

Columns compress when the roster grows

With two or three players the table is spacious — but add five or six and the fixed min-width forces horizontal scrolling. The diagonal headers and per-column sizing should shrink columns enough to fit. This test adds six players whose names share a common prefix and checks that the table still fits inside the viewport without requiring a sideways scroll.

@testcase
def test_columns_compress(page):
    """Many players should not cause horizontal scrolling."""
    clear_state(page)
    for name in ["Samuel", "Samantha", "Sabrina", "Sandra", "Sarah", "Sylvie"]:
        add_player(page, name)
    wrap = page.locator(".table-wrap")
    sw = wrap.evaluate("el => el.scrollWidth")
    cw = wrap.evaluate("el => el.clientWidth")
    assert sw <= cw, f"table overflows: scrollWidth {sw} > clientWidth {cw}"
    # The # column must stay narrow
    hash_w = page.evaluate("document.querySelector('#headerRow th').offsetWidth")
    assert hash_w <= 36, f"# column too wide: {hash_w}px"
    print("  PASS: columns compress")

Start fresh and add six players whose names share a long prefix.

clear_state(page)
for name in ["Samuel", "Samantha", "Sabrina", "Sandra", "Sarah", "Sylvie"]:
    add_player(page, name)

The table wrapper must not scroll horizontally: its scroll width should equal its visible width.

wrap = page.locator(".table-wrap")
sw = wrap.evaluate("el => el.scrollWidth")
cw = wrap.evaluate("el => el.clientWidth")
assert sw <= cw, f"table overflows: scrollWidth {sw} > clientWidth {cw}"
# The # column must stay narrow
hash_w = page.evaluate("document.querySelector('#headerRow th').offsetWidth")
assert hash_w <= 36, f"# column too wide: {hash_w}px"

Changing the playing order

In many tabletop games the seating order matters — it decides who plays after whom. Sometimes you realise mid-game that the order is wrong, or two people swap seats. The reorder button (&#x2195;) in the table’s corner opens a modal where you drag players by their grip handle () to set the new order. Confirming rearranges the columns and moves every historical score so that each player’s data follows them.

// ── Reorder players ──
function openReorderModal() {
  var list = getPlayers().map(function(p){ return { name: p.name }; });
  Alpine.store('app').reorderList = list;
  Alpine.store('app').showReorder = true;
}

function confirmReorder() {
  var newOrder = Alpine.store('app').reorderList;
  var oldNames = getPlayers().map(function(p){ return p.name; });
  // Build permutation: perm[newIdx] = oldIdx
  var perm = newOrder.map(function(p) {
    return oldNames.indexOf(p.name);
  });
  // Apply to yPlayers: delete all and re-create in new order
  yPlayers.delete(0, yPlayers.length);
  perm.forEach(function(oldIdx) {
    var pm = new Y.Map();
    pm.set('name', oldNames[oldIdx]);
    yPlayers.push([pm]);
  });
  // Apply to yRounds: permute each round
  for (var i = 0; i < yRounds.length; i++) {
    var old = yRounds.get(i);
    var newRow = perm.map(function(oldIdx){ return old[oldIdx] || 0; });
    yRounds.delete(i, 1);
    yRounds.insert(i, [newRow]);
  }
  // The draft is the trailing round, so it is permuted in lockstep above.
  Alpine.store('app').showReorder = false;
  render();
}

// ── Wire shared reorder engine to Alpine stores ──
// The engine (shared_blocks.org:js-reorder-engine) fires =reorder:move=
// on the list when the user drags an item across slots. We update the
// corresponding =Alpine.store('app')[dataset.reorder]= array, Alpine
// re-renders the x-for.
document.addEventListener('reorder:move', function(e) {
  var list = e.target.closest('.reorder-list');
  if (!list) return;
  var key = list.dataset.reorder;
  if (!key) return;
  var arr = Alpine.store('app')[key].slice();
  var moved = arr.splice(e.detail.from, 1)[0];
  arr.splice(e.detail.to, 0, moved);
  Alpine.store('app')[key] = arr;
});

<!-- Reorder Players -->
<div class="modal-overlay" id="modalReorder" :class="{ 'open': $store.app.showReorder }" @click.self="$store.app.showReorder = false">
  <div class="modal">
    <h2>Player Order</h2>
    <div class="reorder-list" data-reorder="reorderList">
      <template x-for="(p, i) in $store.app.reorderList" :key="'ro'+i">
        <div class="reorder-item" :data-idx="i">
          <span class="reorder-grip" aria-label="Drag to reorder"></span>
          <span class="reorder-rank" x-text="i + 1"></span>
          <span class="reorder-name" x-text="p.name"></span>
        </div>
      </template>
    </div>
    <button class="modal-close" id="reorderConfirm" @click="confirmReorder()">Apply</button>
    <button class="modal-close" id="reorderCancel" style="background:var(--muted);margin-top:8px" @click="$store.app.showReorder = false">Cancel</button>
  </div>
</div>

Le CSS du drag-and-drop (.reorder-list, .reorder-item, .drag-clone, .reorder-grip) vit dans shared_blocks.org (section Reorder engine) et est inclus via <<shared_blocks.org:css-reorder-ex()>> dans le root block, avec l’engine JS.

Reordering players mid-game

After entering a round of scores, the reorder button lets you drag players into a new order. The test enters one round (10 for Alice, 20 for Bob), then drags Bob above Alice and checks that the names and totals follow suit.

@testcase
def test_reorder_mid_game(page):
    """Reorder players mid-game and verify scores follow."""
    clear_state(page)
    add_player(page, "Alice")
    add_player(page, "Bob")
    enter_score_via_numpad(page, 0, "10")
    click_numpad(page, "next")
    page.wait_for_selector('#entry_1.active-pad')
    for d in "20":
        page.click(f'.np-btn[data-key="{d}"]')
    click_numpad(page, "next")
    page.wait_for_selector("#numpad.open")
    click_numpad(page, "done")
    page.click("#btnReorder")
    page.wait_for_selector("#modalReorder.open")
    # Drag Bob (index 1) above Alice (index 0)
    drag_reorder(page, "#modalReorder .reorder-list", 1, 0)
    page.click("#reorderConfirm")
    page.wait_for_selector("#modalReorder", state="hidden")
    names = get_player_names(page)
    assert names == ["Bob", "Alice"], f"Expected [Bob, Alice], got {names}"
    t = get_totals(page)
    assert t == [20, 10], f"Expected [20, 10], got {t}"
    print("  PASS: reorder mid-game")

Start with Alice and Bob, enter a round of scores.

clear_state(page)
add_player(page, "Alice")
add_player(page, "Bob")
enter_score_via_numpad(page, 0, "10")
click_numpad(page, "next")
page.wait_for_selector('#entry_1.active-pad')
for d in "20":
    page.click(f'.np-btn[data-key="{d}"]')
click_numpad(page, "next")
page.wait_for_selector("#numpad.open")
click_numpad(page, "done")

Open the reorder modal and drag Bob above Alice (from index 1 to index 0).

page.click("#btnReorder")
page.wait_for_selector("#modalReorder.open")
# Drag Bob (index 1) above Alice (index 0)
drag_reorder(page, "#modalReorder .reorder-list", 1, 0)
page.click("#reorderConfirm")
page.wait_for_selector("#modalReorder", state="hidden")

Bob should now be first with his score of 20, Alice second with 10.

names = get_player_names(page)
assert names == ["Bob", "Alice"], f"Expected [Bob, Alice], got {names}"
t = get_totals(page)
assert t == [20, 10], f"Expected [20, 10], got {t}"

Scores survive reordering across multiple rounds

With three players and two rounds already committed, reordering must permute every historical row so that the totals remain correct.

@testcase
def test_reorder_preserves_scores(page):
    """Reordering preserves scores across multiple rounds."""
    clear_state(page)
    for name in ["Alice", "Bob", "Charlie"]:
        add_player(page, name)
    # Round 1: 10, 20, 30
    enter_score_via_numpad(page, 0, "10")
    click_numpad(page, "next")
    page.wait_for_selector('#entry_1.active-pad')
    for d in "20": page.click(f'.np-btn[data-key="{d}"]')
    click_numpad(page, "next")
    page.wait_for_selector('#entry_2.active-pad')
    for d in "30": page.click(f'.np-btn[data-key="{d}"]')
    click_numpad(page, "next")
    page.wait_for_selector("#numpad.open")
    click_numpad(page, "done")
    # Round 2: 5, 15, 25
    enter_score_via_numpad(page, 0, "5")
    click_numpad(page, "next")
    page.wait_for_selector('#entry_1.active-pad')
    for d in "15": page.click(f'.np-btn[data-key="{d}"]')
    click_numpad(page, "next")
    page.wait_for_selector('#entry_2.active-pad')
    for d in "25": page.click(f'.np-btn[data-key="{d}"]')
    click_numpad(page, "next")
    page.wait_for_selector("#numpad.open")
    click_numpad(page, "done")
    page.click("#btnReorder")
    page.wait_for_selector("#modalReorder.open")
    # Drag Charlie (index 2) to the top (index 0)
    drag_reorder(page, "#modalReorder .reorder-list", 2, 0)
    page.click("#reorderConfirm")
    page.wait_for_selector("#modalReorder", state="hidden")
    names = get_player_names(page)
    assert names == ["Charlie", "Alice", "Bob"], f"Expected [Charlie, Alice, Bob], got {names}"
    t = get_totals(page)
    assert t == [55, 15, 35], f"Expected [55, 15, 35], got {t}"
    print("  PASS: reorder preserves scores")

Set up three players with two rounds: round 1 = [10, 20, 30], round 2 = [5, 15, 25]. Totals before reorder: [15, 35, 55].

clear_state(page)
for name in ["Alice", "Bob", "Charlie"]:
    add_player(page, name)
# Round 1: 10, 20, 30
enter_score_via_numpad(page, 0, "10")
click_numpad(page, "next")
page.wait_for_selector('#entry_1.active-pad')
for d in "20": page.click(f'.np-btn[data-key="{d}"]')
click_numpad(page, "next")
page.wait_for_selector('#entry_2.active-pad')
for d in "30": page.click(f'.np-btn[data-key="{d}"]')
click_numpad(page, "next")
page.wait_for_selector("#numpad.open")
click_numpad(page, "done")
# Round 2: 5, 15, 25
enter_score_via_numpad(page, 0, "5")
click_numpad(page, "next")
page.wait_for_selector('#entry_1.active-pad')
for d in "15": page.click(f'.np-btn[data-key="{d}"]')
click_numpad(page, "next")
page.wait_for_selector('#entry_2.active-pad')
for d in "25": page.click(f'.np-btn[data-key="{d}"]')
click_numpad(page, "next")
page.wait_for_selector("#numpad.open")
click_numpad(page, "done")

Drag Charlie (index 2) to the top (index 0). New order should be Charlie, Alice, Bob → totals [55, 15, 35].

page.click("#btnReorder")
page.wait_for_selector("#modalReorder.open")
# Drag Charlie (index 2) to the top (index 0)
drag_reorder(page, "#modalReorder .reorder-list", 2, 0)
page.click("#reorderConfirm")
page.wait_for_selector("#modalReorder", state="hidden")

Totals must reflect the new column order.

names = get_player_names(page)
assert names == ["Charlie", "Alice", "Bob"], f"Expected [Charlie, Alice, Bob], got {names}"
t = get_totals(page)
assert t == [55, 15, 35], f"Expected [55, 15, 35], got {t}"

Reorder button hidden with a single player

Reordering makes no sense with fewer than two players, so the button should be invisible in that case.

@testcase
def test_reorder_button_hidden(page):
    """Reorder button is hidden when fewer than two players."""
    clear_state(page)
    assert not page.locator("#btnReorder").is_visible(), "should be hidden with no players"
    add_player(page, "Alice")
    assert not page.locator("#btnReorder").is_visible(), "should be hidden with one player"
    add_player(page, "Bob")
    page.wait_for_selector("#btnReorder", state="visible")
    assert page.locator("#btnReorder").is_visible(), "should be visible with two players"
    print("  PASS: reorder button hidden")

Who’s winning?

Two mechanisms give instant feedback on the game state.

The totals row at the bottom of the table always shows cumulative scores. When there are two or more players, the leader’s total is highlighted in gold — a subtle but satisfying visual cue.

The scoreboard modal (trophy button in the header) shows all players ranked by score, with gold/silver/bronze styling for the podium. This is the “end of game” view: a quick way to see the final standings without counting columns.

Not every game rewards the highest score. In golf or hearts the winner is the player with the fewest points. A “Lowest score wins” checkbox in the game-setup modal flips the logic: the totals row highlights the minimum instead of the maximum, and the scoreboard sorts ascending. The flag is stored alongside the game name in the shared yMeta map, so every device sees the same ranking direction.

The scoreboard sorts players by total score and renders ranked rows with gold/silver/bronze styling for the top 3. When two players are tied they share the same rank and podium colour. Tapping the trophy button builds the list on the fly from the current Yjs state.

<!-- Scoreboard Modal -->
<div class="modal-overlay" id="modalScoreboard" :class="{ 'open': $store.app.showScoreboard }" @click.self="$store.app.showScoreboard = false">
  <div class="modal">
    <h2>&#x1f3c6; Results</h2>
    <div id="scoreboardList">
      <template x-for="(e, i) in $store.game.scoreboardEntries" :key="'sb'+i">
        <div class="modal-row">
          <div>
            <span class="rank" :class="{ gold: e.rank===0, silver: e.rank===1, bronze: e.rank===2 }"
              x-text="'#' + (e.rank+1)"></span>
            <span class="modal-name" x-text="e.name"></span>
          </div>
          <span class="modal-score" x-text="e.score"></span>
        </div>
      </template>
      <p x-show="$store.game.players.length === 0" style="color:var(--muted);text-align:center;padding:20px">No players yet.</p>
    </div>
    <label style="display:flex;align-items:center;gap:8px;margin:12px 0;cursor:pointer;font-size:.9rem">
      <input type="checkbox" id="scoreboardLowWins"
        :checked="$store.game.lowWins"
        @change="toggleLowWins($el.checked)"> Lowest score wins
    </label>
    <label style="display:block;margin:8px 0;font-size:.85rem;color:var(--muted)">Score transform (JS)
      <input type="text" id="scoreTransformInput" placeholder="e.g. total > 50 ? 25 : total"
        :value="$store.game.scoreTransform"
        @change="setScoreTransform($el.value)"
        style="width:100%;box-sizing:border-box;margin-top:4px;padding:8px;border-radius:8px;border:1.5px solid rgba(255,255,255,.12);background:var(--bg);color:var(--text);font-family:monospace;font-size:.85rem">
    </label>
    <label style="display:block;margin:8px 0;font-size:.85rem;color:var(--muted)">End condition (JS)
      <input type="text" id="endConditionInput" placeholder="e.g. total > 10"
        :value="$store.game.endCondition"
        @change="setEndCondition($el.value)"
        style="width:100%;box-sizing:border-box;margin-top:4px;padding:8px;border-radius:8px;border:1.5px solid rgba(255,255,255,.12);background:var(--bg);color:var(--text);font-family:monospace;font-size:.85rem">
    </label>
    <div style="margin:8px 0;display:flex;gap:8px">
      <button class="preset-btn" id="presetMolkky" @click="applyPreset('molkky')">M&#xF6;lkky</button>
      <button class="preset-btn" id="presetOdin" @click="applyPreset('odin')">Odin</button>
    </div>
    <button class="modal-close" id="closeScoreboard" @click="$store.app.showScoreboard = false">Close</button>
  </div>
</div>

// ── Scoreboard ──
function openScoreboard(){
  Alpine.store('app').showScoreboard = true;
}
function toggleLowWins(checked){
  yMeta.set('lowWins', checked);
  render();
}
function setScoreTransform(v){
  yMeta.set('scoreTransform', v);
  _cachedTransformFn = null;
  render();
}
function setEndCondition(v){
  yMeta.set('endCondition', v);
  _cachedEndFn = null;
  render();
}
// A preset fully describes a game — transform, end condition, victory
// direction — in one place, so the two apply paths and the name-match
// below all read the same source. The 'names' are what a player types.
var PRESETS = {
  molkky: { names: ['molkky', 'mölkky'], scoreTransform: 'total > 50 ? 25 : total', endCondition: 'total === 50', lowWins: false },
  odin:   { names: ['odin'],             scoreTransform: '',                        endCondition: 'total >= 10',   lowWins: true  }
};
function applyPreset(key){
  var p = PRESETS[key];
  if(!p) return;
  $('scoreTransformInput').value = p.scoreTransform;
  $('endConditionInput').value = p.endCondition;
  setScoreTransform(p.scoreTransform);
  setEndCondition(p.endCondition);
  toggleLowWins(p.lowWins);
}
function applyPickPreset(key){
  var p = PRESETS[key];
  if(!p) return;
  $('pickScoreTransform').value = p.scoreTransform;
  $('pickEndCondition').value = p.endCondition;
  $('pickLowWins').checked = p.lowWins;
}
// A preset's name is also the game's name, so typing it is enough to set
// the game up — no need to reach for the button. The match ignores case
// and the Mölkky umlaut.
function presetForName(name){
  var n = (name || '').trim().toLowerCase();
  for(var key in PRESETS){
    if(PRESETS[key].names.indexOf(n) !== -1) return key;
  }
  return null;
}
function onPickGameNameInput(){
  updatePickStartEnabled();
  var preset = presetForName($('pickGameName').value);
  if(preset) applyPickPreset(preset);
}
function showWinToast(names){
  var el = $('winToast');
  var verb = names.length > 1 ? ' win!' : ' wins!';
  el.textContent = '\uD83C\uDFC6 ' + names.join(', ') + verb;
  el.classList.add('show');
  setTimeout(function(){ el.classList.remove('show'); }, 4000);
}

Checking the scoreboard ranks players correctly

The scoreboard ranks players by total score, highest first. This catches sorting regressions and verifies the modal renders player names next to their totals.

@testcase
def test_scoreboard(page):
    """Scoreboard shows players sorted by score."""
    clear_state(page)
    add_player(page, "Alice")
    add_player(page, "Bob")
    enter_score_via_numpad(page, 0, "5")
    click_numpad(page, "next")
    page.wait_for_selector('#entry_1.active-pad')
    for d in "15":
        page.click(f'.np-btn[data-key="{d}"]')
    click_numpad(page, "next")
    page.wait_for_selector("#numpad.open")
    click_numpad(page, "done")
    page.click("#btnScoreboard")
    page.wait_for_selector("#modalScoreboard.open")
    rows = page.locator(".modal-row")
    first_name = rows.nth(0).locator(".modal-name").inner_text()
    assert first_name == "Bob", f"Expected Bob first, got {first_name}"
    page.click("#closeScoreboard")
    print("  PASS: scoreboard")

Give Alice 5 and Bob 15 so Bob should rank first.

clear_state(page)
add_player(page, "Alice")
add_player(page, "Bob")
enter_score_via_numpad(page, 0, "5")
click_numpad(page, "next")
page.wait_for_selector('#entry_1.active-pad')
for d in "15":
    page.click(f'.np-btn[data-key="{d}"]')
click_numpad(page, "next")
page.wait_for_selector("#numpad.open")
click_numpad(page, "done")

Open the scoreboard modal.

page.click("#btnScoreboard")
page.wait_for_selector("#modalScoreboard.open")

Bob (15) should be ranked above Alice (5).

rows = page.locator(".modal-row")
first_name = rows.nth(0).locator(".modal-name").inner_text()
assert first_name == "Bob", f"Expected Bob first, got {first_name}"

Tied players share the same podium rank

When two players finish with the same score they should share the same rank and podium styling — both gold if they are tied for first, both silver if tied for second, and so on.

@testcase
def test_scoreboard_tie(page):
    """Tied players share the same rank and podium class."""
    clear_state(page)
    add_player(page, "Alice")
    add_player(page, "Bob")
    add_player(page, "Carol")
    enter_score_via_numpad(page, 0, "10")
    click_numpad(page, "next")
    page.wait_for_selector('#entry_1.active-pad')
    enter_score_via_numpad(page, 1, "10")
    click_numpad(page, "next")
    page.wait_for_selector('#entry_2.active-pad')
    enter_score_via_numpad(page, 2, "5")
    click_numpad(page, "next")
    page.wait_for_selector("#numpad.open")
    click_numpad(page, "done")
    page.click("#btnScoreboard")
    page.wait_for_selector("#modalScoreboard.open")
    rows = page.locator(".modal-row")
    for idx in range(2):
        rank_el = rows.nth(idx).locator(".rank")
        rank_text = rank_el.inner_text()
        assert rank_text == "#1", f"Player {idx} rank should be #1, got {rank_text}"
        rank_cls = rank_el.get_attribute("class") or ""
        assert "gold" in rank_cls, f"Player {idx} should have gold class, got {rank_cls}"
    carol_rank = rows.nth(2).locator(".rank")
    assert carol_rank.inner_text() == "#2", f"Carol should be #2, got {carol_rank.inner_text()}"
    carol_cls = carol_rank.get_attribute("class") or ""
    assert "silver" in carol_cls, f"Carol should have silver class, got {carol_cls}"
    page.click("#closeScoreboard")
    print("  PASS: scoreboard tie")

Give Alice, Bob, and Carol scores of 10, 10, and 5 then open the scoreboard.

clear_state(page)
add_player(page, "Alice")
add_player(page, "Bob")
add_player(page, "Carol")
enter_score_via_numpad(page, 0, "10")
click_numpad(page, "next")
page.wait_for_selector('#entry_1.active-pad')
enter_score_via_numpad(page, 1, "10")
click_numpad(page, "next")
page.wait_for_selector('#entry_2.active-pad')
enter_score_via_numpad(page, 2, "5")
click_numpad(page, "next")
page.wait_for_selector("#numpad.open")
click_numpad(page, "done")
page.click("#btnScoreboard")
page.wait_for_selector("#modalScoreboard.open")

Alice and Bob (both 10) should display #1 with the gold class. Carol (5) comes right after the tied pair, so she should be #2 with silver — not #3 with bronze.

rows = page.locator(".modal-row")
for idx in range(2):
    rank_el = rows.nth(idx).locator(".rank")
    rank_text = rank_el.inner_text()
    assert rank_text == "#1", f"Player {idx} rank should be #1, got {rank_text}"
    rank_cls = rank_el.get_attribute("class") or ""
    assert "gold" in rank_cls, f"Player {idx} should have gold class, got {rank_cls}"
carol_rank = rows.nth(2).locator(".rank")
assert carol_rank.inner_text() == "#2", f"Carol should be #2, got {carol_rank.inner_text()}"
carol_cls = carol_rank.get_attribute("class") or ""
assert "silver" in carol_cls, f"Carol should have silver class, got {carol_cls}"

Low-wins mode flips ranking and highlight

Some games reward the lowest score — golf, for instance, or hearts. When the “Lowest score wins” toggle is on during game setup, the scoreboard should rank the player with the fewest points first, and the totals row should highlight that player’s column in gold.

@testcase
def test_scoreboard_low_wins(page):
    """Low-wins mode ranks lowest score first and highlights it."""
    clear_state(page)
    add_player(page, "Alice")
    add_player(page, "Bob")
    open_new_game(page)
    page.fill("#pickGameName", "Golf")
    page.check("#pickLowWins")
    page.click("#pickStart")
    page.wait_for_selector("#modalPick", state="hidden")
    enter_score_via_numpad(page, 0, "15")
    click_numpad(page, "next")
    page.wait_for_selector('#entry_1.active-pad')
    enter_score_via_numpad(page, 1, "5")
    click_numpad(page, "next")
    page.wait_for_selector("#numpad.open")
    click_numpad(page, "done")
    total_cells = page.locator("#totalRow td:not(:first-child)")
    alice_cls = total_cells.nth(0).get_attribute("class") or ""
    bob_cls = total_cells.nth(1).get_attribute("class") or ""
    assert "winner" in bob_cls, f"Expected Bob to be winner, classes: {bob_cls}"
    assert "winner" not in alice_cls, f"Alice should not be winner, classes: {alice_cls}"
    page.click("#btnScoreboard")
    page.wait_for_selector("#modalScoreboard.open")
    rows = page.locator(".modal-row")
    first_name = rows.nth(0).locator(".modal-name").inner_text()
    assert first_name == "Bob", f"Expected Bob first in low-wins, got {first_name}"
    page.click("#closeScoreboard")
    print("  PASS: scoreboard low-wins")

Start a new game with “Lowest score wins” checked. We reuse the roster already present from earlier tests, then create a fresh game via the pick modal.

clear_state(page)
add_player(page, "Alice")
add_player(page, "Bob")
open_new_game(page)
page.fill("#pickGameName", "Golf")
page.check("#pickLowWins")
page.click("#pickStart")
page.wait_for_selector("#modalPick", state="hidden")

Give Alice 15 and Bob 5 so Bob has the lower — and winning — score.

enter_score_via_numpad(page, 0, "15")
click_numpad(page, "next")
page.wait_for_selector('#entry_1.active-pad')
enter_score_via_numpad(page, 1, "5")
click_numpad(page, "next")
page.wait_for_selector("#numpad.open")
click_numpad(page, "done")

Bob’s total column (index 1) should carry the winner class, not Alice’s.

total_cells = page.locator("#totalRow td:not(:first-child)")
alice_cls = total_cells.nth(0).get_attribute("class") or ""
bob_cls = total_cells.nth(1).get_attribute("class") or ""
assert "winner" in bob_cls, f"Expected Bob to be winner, classes: {bob_cls}"
assert "winner" not in alice_cls, f"Alice should not be winner, classes: {alice_cls}"

And the scoreboard should rank Bob (5) above Alice (15).

page.click("#btnScoreboard")
page.wait_for_selector("#modalScoreboard.open")
rows = page.locator(".modal-row")
first_name = rows.nth(0).locator(".modal-name").inner_text()
assert first_name == "Bob", f"Expected Bob first in low-wins, got {first_name}"

Toggling low-wins from the podium

You might realise mid-game that you picked the wrong scoring direction. The scoreboard modal includes the same “Lowest score wins” toggle so you can flip it without restarting. The ranking and the totals-row highlight update immediately.

@testcase
def test_scoreboard_toggle_low_wins(page):
    """Toggling low-wins in the scoreboard flips ranking live."""
    clear_state(page)
    add_player(page, "Alice")
    add_player(page, "Bob")
    enter_score_via_numpad(page, 0, "5")
    click_numpad(page, "next")
    page.wait_for_selector('#entry_1.active-pad')
    for d in "15":
        page.click(f'.np-btn[data-key="{d}"]')
    click_numpad(page, "next")
    page.wait_for_selector("#numpad.open")
    click_numpad(page, "done")
    page.click("#btnScoreboard")
    page.wait_for_selector("#modalScoreboard.open")
    page.check("#scoreboardLowWins")
    rows = page.locator(".modal-row")
    first_name = rows.nth(0).locator(".modal-name").inner_text()
    assert first_name == "Alice", f"Expected Alice first after toggle, got {first_name}"
    total_cells = page.locator("#totalRow td:not(:first-child)")
    alice_cls = total_cells.nth(0).get_attribute("class") or ""
    assert "winner" in alice_cls, f"Alice should be winner after toggle, classes: {alice_cls}"
    page.click("#closeScoreboard")
    print("  PASS: scoreboard toggle low-wins")

We reuse the same setup as the regular scoreboard test: Alice has 5 and Bob has 15, so in the default high-wins mode Bob ranks first. Opening the scoreboard and checking “Lowest score wins” should flip the order — Alice (5) moves to first place.

page.check("#scoreboardLowWins")

rows = page.locator(".modal-row")
first_name = rows.nth(0).locator(".modal-name").inner_text()
assert first_name == "Alice", f"Expected Alice first after toggle, got {first_name}"
total_cells = page.locator("#totalRow td:not(:first-child)")
alice_cls = total_cells.nth(0).get_attribute("class") or ""
assert "winner" in alice_cls, f"Alice should be winner after toggle, classes: {alice_cls}"

Custom rules

Some games need rules beyond “add it up”. Mölkky resets a player to 25 when they pass 50, ends the moment someone lands on exactly 50, and the highest score takes it. Odin ends the moment someone passes 10 — and there the lowest score wins, so the player who crossed the line is the loser. The same act, triggering the end, makes you the victor in one game and the loser in the other. Three things vary independently, so the app exposes each as a per-game setting:

  • Score transform — rewrites each player’s running total after every round. total > 50 ? 25 : total is the Mölkky reset.
  • End condition — the moment any player’s total satisfies it, the game is over. total > 10 is Odin’s threshold.
  • Victory direction — the “Lowest score wins” toggle that already drives the ranking. It alone decides who the end crowns.

Pulling end and victory apart is what lets one engine cover both games: the end condition only stops the clock — it never names a winner — and the ranking direction picks who stood best when it did. Mölkky is “end on total == 50=, highest wins”; Odin is “end on total > 10, lowest wins”.

The transform and end-condition fields take any JavaScript expression, seeing total (the player’s cumulative score), playerIndex (their column), and round (zero-based). Typing them by hand is fiddly, so a preset button fills all three settings at once: Mölkky and Odin sit beside the fields in both the game-setup and scoreboard modals. Every setting persists in yMeta, synced across devices like the rest.

When the end condition fires after a completed round, the victors’ total cells receive a .game-won class, a toast banner names them, and the scoreboard modal opens automatically.

<div id="winToast" class="win-toast"></div>

.win-toast{
  position:fixed;top:0;left:0;right:0;
  padding:16px;text-align:center;font-size:1.1rem;font-weight:700;
  background:linear-gradient(135deg,#ffd700,#ff8c00);color:#1a1a2e;
  transform:translateY(-100%);transition:transform .3s ease;
  z-index:9999;
}
.win-toast.show{transform:translateY(0)}

Flagging the deciding round before it is committed

A player rarely waits for the app to agree that they have won. The last score drops in, someone calls it, and the round is abandoned uncommitted while the table moves on to celebrating. The win is real to the players long before anything is committed — and the decision happens right there, with the thumb resting on the commit button it never pressed.

So the button says it itself. The moment the round being typed would satisfy the end condition, the green + takes on a discreet gold glow, and loses it the instant the scores fall back below the line. It stays quiet on purpose — a loud cue here would fight the next keystroke — so it only tints the button in place, shifting nothing in the row.

The cue has to track the live draft, not merely the presence of an end condition: with the condition armed but the row still blank it stays off, and only once a typed total crosses the line does it appear.

@testcase
def test_end_condition_hint(page):
    """Typing a draft past the end condition flags the commit button live."""
    clear_state(page)
    add_player(page, "Alice")
    add_player(page, "Bob")
    page.click("#btnScoreboard")
    page.wait_for_selector("#modalScoreboard.open")
    page.fill("#endConditionInput", "total >= 10")
    page.click("#closeScoreboard")
    page.wait_for_selector("#modalScoreboard", state="hidden")
    assert "ends-game" not in (page.locator("#entryConfirm").get_attribute("class") or ""), \
        "commit button should not flag the end before any score crosses the line"
    enter_score_via_numpad(page, 0, "10")
    page.wait_for_function(
        "document.querySelector('#entryConfirm').classList.contains('ends-game')",
        timeout=5000)
    print("  PASS: end condition hint")

Two players and an Odin-style threshold — the game ends once anyone reaches ten.

clear_state(page)
add_player(page, "Alice")
add_player(page, "Bob")
page.click("#btnScoreboard")
page.wait_for_selector("#modalScoreboard.open")
page.fill("#endConditionInput", "total >= 10")
page.click("#closeScoreboard")
page.wait_for_selector("#modalScoreboard", state="hidden")

With the condition armed but no score typed yet, the commit button is unflagged.

assert "ends-game" not in (page.locator("#entryConfirm").get_attribute("class") or ""), \
    "commit button should not flag the end before any score crosses the line"

Type ten for Alice — without committing — and the button flags that finishing this round ends the game.

enter_score_via_numpad(page, 0, "10")
page.wait_for_function(
    "document.querySelector('#entryConfirm').classList.contains('ends-game')",
    timeout=5000)

Whether finishing the round ends the game is a fact about the totals, so the store derives it once — endConditionMet asks whether any live total, the draft folded in, satisfies the end function — and the commit button binds its ends-game cue to that flag.

The cue is a soft gold ring over the green button: no fill, no growth, so it tints the button without nudging the row.

.entry-confirm.ends-game{box-shadow:0 0 0 2px #ffd700,0 0 10px 1px rgba(255,215,0,.55)}

Score transform resets totals exceeding a threshold

A Mölkky-style transform resets a player to 25 whenever their score goes past 50. This test enters two rounds of scores that push a player over the limit and checks that the total reflects the reset.

@testcase
def test_score_transform(page):
    """Score transform applies cumulatively per round."""
    clear_state(page)
    add_player(page, "Alice")
    add_player(page, "Bob")
    # Set transform via scoreboard modal
    page.click("#btnScoreboard")
    page.wait_for_selector("#modalScoreboard.open")
    page.fill("#scoreTransformInput", "total > 50 ? 25 : total")
    page.click("#closeScoreboard")
    # Round 1: Alice 30, Bob 10
    enter_score_via_numpad(page, 0, "30")
    click_numpad(page, "next")
    page.wait_for_selector('#entry_1.active-pad')
    enter_score_via_numpad(page, 1, "10")
    click_numpad(page, "next")
    page.wait_for_selector("#numpad.open")
    click_numpad(page, "done")
    # Round 2: Alice 25 (total would be 55, resets to 25), Bob 10
    enter_score_via_numpad(page, 0, "25")
    click_numpad(page, "next")
    page.wait_for_selector('#entry_1.active-pad')
    enter_score_via_numpad(page, 1, "10")
    click_numpad(page, "next")
    page.wait_for_selector("#numpad.open")
    click_numpad(page, "done")
    total_cells = page.locator("#totalRow td:not(:first-child)")
    alice_total = int(total_cells.nth(0).inner_text())
    bob_total = int(total_cells.nth(1).inner_text())
    assert alice_total == 25, f"Alice total should be 25, got {alice_total}"
    assert bob_total == 20, f"Bob total should be 20, got {bob_total}"
    print("  PASS: score transform")

Set up two players, configure the transform via JavaScript, enter two rounds so that Alice’s total exceeds 50.

clear_state(page)
add_player(page, "Alice")
add_player(page, "Bob")
# Set transform via scoreboard modal
page.click("#btnScoreboard")
page.wait_for_selector("#modalScoreboard.open")
page.fill("#scoreTransformInput", "total > 50 ? 25 : total")
page.click("#closeScoreboard")
# Round 1: Alice 30, Bob 10
enter_score_via_numpad(page, 0, "30")
click_numpad(page, "next")
page.wait_for_selector('#entry_1.active-pad')
enter_score_via_numpad(page, 1, "10")
click_numpad(page, "next")
page.wait_for_selector("#numpad.open")
click_numpad(page, "done")
# Round 2: Alice 25 (total would be 55, resets to 25), Bob 10
enter_score_via_numpad(page, 0, "25")
click_numpad(page, "next")
page.wait_for_selector('#entry_1.active-pad')
enter_score_via_numpad(page, 1, "10")
click_numpad(page, "next")
page.wait_for_selector("#numpad.open")
click_numpad(page, "done")

Alice’s total should be 25 (reset from 55), Bob’s should be 20.

total_cells = page.locator("#totalRow td:not(:first-child)")
alice_total = int(total_cells.nth(0).inner_text())
bob_total = int(total_cells.nth(1).inner_text())
assert alice_total == 25, f"Alice total should be 25, got {alice_total}"
assert bob_total == 20, f"Bob total should be 20, got {bob_total}"

Mölkky: the end fires and the highest total takes it

In Mölkky the end condition total == 50= and the victory both land on the same player — whoever reaches 50 is also the highest. This test drives Alice to exactly 50 and checks she is crowned while Bob is not.

@testcase
def test_win_condition(page):
    """Reaching the end-condition total ends the game; the highest wins."""
    clear_state(page)
    add_player(page, "Alice")
    add_player(page, "Bob")
    # Set the end condition via scoreboard modal
    page.click("#btnScoreboard")
    page.wait_for_selector("#modalScoreboard.open")
    page.fill("#endConditionInput", "total === 50")
    page.click("#closeScoreboard")
    # Round 1: Alice 50, Bob 10
    enter_score_via_numpad(page, 0, "50")
    click_numpad(page, "next")
    page.wait_for_selector('#entry_1.active-pad')
    enter_score_via_numpad(page, 1, "10")
    # Committing the round ends the game — numpad won't reopen
    click_numpad(page, "next")
    total_cells = page.locator("#totalRow td:not(:first-child)")
    alice_cls = total_cells.nth(0).get_attribute("class") or ""
    assert "game-won" in alice_cls, f"Alice should have game-won class, got: {alice_cls}"
    bob_cls = total_cells.nth(1).get_attribute("class") or ""
    assert "game-won" not in bob_cls, f"Bob should not have game-won class, got: {bob_cls}"
    # Toast should appear with winner name
    toast = page.locator("#winToast")
    toast.wait_for(state="visible", timeout=3000)
    toast_text = toast.inner_text()
    assert "Alice" in toast_text, f"Toast should mention Alice, got: {toast_text}"
    # Scoreboard should have opened automatically (after 300ms delay)
    page.wait_for_selector("#modalScoreboard.open", timeout=3000)
    assert modal_is_open(page, "modalScoreboard"), "Scoreboard should auto-open on win"
    page.click("#closeScoreboard")
    print("  PASS: win condition")

Set up a game with Alice and Bob, configure the end condition, and enter scores so that Alice reaches exactly 50.

clear_state(page)
add_player(page, "Alice")
add_player(page, "Bob")
# Set the end condition via scoreboard modal
page.click("#btnScoreboard")
page.wait_for_selector("#modalScoreboard.open")
page.fill("#endConditionInput", "total === 50")
page.click("#closeScoreboard")
# Round 1: Alice 50, Bob 10
enter_score_via_numpad(page, 0, "50")
click_numpad(page, "next")
page.wait_for_selector('#entry_1.active-pad')
enter_score_via_numpad(page, 1, "10")
# Committing the round ends the game — numpad won't reopen
click_numpad(page, "next")

Alice’s total cell should have the game-won class, a toast should appear with her name, and the scoreboard modal should open automatically.

total_cells = page.locator("#totalRow td:not(:first-child)")
alice_cls = total_cells.nth(0).get_attribute("class") or ""
assert "game-won" in alice_cls, f"Alice should have game-won class, got: {alice_cls}"
bob_cls = total_cells.nth(1).get_attribute("class") or ""
assert "game-won" not in bob_cls, f"Bob should not have game-won class, got: {bob_cls}"
# Toast should appear with winner name
toast = page.locator("#winToast")
toast.wait_for(state="visible", timeout=3000)
toast_text = toast.inner_text()
assert "Alice" in toast_text, f"Toast should mention Alice, got: {toast_text}"
# Scoreboard should have opened automatically (after 300ms delay)
page.wait_for_selector("#modalScoreboard.open", timeout=3000)
assert modal_is_open(page, "modalScoreboard"), "Scoreboard should auto-open on win"
page.click("#closeScoreboard")

Odin: a threshold ends the game, the lowest score wins

Mölkky is the easy case — the player who triggers the end is also the one who won. Odin pulls the two apart: a round pushes someone’s total past 10 and the game stops there, but that player is the loser. Victory goes to whoever sits lowest.

So a preset is not just an end condition: it also fixes the victory direction. Odin ends on total > 10 and crowns the lowest score; one button sets both.

@testcase
def test_odin_preset(page):
    """Odin preset: a total past 10 ends the game, lowest score wins."""
    clear_state(page)
    for name in ["Alice", "Bob", "Charlie"]:
        add_player(page, name)
    page.click("#btnScoreboard")
    page.wait_for_selector("#modalScoreboard.open")
    page.click("#presetOdin")
    page.click("#closeScoreboard")
    # Round 1: Alice 8, Bob 3, Charlie 5
    enter_score_via_numpad(page, 0, "8")
    click_numpad(page, "next")
    page.wait_for_selector('#entry_1.active-pad')
    enter_score_via_numpad(page, 1, "3")
    click_numpad(page, "next")
    page.wait_for_selector('#entry_2.active-pad')
    enter_score_via_numpad(page, 2, "5")
    click_numpad(page, "next")
    page.wait_for_selector("#numpad.open")
    click_numpad(page, "done")
    # Round 2: Alice +5 (=13, past 10), Bob +2 (=5), Charlie +1 (=6)
    enter_score_via_numpad(page, 0, "5")
    click_numpad(page, "next")
    page.wait_for_selector('#entry_1.active-pad')
    enter_score_via_numpad(page, 1, "2")
    click_numpad(page, "next")
    page.wait_for_selector('#entry_2.active-pad')
    enter_score_via_numpad(page, 2, "1")
    # Committing the round crosses the threshold and ends the game
    click_numpad(page, "next")
    total_cells = page.locator("#totalRow td:not(:first-child)")
    totals = [int(total_cells.nth(i).inner_text()) for i in range(total_cells.count())]
    assert totals == [13, 5, 6], f"Expected [13, 5, 6], got {totals}"
    bob_cls = total_cells.nth(1).get_attribute("class") or ""
    assert "game-won" in bob_cls, f"Bob (lowest) should be the victor, classes: {bob_cls}"
    alice_cls = total_cells.nth(0).get_attribute("class") or ""
    assert "game-won" not in alice_cls, f"Alice triggered the end but should not win, classes: {alice_cls}"
    toast = page.locator("#winToast")
    toast.wait_for(state="visible", timeout=3000)
    assert "Bob" in toast.inner_text(), f"Toast should crown Bob, got: {toast.inner_text()}"
    page.wait_for_selector("#modalScoreboard.open", timeout=3000)
    page.click("#closeScoreboard")
    print("  PASS: odin preset")

Three players, then apply the Odin preset from the scoreboard.

clear_state(page)
for name in ["Alice", "Bob", "Charlie"]:
    add_player(page, name)
page.click("#btnScoreboard")
page.wait_for_selector("#modalScoreboard.open")
page.click("#presetOdin")
page.click("#closeScoreboard")

A first harmless round, then a second that pushes Alice to 13 — past the threshold — while Bob stays lowest at 5 and Charlie at 6.

# Round 1: Alice 8, Bob 3, Charlie 5
enter_score_via_numpad(page, 0, "8")
click_numpad(page, "next")
page.wait_for_selector('#entry_1.active-pad')
enter_score_via_numpad(page, 1, "3")
click_numpad(page, "next")
page.wait_for_selector('#entry_2.active-pad')
enter_score_via_numpad(page, 2, "5")
click_numpad(page, "next")
page.wait_for_selector("#numpad.open")
click_numpad(page, "done")
# Round 2: Alice +5 (=13, past 10), Bob +2 (=5), Charlie +1 (=6)
enter_score_via_numpad(page, 0, "5")
click_numpad(page, "next")
page.wait_for_selector('#entry_1.active-pad')
enter_score_via_numpad(page, 1, "2")
click_numpad(page, "next")
page.wait_for_selector('#entry_2.active-pad')
enter_score_via_numpad(page, 2, "1")
# Committing the round crosses the threshold and ends the game
click_numpad(page, "next")

Bob, the lowest, is crowned — not Alice, who crossed the line. The toast names Bob and the scoreboard opens.

total_cells = page.locator("#totalRow td:not(:first-child)")
totals = [int(total_cells.nth(i).inner_text()) for i in range(total_cells.count())]
assert totals == [13, 5, 6], f"Expected [13, 5, 6], got {totals}"
bob_cls = total_cells.nth(1).get_attribute("class") or ""
assert "game-won" in bob_cls, f"Bob (lowest) should be the victor, classes: {bob_cls}"
alice_cls = total_cells.nth(0).get_attribute("class") or ""
assert "game-won" not in alice_cls, f"Alice triggered the end but should not win, classes: {alice_cls}"
toast = page.locator("#winToast")
toast.wait_for(state="visible", timeout=3000)
assert "Bob" in toast.inner_text(), f"Toast should crown Bob, got: {toast.inner_text()}"
page.wait_for_selector("#modalScoreboard.open", timeout=3000)
page.click("#closeScoreboard")

Naming a game after a preset sets it up

A preset’s real name is the game’s name. Someone setting up an Odin night types “Odin” into the game-name field — so that is the moment to fill the rules, before they ever reach for the preset button. Typing a name that matches a known preset applies it automatically; the match is case-insensitive so “odin” works as well as “Odin”.

@testcase
def test_preset_by_name(page):
    """Typing a preset's name in the new-game modal fills its rules."""
    clear_state(page)
    add_player(page, "Alice")
    open_new_game(page)
    page.fill("#pickGameName", "Odin")
    assert page.input_value("#pickEndCondition") == "total >= 10", \
        f"End condition not auto-filled: {page.input_value('#pickEndCondition')}"
    assert page.is_checked("#pickLowWins"), "Lowest-wins should be auto-checked for Odin"
    page.click("#pickCancel")
    print("  PASS: preset by name")

Open the new-game modal and type “Odin” as the game name.

clear_state(page)
add_player(page, "Alice")
open_new_game(page)
page.fill("#pickGameName", "Odin")

The end-condition and low-wins fields should now carry the Odin preset — no preset button touched.

assert page.input_value("#pickEndCondition") == "total >= 10", \
    f"End condition not auto-filled: {page.input_value('#pickEndCondition')}"
assert page.is_checked("#pickLowWins"), "Lowest-wins should be auto-checked for Odin"

Rules persist through game setup

Rules set in the setup modal should be saved to yMeta and visible in the scoreboard modal after the game starts.

@testcase
def test_rules_in_setup(page):
    """Rules set in new-game modal persist and appear in scoreboard."""
    clear_state(page)
    # Add a player so the header buttons appear
    add_player(page, "Alice")
    # Open new game modal via header button
    open_new_game(page)
    add_roster_player(page, "Bob")
    page.fill("#pickGameName", "Molkky")
    # Alice is already pre-selected from the current game, just select Bob too
    page.click('.pick-chip[data-pname="Bob"]')
    page.fill("#pickScoreTransform", "total > 50 ? 25 : total")
    page.fill("#pickEndCondition", "total === 50")
    page.click("#pickStart")
    page.wait_for_selector("#modalPick:not(.open)", state="attached")
    # Verify rules are in scoreboard
    page.click("#btnScoreboard")
    page.wait_for_selector("#modalScoreboard.open")
    transform_val = page.input_value("#scoreTransformInput")
    assert transform_val == "total > 50 ? 25 : total", f"Transform not saved: {transform_val}"
    end_val = page.input_value("#endConditionInput")
    assert end_val == "total === 50", f"End condition not saved: {end_val}"
    page.click("#closeScoreboard")
    print("  PASS: rules in setup")

clear_state(page)
# Add a player so the header buttons appear
add_player(page, "Alice")
# Open new game modal via header button
open_new_game(page)
add_roster_player(page, "Bob")
page.fill("#pickGameName", "Molkky")
# Alice is already pre-selected from the current game, just select Bob too
page.click('.pick-chip[data-pname="Bob"]')
page.fill("#pickScoreTransform", "total > 50 ? 25 : total")
page.fill("#pickEndCondition", "total === 50")
page.click("#pickStart")
page.wait_for_selector("#modalPick:not(.open)", state="attached")
# Verify rules are in scoreboard
page.click("#btnScoreboard")
page.wait_for_selector("#modalScoreboard.open")
transform_val = page.input_value("#scoreTransformInput")
assert transform_val == "total > 50 ? 25 : total", f"Transform not saved: {transform_val}"
end_val = page.input_value("#endConditionInput")
assert end_val == "total === 50", f"End condition not saved: {end_val}"
page.click("#closeScoreboard")

Saving and resuming

Games often span multiple sessions — you pause for dinner, continue tomorrow, or want to look up last week’s scores. The app auto-saves the current game whenever you start a new one, and lets you explicitly load any past game from an archive.

The archive opens from a folder button (&#x1f4c2;) in the header: a folder reads as “open a saved game”, which keeps it clearly apart from the trophy (&#x1f3c6;) that opens the live scoreboard — the two are different enough at a glance that neither is mistaken for the other.

Save and load

Saving snapshots the current players, rounds, and game name into yGames. Each entry gets a unique ID and timestamp. Loading a saved game removes it from the archive (it becomes the active session), so re-saving never creates a duplicate. Loading also handles backward compatibility for an older format that stored cumulative scores instead of per-round deltas.

<!-- Saved Games Modal -->
<div class="modal-overlay" id="modalGames" :class="{ 'open': $store.app.showGames }" @click.self="$store.app.showGames = false">
  <div class="modal">
    <h2>&#x1f4c2; Saved Games</h2>
    <div id="gamesList">
      <p x-show="$store.game.savedGames.length === 0" class="no-games">No saved games.</p>
      <template x-for="(g, ri) in $store.game.savedGames" :key="'sg'+ri">
        <div class="game-entry" :data-gidx="g._idx">
          <div class="game-entry-header">
            <span class="game-entry-name" x-text="g.gameName"></span>
            <span class="game-entry-date" x-text="g._dateStr"></span>
            <button class="game-entry-load" :data-gloadidx="g._idx"
              @click="loadGame(g._idx)">&#x25b6;</button>
            <button class="game-entry-delete" :data-gdelidx="g._idx"
              @click="deleteGame(g._idx)">&#x1f5d1;</button>
          </div>
          <div class="game-entry-players" x-text="g._summary"></div>
        </div>
      </template>
    </div>
    <button class="modal-close" id="closeGames" @click="$store.app.showGames = false">Close</button>
  </div>
</div>

// ── Save ──
function saveGame(){
  if(yPlayers.length === 0) return;
  var players = getPlayers();
  var rounds = committedRounds();
  var t = totals();
  var id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
  yGames.push([{
    id: id,
    date: new Date().toISOString(),
    gameName: yMeta.get('gameName') || 'Untitled',
    lowWins: !!yMeta.get('lowWins'),
    scoreTransform: yMeta.get('scoreTransform') || '',
    endCondition: yMeta.get('endCondition') || yMeta.get('winCondition') || '',
    players: players.map(function(p,i){ return {name:p.name, score:t[i]}; }),
    rounds: JSON.parse(JSON.stringify(rounds))
  }]);
}

// ── Load ──
function openLoadModal(){
  syncFromYjs();
  Alpine.store('app').showGames = true;
}

.game-entry{
  padding:14px;background:rgba(255,255,255,.05);border-radius:12px;
  margin-bottom:8px;cursor:pointer;transition:background .12s;
}
.game-entry:active{background:rgba(255,255,255,.1)}
.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}

Saving a session and loading it back

Starting a new game auto-saves the previous one. This test creates a game with a known name and score, starts a second game, then loads the first back and checks that name, players, and scores are faithfully restored.

@testcase
def test_save_and_load_game(page):
    """Save a game and load it back."""
    clear_state(page)
    add_player(page, "Alice")
    page.evaluate("document.getElementById('gameName').value = 'SaveTest';"
                  "document.getElementById('gameName').dispatchEvent(new Event('input'));")
    enter_score_via_numpad(page, 0, "42")
    click_numpad(page, "next")
    page.wait_for_selector("#numpad.open")
    click_numpad(page, "done")
    open_new_game(page)
    page.fill("#pickGameName", "NewGame")
    page.click("#pickStart")
    page.wait_for_selector("#modalPick", state="hidden")
    page.click("#btnLoad")
    page.wait_for_selector("#modalGames.open")
    entries = page.locator(".game-entry")
    assert entries.count() >= 1, "Expected at least one saved game"
    assert "SaveTest" in entries.first.inner_text()
    page.on("dialog", lambda d: d.accept())
    entries.first.locator(".game-entry-load").click()
    page.wait_for_selector("#modalGames", state="hidden")
    assert page.input_value("#gameName") == "SaveTest"
    t = get_totals(page)
    assert t == [42], f"Expected [42], got {t}"
    print("  PASS: save and load game")

Create a game named “SaveTest” with Alice scoring 42.

clear_state(page)
add_player(page, "Alice")
page.evaluate("document.getElementById('gameName').value = 'SaveTest';"
              "document.getElementById('gameName').dispatchEvent(new Event('input'));")
enter_score_via_numpad(page, 0, "42")
click_numpad(page, "next")
page.wait_for_selector("#numpad.open")
click_numpad(page, "done")

Start a new game — this auto-saves the current one.

open_new_game(page)
page.fill("#pickGameName", "NewGame")
page.click("#pickStart")
page.wait_for_selector("#modalPick", state="hidden")

Load the saved game and verify the name and scores are restored.

page.click("#btnLoad")
page.wait_for_selector("#modalGames.open")
entries = page.locator(".game-entry")
assert entries.count() >= 1, "Expected at least one saved game"
assert "SaveTest" in entries.first.inner_text()
page.on("dialog", lambda d: d.accept())
entries.first.locator(".game-entry-load").click()
page.wait_for_selector("#modalGames", state="hidden")
assert page.input_value("#gameName") == "SaveTest"
t = get_totals(page)
assert t == [42], f"Expected [42], got {t}"

Starting a new game

The “New Game” button auto-saves the current game (if any players exist), then opens the player pick modal for a fresh start.

// ── New Game ──
function newGame(){
  if(yPlayers.length > 0){
    saveGame();
  }
  openPickModal();
}

The old game is auto-saved when you start a new one

Users should never lose data by starting a new game. This test creates a game with scores, starts a fresh game, then opens the saved games list and verifies the old game appears there.

@testcase
def test_new_game_saves(page):
    """Starting a new game auto-saves the current one."""
    clear_state(page)
    add_player(page, "Alice")
    page.fill("#gameName", "OldGame")
    page.locator("#gameName").dispatch_event("input")
    enter_score_via_numpad(page, 0, "42")
    click_numpad(page, "next")
    page.wait_for_selector("#numpad.open")
    click_numpad(page, "done")
    open_new_game(page)
    page.fill("#pickGameName", "NewGame")
    page.click("#pickStart")
    page.wait_for_selector("#modalPick", state="hidden")
    page.click("#btnLoad")
    page.wait_for_selector("#modalGames.open")
    entries = page.locator(".game-entry")
    assert entries.count() >= 1, "Expected at least one saved game"
    assert "OldGame" in entries.first.inner_text(), "Saved game should contain OldGame"
    page.click("#closeGames")
    print("  PASS: starting a new game saves the old one")

Create a game named “OldGame” with a score for Alice.

clear_state(page)
add_player(page, "Alice")
page.fill("#gameName", "OldGame")
page.locator("#gameName").dispatch_event("input")
enter_score_via_numpad(page, 0, "42")
click_numpad(page, "next")
page.wait_for_selector("#numpad.open")
click_numpad(page, "done")

Start a fresh game — the old one should be auto-saved.

open_new_game(page)
page.fill("#pickGameName", "NewGame")
page.click("#pickStart")
page.wait_for_selector("#modalPick", state="hidden")

Open the saved games list and verify “OldGame” appears.

page.click("#btnLoad")
page.wait_for_selector("#modalGames.open")
entries = page.locator(".game-entry")
assert entries.count() >= 1, "Expected at least one saved game"
assert "OldGame" in entries.first.inner_text(), "Saved game should contain OldGame"
page.click("#closeGames")

Resuming a saved game does not duplicate it

A common flow is to load an old game, play a few more rounds, then move on. The app should update the existing archive entry rather than creating a second copy. This test saves a game, loads it back, adds a round, starts a new game (which auto-saves), and checks that only one entry with that name exists in the list.

@testcase
def test_resume_no_duplicate(page):
    """Loading a saved game and re-saving does not create a duplicate."""
    clear_state(page)
    add_player(page, "Alice")
    page.fill("#gameName", "ResumeMe")
    page.locator("#gameName").dispatch_event("input")
    enter_score_via_numpad(page, 0, "10")
    click_numpad(page, "next")
    page.wait_for_selector("#numpad.open")
    click_numpad(page, "done")
    open_new_game(page)
    page.fill("#pickGameName", "Temp")
    page.click("#pickStart")
    page.wait_for_selector("#modalPick", state="hidden")
    page.click("#btnLoad")
    page.wait_for_selector("#modalGames.open")
    page.on("dialog", lambda d: d.accept())
    entries = page.locator(".game-entry")
    entries.first.locator(".game-entry-load").click()
    page.wait_for_selector("#modalGames", state="hidden")
    assert page.input_value("#gameName") == "ResumeMe"
    enter_score_via_numpad(page, 0, "5")
    click_numpad(page, "next")
    page.wait_for_selector("#numpad.open")
    click_numpad(page, "done")
    open_new_game(page)
    page.fill("#pickGameName", "Final")
    page.click("#pickStart")
    page.wait_for_selector("#modalPick", state="hidden")
    page.click("#btnLoad")
    page.wait_for_selector("#modalGames.open")
    all_entries = page.locator(".game-entry")
    count = 0
    for i in range(all_entries.count()):
        if "ResumeMe" in all_entries.nth(i).inner_text():
            count += 1
    assert count == 1, f"Expected 1 'ResumeMe' entry, found {count}"
    page.click("#closeGames")
    print("  PASS: resuming a saved game does not duplicate it")

Create a game named “ResumeMe”, enter a score, then start a new game so “ResumeMe” lands in the archive.

clear_state(page)
add_player(page, "Alice")
page.fill("#gameName", "ResumeMe")
page.locator("#gameName").dispatch_event("input")
enter_score_via_numpad(page, 0, "10")
click_numpad(page, "next")
page.wait_for_selector("#numpad.open")
click_numpad(page, "done")
open_new_game(page)
page.fill("#pickGameName", "Temp")
page.click("#pickStart")
page.wait_for_selector("#modalPick", state="hidden")

Load “ResumeMe” back from the archive.

page.click("#btnLoad")
page.wait_for_selector("#modalGames.open")
page.on("dialog", lambda d: d.accept())
entries = page.locator(".game-entry")
entries.first.locator(".game-entry-load").click()
page.wait_for_selector("#modalGames", state="hidden")
assert page.input_value("#gameName") == "ResumeMe"

Add another round so the game has changed since loading.

enter_score_via_numpad(page, 0, "5")
click_numpad(page, "next")
page.wait_for_selector("#numpad.open")
click_numpad(page, "done")

Start yet another game (auto-saving “ResumeMe”) and verify that the archive contains exactly one entry named “ResumeMe”.

open_new_game(page)
page.fill("#pickGameName", "Final")
page.click("#pickStart")
page.wait_for_selector("#modalPick", state="hidden")
page.click("#btnLoad")
page.wait_for_selector("#modalGames.open")
all_entries = page.locator(".game-entry")
count = 0
for i in range(all_entries.count()):
    if "ResumeMe" in all_entries.nth(i).inner_text():
        count += 1
assert count == 1, f"Expected 1 'ResumeMe' entry, found {count}"
page.click("#closeGames")

Starting a new game resets the scores

When users start a fresh game, the slate must be clean — no leftover totals from the previous session. This test creates a game, enters a score, then starts a new game and verifies every total is zero.

@testcase
def test_new_game_resets_scores(page):
    """Starting a new game zeroes out all scores."""
    clear_state(page)
    add_player(page, "Alice")
    enter_score_via_numpad(page, 0, "42")
    click_numpad(page, "done")
    t = get_totals(page)
    assert t == [42], f"Expected [42] before new game, got {t}"
    open_new_game(page)
    page.fill("#pickGameName", "Fresh")
    page.click("#pickStart")
    page.wait_for_selector("#modalPick", state="hidden")
    t = get_totals(page)
    assert all(v == 0 for v in t), f"Expected all-zero totals after new game, got {t}"
    val = page.locator("#entry_0").input_value()
    assert val in ("", "0"), f"Entry not reset: got '{val}'"
    print("  PASS: new game resets scores")

Create a game with Alice, enter a score in the entry row (without committing the round) so the live total is non-zero.

clear_state(page)
add_player(page, "Alice")
enter_score_via_numpad(page, 0, "42")
click_numpad(page, "done")
t = get_totals(page)
assert t == [42], f"Expected [42] before new game, got {t}"

Start a fresh game with the same player.

open_new_game(page)
page.fill("#pickGameName", "Fresh")
page.click("#pickStart")
page.wait_for_selector("#modalPick", state="hidden")

Every total and entry input should now be zero.

t = get_totals(page)
assert all(v == 0 for v in t), f"Expected all-zero totals after new game, got {t}"
val = page.locator("#entry_0").input_value()
assert val in ("", "0"), f"Entry not reset: got '{val}'"

Exporting to CSV

For people who want their data outside the app — in a spreadsheet, for a league tracker, or just for the record. Generates a CSV with player names as headers, one row per round, and a TOTAL row.

// ── Export CSV ──
function exportCSV(){
  if(yPlayers.length === 0){ alert('Nothing to export.'); return; }
  var players = getPlayers();
  var rounds = committedRounds();
  var t = totals();
  var lines = [];
  lines.push(['Round'].concat(players.map(function(p){ return '"'+p.name.replace(/"/g,'""')+'"'; })).join(','));
  rounds.forEach(function(r, ri){
    lines.push([ri+1].concat(r).join(','));
  });
  lines.push(['TOTAL'].concat(t).join(','));
  var blob = new Blob([lines.join('\n')], {type:'text/csv;charset=utf-8;'});
  var a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = (yMeta.get('gameName') || 'scores') + '.csv';
  a.click();
  URL.revokeObjectURL(a.href);
};

Exporting produces a well-formed CSV file

The export button triggers a CSV download containing all rounds and totals. This test intercepts the download, parses the content, and checks that the header row, score rows, and total row match the game state.

@testcase
def test_export_csv(page):
    """Export CSV produces correct content."""
    clear_state(page)
    add_player(page, "Alice")
    add_player(page, "Bob")
    page.fill("#gameName", "TestCSV")
    page.locator("#gameName").dispatch_event("input")
    enter_score_via_numpad(page, 0, "10")
    click_numpad(page, "next")
    page.wait_for_selector('#entry_1.active-pad')
    for d in "20":
        page.click(f'.np-btn[data-key="{d}"]')
    click_numpad(page, "next")
    page.wait_for_selector("#numpad.open")
    click_numpad(page, "done")
    with page.expect_download() as dl_info:
        page.click("#btnExport")
    dl = dl_info.value
    assert "TestCSV" in dl.suggested_filename, f"Filename should contain TestCSV, got {dl.suggested_filename}"
    content = dl.path().read_text()
    lines = content.strip().split("\n")
    assert len(lines) == 3, f"Expected 3 lines (header, round, total), got {len(lines)}"
    assert "Alice" in lines[0] and "Bob" in lines[0], f"Header should have player names: {lines[0]}"
    assert lines[1].startswith("1,"), f"Round row should start with 1: {lines[1]}"
    assert "TOTAL" in lines[2], f"Last row should be TOTAL: {lines[2]}"
    print("  PASS: export CSV")

Create a game named “TestCSV” with Alice (10) and Bob (20).

clear_state(page)
add_player(page, "Alice")
add_player(page, "Bob")
page.fill("#gameName", "TestCSV")
page.locator("#gameName").dispatch_event("input")
enter_score_via_numpad(page, 0, "10")
click_numpad(page, "next")
page.wait_for_selector('#entry_1.active-pad')
for d in "20":
    page.click(f'.np-btn[data-key="{d}"]')
click_numpad(page, "next")
page.wait_for_selector("#numpad.open")
click_numpad(page, "done")

Intercept the download triggered by the export button.

with page.expect_download() as dl_info:
    page.click("#btnExport")
dl = dl_info.value
assert "TestCSV" in dl.suggested_filename, f"Filename should contain TestCSV, got {dl.suggested_filename}"
content = dl.path().read_text()
lines = content.strip().split("\n")

The CSV should have a header row with player names, one round row, and a TOTAL row.

assert len(lines) == 3, f"Expected 3 lines (header, round, total), got {len(lines)}"
assert "Alice" in lines[0] and "Bob" in lines[0], f"Header should have player names: {lines[0]}"
assert lines[1].startswith("1,"), f"Round row should start with 1: {lines[1]}"
assert "TOTAL" in lines[2], f"Last row should be TOTAL: {lines[2]}"

Playing together

The app uses Yjs CRDTs to sync state across devices over WebSocket. Authorization is handled via a magic link: the admin generates a link with clk authorizationserver generate, the user clicks it, and the server sets a cookie and redirects to the app. From then on all changes propagate in real time. The sync dot in the header turns green when connected.

Pour generer un code d’autorisation :

clk authorizationserver generate --redirect 'https://ipfs.konubinix.eu/p/<cid>?websocket_url=wss://konubinix.eu/ywebsocket' ywebsocket/tally sam-phone

Sync initialization

The shared setupSync helper from PWA score apps — shared blocks wires up WebSocket connection, remote-change rendering, and initial render. The sync dot color is driven by the data-ws body attribute set by that shared block.

// ── Init ──
setupSync(persistence, ydoc, render);

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).

Plumbing

Everything below is infrastructure that supports the user-facing features above: the data layer, event wiring, visual foundation, and PWA lifecycle.

Data layer

The Yjs document (ydoc) is the single source of truth, shared with the card-based Tally app (tally_score_tracker.org). Both apps read and write the same shared types:

  • currentPlayers (Y.Array of Y.Maps): each Y.Map has name
  • rounds (Y.Array of arrays): each inner array holds one delta per player. The trailing round is the draft the players are filling in right now — its untouched cells hold ' and read as zero — and every round before it is committed history. (The card app keeps score its own way and ignores rounds.)
  • savedGames (Y.Array): archive of finished games
  • meta (Y.Map): game-level metadata (currently just gameName)
  • sc_roster (Y.Array of plain objects): persistent player roster (score counter only)

Because the draft lives in the shared document like any other round, every device watching the room sees the same half-filled row take shape, not just the committed history. Totals are derived by summing rounds per player column — committed rounds plus the live draft. No score or history fields on players — concurrent round appends merge correctly via Y.Array.

IndexeddbPersistence keeps a local copy in the browser’s IndexedDB so the app works fully offline.

The room name defaults to tally but can be overridden via the room query parameter (e.g. ?room=debug). This is handy for debugging: opening the app at https://example.com/?room=debug creates an isolated session that won’t interfere with a real game in progress.

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

// ── Yjs setup (shared with tally_score_tracker) ──
var _roomName = new URLSearchParams(location.search).get('room') || 'tally';
var ydoc = new Y.Doc();
var persistence = new IndexeddbPersistence(_roomName, ydoc);
var yPlayers = ydoc.getArray('currentPlayers'); // Y.Map {name}
var yRounds  = ydoc.getArray('rounds');         // [[d0, d1, ...], ...]
var yGames   = ydoc.getArray('savedGames');     // [{id,date,gameName,players,rounds}]
var yRoster  = ydoc.getArray('sc_roster');      // [{name:'...'}] persistent player roster
var yMeta    = ydoc.getMap('meta');              // gameName
var COLORS   = ['#e94560','#4ecca3','#533483','#f9a826','#3fc1c9','#fc5185','#8dc6ff','#6c5ce7'];
var MAX_PLAYERS = 8;

window.ydoc = ydoc;
window.Y = Y;

// ── DOM refs ──
var $ = function(id){ return document.getElementById(id); };

// ── Helpers ──
function esc(s){ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/"/g,'&quot;'); }

function getPlayers(){
  var a = [];
  for(var i=0;i<yPlayers.length;i++) a.push({name: yPlayers.get(i).get('name')});
  return a;
}
function getRounds(){
  var a = [];
  for(var i=0;i<yRounds.length;i++) a.push(yRounds.get(i));
  return a;
}

// ── The in-progress round ──
// The trailing round of yRounds is always the draft the players are
// filling in; everything before it is committed history.  An untouched
// cell holds '' (shown as a placeholder, counted as 0).
function emptyRow(n){ var a = []; for(var i=0;i<n;i++) a.push(''); return a; }
function committedRounds(){
  var a = getRounds();
  return a.length ? a.slice(0, a.length - 1) : [];
}
function draftRow(){
  var a = getRounds();
  return a.length ? a[a.length - 1].slice() : [];
}
function ensureDraft(){
  var n = yPlayers.length;
  if(n === 0) return;
  if(yRounds.length === 0){ yRounds.push([emptyRow(n)]); return; }
  var last = yRounds.get(yRounds.length - 1).slice();
  if(last.length !== n){
    while(last.length < n) last.push('');
    last.length = n;
    yRounds.delete(yRounds.length - 1, 1);
    yRounds.push([last]);
  }
}
function flushDraft(){
  if(yPlayers.length === 0) return;
  var row = Alpine.store('game').draft.slice();
  if(yRounds.length === 0){ yRounds.push([row]); return; }
  yRounds.delete(yRounds.length - 1, 1);
  yRounds.push([row]);
}
function getSavedGames(){
  var a = [];
  for(var i=0;i<yGames.length;i++){
    var v = yGames.get(i);
    a.push(v && typeof v.toJSON === 'function' ? v.toJSON() : v);
  }
  return a;
}

// ── Custom rule caches ──
var _cachedTransformFn = null;
var _cachedTransformExpr = '';
var _cachedEndFn = null;
var _cachedEndExpr = '';

function getTransformFn(expr){
  if(!expr) return null;
  if(expr === _cachedTransformExpr && _cachedTransformFn) return _cachedTransformFn;
  try {
    _cachedTransformFn = new Function('total', 'playerIndex', 'round', 'return ' + expr);
    _cachedTransformExpr = expr;
    return _cachedTransformFn;
  } catch(e){ return null; }
}

function getEndFn(expr){
  if(!expr) return null;
  if(expr === _cachedEndExpr && _cachedEndFn) return _cachedEndFn;
  try {
    _cachedEndFn = new Function('total', 'playerIndex', 'round', 'return ' + expr);
    _cachedEndExpr = expr;
    return _cachedEndFn;
  } catch(e){ return null; }
}

function totals(){
  var players = getPlayers();
  var rounds = committedRounds();
  var tfn = getTransformFn(yMeta.get('scoreTransform') || '');
  var t = players.map(function(){ return 0; });
  rounds.forEach(function(r, ri){
    r.forEach(function(v,i){ t[i] += (v||0); });
    if(tfn){
      for(var i = 0; i < t.length; i++){
        try { t[i] = tfn(t[i], i, ri); } catch(e){}
      }
    }
  });
  return t;
}

// ── Per-column widths from distinguishing prefixes (diagonal) ──
var _cosAngle = Math.cos(55 * Math.PI / 180);
var _chPx = null;
function chToPx(){
  if(_chPx) return _chPx;
  var el = document.createElement('span');
  el.style.cssText = 'font:700 .8rem monospace;position:absolute;visibility:hidden';
  el.textContent = '0';
  document.body.appendChild(el);
  _chPx = el.offsetWidth;
  document.body.removeChild(el);
  return _chPx;
}
function colWidths(names){
  if(names.length === 0) return [];
  // For each name, find the shortest prefix that distinguishes it
  var lens = names.map(function(n, i){
    var need = 1;
    for(var j = 0; j < names.length; j++){
      if(j === i) continue;
      var shared = 0;
      var a = n.toLowerCase(), b = names[j].toLowerCase();
      while(shared < a.length && shared < b.length && a[shared] === b[shared]) shared++;
      if(shared + 1 > need) need = shared + 1;
    }
    return Math.min(need, n.length);
  });
  var ch = chToPx();
  return names.map(function(n, i){
    return Math.max(20, Math.ceil(lens[i] * ch * _cosAngle));
  });
}

Loading and deleting saved games

Loading a saved game restores players and rounds from the snapshot and removes the entry from the archive — the game is now the active session, not a saved one. When you later save or start a new game, a fresh entry is created, so there is never a stale duplicate. Deleting a game removes it from the archive and refreshes the list.

function loadGame(idx){
  var g = getSavedGames()[idx];
  if(!g) return;
  var hasScores = committedRounds().length > 0;
  if(hasScores){
    if(!confirm('Save current game before loading?')) return;
    saveGame();
  }
  yGames.delete(idx, 1);
  yPlayers.delete(0, yPlayers.length);
  yRounds.delete(0, yRounds.length);
  (g.players||[]).forEach(function(p){
    var pm = new Y.Map();
    pm.set('name', p.name);
    yPlayers.push([pm]);
  });
  // Backward compat: convert old tally format (finalScore/history) to rounds
  if(g.rounds){
    g.rounds.forEach(function(r){ yRounds.push([r]); });
  } else if(g.players && g.players[0] && g.players[0].history){
    var histories = g.players.map(function(p){
      var h = typeof p.history === 'string' ? JSON.parse(p.history) : (p.history || []);
      return h;
    });
    var maxLen = Math.max.apply(null, histories.map(function(h){ return h.length; }));
    for(var ri=0;ri<maxLen;ri++){
      var row = histories.map(function(h){
        var cur = ri < h.length ? h[ri] : (h.length ? h[h.length-1] : 0);
        var prev = ri > 0 && ri-1 < h.length ? h[ri-1] : 0;
        return cur - prev;
      });
      yRounds.push([row]);
    }
  }
  yMeta.set('gameName', g.gameName || '');
  yMeta.set('lowWins', !!g.lowWins);
  yMeta.set('scoreTransform', g.scoreTransform || '');
  yMeta.set('endCondition', g.endCondition || g.winCondition || '');
  _cachedTransformFn = null;
  _cachedEndFn = null;
  ensureDraft();
  Alpine.store('app').showGames = false;
  render();
}

function deleteGame(idx){
  if(confirm('Delete this saved game?')){
    yGames.delete(idx, 1);
    openLoadModal();
  }
}

Keeping the screen awake

Board game sessions can last hours. The Screen Wake Lock API keeps the display on so you don’t have to unlock your phone every time you want to record a score. The lock is re-requested when the tab regains visibility (the browser releases it when backgrounded).

// ── Wake Lock ──
var wakeLock = null;
async function requestWakeLock(){
  try{ if('wakeLock' in navigator) wakeLock = await navigator.wakeLock.request('screen'); }catch(e){}
}
document.addEventListener('visibilitychange', function(){
  if(document.visibilityState === 'visible') requestWakeLock();
});
requestWakeLock();

Installing as a PWA

The install button appears in the header when the browser signals the app is installable (beforeinstallprompt event). It’s hidden when already running in standalone mode. The early capture script runs in <head> so the event is never missed.

<script>
// Capture beforeinstallprompt early so it is not missed.
window.__pwaInstallEvent = null;
window.addEventListener('beforeinstallprompt', function(e) {
  e.preventDefault();
  window.__pwaInstallEvent = e;
  var btn = document.getElementById('btnInstall');
  if (btn) btn.style.display = '';
});
window.addEventListener('appinstalled', function() {
  window.__pwaInstallEvent = null;
  var btn = document.getElementById('btnInstall');
  if (btn) btn.style.display = 'none';
});
</script>

// ── PWA Install (late wiring) ──
if (window.__pwaInstallEvent) {
  $('btnInstall').style.display = '';
}
function installApp() {
  if (!window.__pwaInstallEvent) return;
  window.__pwaInstallEvent.prompt();
  window.__pwaInstallEvent.userChoice.then(function() {
    window.__pwaInstallEvent = null;
    $('btnInstall').style.display = 'none';
  });
}
if (window.matchMedia('(display-mode: standalone)').matches) {
  $('btnInstall').style.display = 'none';
}

Window API

Exposes render as a window global so the Playwright test suite can call it via page.evaluate().

// ── Window compat wrappers ──
window.renderFromYjs = render;

Alpine store

Alpine manages UI state — which modal is open, numpad visibility — while Yjs remains the source of truth for game data. The store exposes the functions defined in the feature blocks above so that @click directives in the HTML can call them.

// ── Alpine store ──
Alpine.store('app', {
  // Modal visibility
  showPick: false,
  showAddPlayer: false,
  showScoreboard: false,
  showGames: false,
  showNumpad: false,
  pickSelected: {},
  showReorder: false,
  reorderList: [],
  pickOrder: [],
});

Alpine.store('game', {
  players: [],
  rounds: [],
  draft: [],
  hasPlayers: false,
  gameName: '',
  lowWins: false,
  scoreTransform: '',
  endCondition: '',
  roster: [],
  savedGames: [],
  colWidths: [],
  get liveTotals(){
    var self = this;
    var tfn = getTransformFn(self.scoreTransform);
    var t = self.players.map(function(){ return 0; });
    self.rounds.forEach(function(r, ri){
      r.forEach(function(v, i){ if(i < t.length) t[i] += (v || 0); });
      if(tfn){
        for(var i = 0; i < t.length; i++){
          try { t[i] = tfn(t[i], i, ri); } catch(e){}
        }
      }
    });
    self.draft.forEach(function(v, i){
      if(i < t.length) t[i] += (v || 0);
    });
    if(tfn){
      for(var i = 0; i < t.length; i++){
        try { t[i] = tfn(t[i], i, self.rounds.length); } catch(e){}
      }
    }
    return t;
  },
  get liveMaxTotal(){
    var t = this.liveTotals;
    if(!t.length) return 0;
    return this.lowWins ? Math.min.apply(null, t) : Math.max.apply(null, t);
  },
  get endConditionMet(){
    var self = this;
    var efn = getEndFn(self.endCondition);
    if(!efn) return false;
    var t = self.liveTotals;
    for(var i = 0; i < t.length; i++){
      try { if(efn(t[i], i, self.rounds.length)) return true; } catch(e){}
    }
    return false;
  },
  get liveWinners(){
    var self = this;
    if(!self.endConditionMet) return {};
    // Who triggered the end is irrelevant — the victors are whoever ranks
    // first in the active direction (lowest or highest).
    var t = self.liveTotals;
    var best = self.liveMaxTotal;
    var winners = {};
    for(var i = 0; i < t.length; i++){ if(t[i] === best) winners[i] = true; }
    return winners;
  },
  get scoreboardEntries(){
    var self = this;
    var entries = self.players.map(function(p, i){ return {name: p.name, score: self.liveTotals[i]||0}; });
    var lw = self.lowWins;
    entries.sort(function(a, b){ return lw ? a.score - b.score : b.score - a.score; });
    var rank = 0;
    for(var i = 0; i < entries.length; i++){
      if(i > 0 && entries[i].score !== entries[i-1].score) rank++;
      entries[i].rank = rank;
    }
    return entries;
  },
  get availablePlayers(){
    var current = {};
    this.players.forEach(function(p){ current[p.name] = true; });
    return this.roster.filter(function(p){ return !current[p.name]; });
  },
});

function syncFromYjs(){
  var g = Alpine.store('game');
  var players = getPlayers();
  g.players = players.map(function(p, i){
    return { name: p.name, color: COLORS[i % 8] };
  });
  g.colWidths = colWidths(players.map(function(p){ return p.name; }));
  g.rounds = committedRounds();
  g.hasPlayers = players.length > 0;
  g.gameName = yMeta.get('gameName') || '';
  g.lowWins = !!yMeta.get('lowWins');
  g.scoreTransform = yMeta.get('scoreTransform') || '';
  // 'winCondition' is the key older saves used before end and victory were split.
  g.endCondition = yMeta.get('endCondition') || yMeta.get('winCondition') || '';
  if(document.activeElement !== $('gameName')) $('gameName').value = g.gameName;
  // The draft is the trailing round; resize it to the player count.
  g.draft = draftRow();
  while(g.draft.length < players.length) g.draft.push('');
  while(g.draft.length > players.length) g.draft.pop();
  g.roster = getRoster().map(function(r){ return { name: r.get ? r.get('name') : r.name }; });
  var games = getSavedGames();
  g.savedGames = games.slice().reverse().map(function(gm, ri){
    var idx = games.length - 1 - ri;
    var d = new Date(gm.date);
    var dateStr = d.toLocaleDateString() + ' ' + d.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'});
    var pStr = (gm.players||[]).map(function(p){ return esc(p.name)+': '+(p.score!==undefined?p.score:(p.finalScore||0)); }).join(', ');
    var nRounds = (gm.rounds||[]).length;
    return { gameName: gm.gameName||'Untitled', _dateStr: dateStr, _summary: pStr+' ('+nRounds+' rounds)', _idx: idx, _raw: gm };
  });
}
var render = syncFromYjs;

// Expose functions to Alpine's scope so @click directives work
window.openPickModal = openPickModal;
window.openAddPlayerModal = openAddPlayerModal;
window.addToRoster = addToRoster;
window.deleteFromRoster = deleteFromRoster;
window.togglePick = togglePick;
window.startGame = startGame;
window.updatePickStartEnabled = updatePickStartEnabled;
window.onPickGameNameInput = onPickGameNameInput;
window.addPlayerFromInput = addPlayerFromInput;
window.addPlayerFromChip = addPlayerFromChip;
window.openScoreboard = openScoreboard;
window.toggleLowWins = toggleLowWins;
window.setScoreTransform = setScoreTransform;
window.setEndCondition = setEndCondition;
window.showWinToast = showWinToast;
window.applyPreset = applyPreset;
window.applyPickPreset = applyPickPreset;
window.openLoadModal = openLoadModal;
window.newGame = newGame;
window.exportCSV = exportCSV;
window.deleteEditRound = deleteEditRound;
window.installApp = installApp;
window.setGameName = function(v){ yMeta.set('gameName', v); };
window.updatePlayerName = function(i, v){ yPlayers.get(i).set('name', v); };
window.removePlayer = removePlayer;
window.openEdit = openEdit;
window.openNumpad = openNumpad;
window.addRound = addRound;
window.loadGame = loadGame;
window.deleteGame = deleteGame;
window.openReorderModal = openReorderModal;
window.confirmReorder = confirmReorder;
window.syncFromYjs = syncFromYjs;

Alpine.start();

Visual foundation

Dark theme with CSS custom properties. The body is a flex column: sticky header, scrollable table area, and the numpad fixed at the bottom when open.

*{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:12px;
}
html,body{height:100%}
body{
  font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;
  background:var(--bg);color:var(--text);
  -webkit-tap-highlight-color:transparent;
  display:flex;flex-direction:column;
}

Shared modal overlay and content styling. The popIn animation gives a subtle scale entrance.

/* ── Modals ── */
.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-row{
  display:flex;justify-content:space-between;align-items:center;
  padding:10px 14px;background:rgba(255,255,255,.05);border-radius:12px;
  margin-bottom:6px;
}
.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;
}
.modal-close:disabled{opacity:.35;cursor:not-allowed}
.pick-chips{display:flex;flex-wrap:wrap;gap:8px}
.pick-chip{
  padding:10px 18px;border-radius:20px;
  background:rgba(255,255,255,.08);border:1.5px solid rgba(255,255,255,.15);
  color:var(--text);font-size:.9rem;font-weight:600;cursor:pointer;
  font-family:inherit;transition:all .15s;
}
.pick-chip.selected{
  background:var(--accent);border-color:var(--accent);color:#fff;
}
.pick-chip-wrap{position:relative;display:inline-block}
.pick-chip-del{
  position:absolute;top:-6px;right:-6px;
  width:18px;height:18px;border-radius:50%;
  background:var(--red,#e94560);border:none;color:#fff;
  font-size:.65rem;line-height:18px;text-align:center;
  cursor:pointer;padding:0;opacity:.7;transition:opacity .15s;
}
.pick-chip-del:hover{opacity:1}

Persistence across reload

The debug stub persists to localStorage. A page reload must restore players and scores exactly as they were — this is the closest thing to a crash-recovery test we can do in the browser.

Verifying that scores survive a page reload

After adding a player and entering a round, a full page reload should bring back the same players and totals. This exercises the localStorage round-trip of the debug Yjs stub.

@testcase
def test_persistence_across_reload(page):
    """Data survives a page reload."""
    clear_state(page)
    add_player(page, "Alice")
    enter_score_via_numpad(page, 0, "33")
    click_numpad(page, "next")
    page.wait_for_selector("#numpad.open")
    click_numpad(page, "done")
    page.reload()
    page.wait_for_selector("#btnAddPlayer")
    names = get_player_names(page)
    assert "Alice" in names, f"Alice not found after reload: {names}"
    t = get_totals(page)
    assert t == [33], f"Expected [33] after reload, got {t}"
    print("  PASS: persistence across reload")

Add Alice and enter a round of 33 points.

clear_state(page)
add_player(page, "Alice")
enter_score_via_numpad(page, 0, "33")
click_numpad(page, "next")
page.wait_for_selector("#numpad.open")
click_numpad(page, "done")

Force a full page reload.

page.reload()
page.wait_for_selector("#btnAddPlayer")

Alice and her score should still be there.

names = get_player_names(page)
assert "Alice" in names, f"Alice not found after reload: {names}"
t = get_totals(page)
assert t == [33], f"Expected [33] after reload, got {t}"

Starting a new game after a reload

The roster is persisted as structured objects. After a page reload the stub deserialises them — the pick modal must still be able to read player names and start a new game. This catches regressions where the deserialised format breaks .name access.

Verifying that a new game can start after reload

Set up a game with two players, reload, then start a fresh game from the pick modal. The modal should close and the new game should have both players.

@testcase
def test_new_game_after_reload(page):
    """Starting a new game works after a page reload."""
    clear_state(page)
    add_player(page, "Alice")
    add_player(page, "Bob")
    page.reload()
    page.wait_for_selector("#btnAddPlayer")
    open_new_game(page)
    page.fill("#pickGameName", "AfterReload")
    page.click("#pickStart")
    page.wait_for_selector("#modalPick", state="hidden", timeout=3000)
    assert page.input_value("#gameName") == "AfterReload"
    names = get_player_names(page)
    assert names == ["Alice", "Bob"], f"Expected [Alice, Bob], got {names}"
    print("  PASS: new game after reload")

Add two players and start a game so the roster is populated in storage.

clear_state(page)
add_player(page, "Alice")
add_player(page, "Bob")

Reload the page so the stub deserialises the roster from localStorage, then open the pick modal, name the game, and start.

page.reload()
page.wait_for_selector("#btnAddPlayer")
open_new_game(page)
page.fill("#pickGameName", "AfterReload")
page.click("#pickStart")
page.wait_for_selector("#modalPick", state="hidden", timeout=3000)

The game should have started with both players.

assert page.input_value("#gameName") == "AfterReload"
names = get_player_names(page)
assert names == ["Alice", "Bob"], f"Expected [Alice, Bob], got {names}"

Game name persists

The game name is stored in yMeta. This is tested separately from the score data because it follows a different code path (the #gameName input dispatches its own input event into the Yjs map).

Checking that the game name survives a reload

Setting the game name via the header input and reloading the page must restore the same value, confirming that the yMeta map is persisted and read back on startup.

@testcase
def test_game_name_persists(page):
    """Game name field persists across reload."""
    clear_state(page)
    add_player(page, "Alice")
    page.evaluate("document.getElementById('gameName').value = 'MyGame';"
                  "document.getElementById('gameName').dispatchEvent(new Event('input'));")
    page.reload()
    page.wait_for_selector("#btnAddPlayer")
    assert page.input_value("#gameName") == "MyGame"
    print("  PASS: game name persists")

Set the game name programmatically and fire the input event so Yjs picks it up.

page.evaluate("document.getElementById('gameName').value = 'MyGame';"
              "document.getElementById('gameName').dispatchEvent(new Event('input'));")

After a reload the field should still read “MyGame”.

page.reload()
page.wait_for_selector("#btnAddPlayer")
assert page.input_value("#gameName") == "MyGame"

Notes linking here