Compare commits

...
Author SHA1 Message Date
jheapsandClaude Opus 5 95fd46ee99 Take the broken wordmark off the felt
The felt carried "TEXAS HOLD'EM" across its middle, and the pot badge sat on top of
it. Measured at every seat count from two to ten, the middle 20% of the word was
covered, so the table read

  TEXAS  LD'EM

That is not a watermark, it is a rendering fault, and it looked like one.

I tried to move it before removing it. Four placements, measured across six viewports
and two seat counts each, as the share of the word covered by a seat, a bet chip or
the pot badge:

                        desktop   tablet   phone     phone     landscape
                        1280x900  768x1024 390x844   360x640   844x390
  centred (before)        20%      20%      20%       20%        20%
  30% down, full size      0-7%     0-12%   43-60%    48-70%     20-23%
  20% down, smaller        0%       0%      40-67%    53-70%     40%
  78% down, smaller        0%       0%      77-97%    90-100%*   27-37%

  * and off the oval entirely at 360px

Desktop and tablet can be made clean. A phone cannot: below 900px the felt is square,
and a square felt with six to nine seats has no clear horizontal band wide enough for
a word. The best phone result of any placement was 40% covered.

So it is gone rather than special-cased per device for a decoration. The felt still has
its gradient, its rim, the inner gold line and its shadow to be a table with, and the
page header says "Texas Hold'em Dealer" a few inches above it.

The reasoning and the numbers are left in the CSS where the element used to be, so
whoever thinks of adding one back finds out what happened first.

269 tests still passing. Seven viewports: contrast clean, layout audit clean everywhere
but the landscape phone already known from #63, no console errors.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 18:54:52 -06:00
jheapsandClaude Opus 5 4cc8faf8cb Notice when the same table is open twice
Opening the tool in a second tab -- tapping the bookmark again rather than hunting for
the tab you already had -- silently throws away whatever the first one is doing.

Each tab holds its own copy in memory and each save writes the whole table, so the
second one to act wins. Driven with two tabs on one profile:

  tab A: deal, and Ann calls    stored -> bettingOpen true, committed [0,1,2,2]
  tab B: type a name in seat 5  stored -> bettingOpen false, committed [0,0,0,0]

The deal, the blinds and the call are gone, and neither tab said anything. That is the
same silent shape as the save and load failures either side of this change.

The browser already tells us: a storage event fires in every other tab when the key
changes, and never in the one that wrote. So a tab that sees its table move underneath
it now says so, once, with the way out attached:

  toast:  "This table is open in another tab. Whichever acts last wins."  [Reload]
  panel:  "This table is open in another tab, and both are writing to it — whichever
           acts last wins, so work done in the other one can vanish. Close one, then
           reload this tab to pick up the table as it stands."

Watched rather than merged, deliberately. Replacing the table under a dealer's hands
in the middle of a hand would be its own hazard, and reloading is the only resolution
that does not throw somebody's work away -- so it is offered rather than taken.

6 tests (269 total): a write from elsewhere is noticed, another app's key and our own
recovery key are not our business, ten writes produce one warning, watching twice
listens once, no listener is not an error, and no window at all is not an error.

Driven with two live tabs: both quiet at first, A deals and B warns with the reload
offer, A stays quiet about its own writes, two more actions in A add no further toasts
in B, and reloading B picks up A's table -- bettingOpen true, committed [2,1,2,2] --
with the warning cleared. Audit and contrast clean, no console errors.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 18:35:27 -06:00
6 changed files with 158 additions and 24 deletions
@@ -25,7 +25,6 @@
<section id="tableSection">
<div id="pokerTableWrap">
<div id="pokerTable">
<div id="tableWatermark" aria-hidden="true">Texas Hold'em</div>
<div id="potBadge">
<span id="potBadgeLabel">Pot</span>
<span id="potBadgeAmount">$0</span>
+12 -19
View File
@@ -357,21 +357,18 @@ button,
justify-content: center;
}
#tableWatermark {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.06);
font-size: 2.4rem;
font-weight: 700;
letter-spacing: 0.15em;
text-transform: uppercase;
text-align: center;
pointer-events: none;
user-select: none;
}
/* The felt used to carry a "TEXAS HOLD'EM" wordmark across its middle. It came out
from behind the pot badge in two pieces — measured at every seat count from two to
ten, the middle 20% of it was covered, so the felt read "TEXAS LD'EM" — and no
placement fixed that everywhere. Thirty per cent down cleared the badge on a desktop
and landed on the seats of a phone's square felt (43-70% covered); a smaller mark at
20% or 78% was clean on desktop and tablet and no better on a phone; the bottom one
fell off the oval entirely at 360px. A square felt with six to nine seats has no
clear band wide enough for a word.
So it's gone. A watermark that looks broken is worse than no watermark, and the felt
has a gradient, a rim, an inner gold line and a shadow to be a table with — plus a
header saying "Texas Hold'em Dealer" a few inches above it. */
#potBadge {
position: relative;
@@ -2845,10 +2842,6 @@ body.hasActionBar #toastHost {
padding: 0.25rem 0.3rem;
}
#tableWatermark {
font-size: 1.4rem;
}
#potBadge {
padding: 0.5rem 1.1rem;
}
@@ -461,6 +461,17 @@ document.addEventListener("DOMContentLoaded", () => {
// The one time the browser refuses to save, say so out loud as well as leaving the
// standing warning in the Session panel. Set before the first render so a failure
// during start-up is caught too.
// Another tab writing to the same table. Said once, with the way out attached —
// reloading here adopts whatever that tab has done, which is the only resolution
// that doesn't throw somebody's work away.
HoldemState.onOtherTab = () => {
Toast.show(
"This table is open in another tab. Whichever acts last wins.",
{ actionLabel: "Reload", onAction: () => window.location.reload(), duration: 20000 });
Render.renderStorageWarning();
};
HoldemState.watchOtherTabs();
HoldemState.onSaveFailed = () => {
Toast.show(
"This table isn't being saved — refreshing would lose it. Keep this tab open.",
@@ -761,13 +761,20 @@ const Render = {
renderStorageWarning() {
const host = document.getElementById("storageWarning");
if (!host) return;
host.hidden = !HoldemState.saveFailed && !HoldemState.loadFailed;
host.hidden = !HoldemState.saveFailed && !HoldemState.loadFailed && !HoldemState.otherTabActive;
if (host.hidden) return;
// Both can be true at once — a browser having trouble with storage tends to
// have it in both directions — and the one that explains an empty table goes
// first, because that's the question the dealer is standing there asking.
// Any of these can be true at once — a browser having trouble with storage
// tends to have it in both directions — and the one that explains an empty
// table goes first, because that's the question the dealer is standing there
// asking.
const lines = [];
if (HoldemState.otherTabActive) {
lines.push(
"This table is open in another tab, and both are writing to it — " +
"whichever acts last wins, so work done in the other one can vanish. " +
"Close one, then reload this tab to pick up the table as it stands.");
}
if (HoldemState.loadFailed) {
lines.push(
"The saved table couldn't be read, so this one started empty. " +
@@ -84,6 +84,30 @@ const HoldemState = {
// Where an unreadable save is kept instead of being written over.
RECOVERY_KEY: "casino.holdem.state.v1.unreadable",
// True once another tab has written to this table. Two tabs each hold their own
// copy in memory and each save writes the whole thing, so the second one to act
// silently throws the first one's work away — measured: tab A deals and takes a
// call, tab B renames a seat, and the deal, the blinds and the call are gone with
// nothing said in either tab.
//
// Watched rather than merged. Replacing the table under a dealer's hands mid-hand
// would be its own hazard; knowing there are two of them is what they need.
otherTabActive: false,
onOtherTab: null,
// The browser fires this in every *other* tab, never the one that wrote, so our
// own saves can't trip it.
watchOtherTabs() {
if (typeof window === "undefined" || this.tabWatcher) return;
this.tabWatcher = event => {
if (event.key !== this.STORAGE_KEY) return;
if (this.otherTabActive) return;
this.otherTabActive = true;
if (typeof this.onOtherTab === "function") this.onOtherTab();
};
window.addEventListener("storage", this.tabWatcher);
},
// True when there was a saved table and it couldn't be read. Same pair as
// saveFailed above, for the same reason: the UI has to be able to say so.
loadFailed: false,
+100
View File
@@ -0,0 +1,100 @@
// Noticing that the same table is open twice.
//
// Two tabs each hold their own copy in memory and each save writes the whole thing,
// so the second one to act throws the first one's work away. This doesn't merge them
// — replacing a table under a dealer's hands mid-hand would be its own hazard — it
// notices, which is what lets them decide.
const test = require("node:test");
const assert = require("node:assert");
const { HoldemState } = require("./load");
function withWindow() {
const listeners = [];
globalThis.window = {
addEventListener(type, fn) { if (type === "storage") listeners.push(fn); },
removeEventListener(type, fn) {
const i = listeners.indexOf(fn);
if (i >= 0) listeners.splice(i, 1);
},
};
return {
listeners,
fire(key) { listeners.forEach(fn => fn({ key })); },
};
}
test.beforeEach(() => {
HoldemState.otherTabActive = false;
HoldemState.onOtherTab = null;
HoldemState.tabWatcher = null;
});
test.after(() => {
delete globalThis.window;
HoldemState.otherTabActive = false;
HoldemState.onOtherTab = null;
HoldemState.tabWatcher = null;
});
test("a write to this table from elsewhere is noticed", () => {
const win = withWindow();
let told = 0;
HoldemState.onOtherTab = () => { told++; };
HoldemState.watchOtherTabs();
win.fire(HoldemState.STORAGE_KEY);
assert.equal(HoldemState.otherTabActive, true);
assert.equal(told, 1);
});
test("somebody else's storage key is not our business", () => {
const win = withWindow();
let told = 0;
HoldemState.onOtherTab = () => { told++; };
HoldemState.watchOtherTabs();
win.fire("some.other.app.state");
win.fire(HoldemState.RECOVERY_KEY);
assert.equal(HoldemState.otherTabActive, false);
assert.equal(told, 0);
});
test("told once, however many times the other tab acts", () => {
const win = withWindow();
let told = 0;
HoldemState.onOtherTab = () => { told++; };
HoldemState.watchOtherTabs();
for (let i = 0; i < 10; i++) win.fire(HoldemState.STORAGE_KEY);
assert.equal(told, 1);
});
test("watching twice doesn't listen twice", () => {
const win = withWindow();
HoldemState.watchOtherTabs();
HoldemState.watchOtherTabs();
HoldemState.watchOtherTabs();
assert.equal(win.listeners.length, 1);
});
test("no listener set is not an error", () => {
const win = withWindow();
HoldemState.onOtherTab = null;
HoldemState.watchOtherTabs();
assert.doesNotThrow(() => win.fire(HoldemState.STORAGE_KEY));
assert.equal(HoldemState.otherTabActive, true);
});
test("no window at all is not an error", () => {
// The same modules are loaded in Node by the test harness.
delete globalThis.window;
HoldemState.tabWatcher = null;
assert.doesNotThrow(() => HoldemState.watchOtherTabs());
assert.equal(HoldemState.otherTabActive, false);
});