Compare commits

...
Author SHA1 Message Date
jheapsandClaude Opus 5 f841ccf425 Stop the table being told a blind the dealer never typed
Typing 0.25 into the small blind produced 250, and nothing said so.

The six setup fields are wired `input` -> save -> Render.renderAll(), and
renderAll comes back round through renderSetupFields and assigns `.value`
to the very box being typed in. The decimal point is where that turns from
a wasted render into a wrong number: a type="number" reports a half-typed
"0." as "0", so the write-back erased the point, and assigning `.value` to
a number input parks the caret at position 0, so every digit after it
landed at the front. Measured keystroke by keystroke in Chrome:

  small blind   "0.25"   0 -> 0 -> 20 -> 250        a table playing 250/500
  big blind     "0.50"   0 -> 0 -> 50 -> 500
  start stack   "12.50"  1 -> 12 -> 12 -> 512 -> 5012
  rake percent  "2.5"    2 -> 2 -> 52               a 52% rake
  rake cap      "7.50"   7 -> 7 -> 57 -> 507
  base pot      "0.75"   0 -> 0 -> 70 -> 750

Every one of those is a valid number, so no validation could have caught
it, and every pot for the rest of the night is computed off it.

Two rules, kept separate. Render.setField refuses to write into the
control that has focus — a render says what the state is, and is not
entitled to say what somebody is half-way through typing. And the fields
come off type="number", which is what made a half-typed number unreadable
to begin with; it also drops characters silently, hides selectionStart,
and puts a spin button in a 54px box where five digits already need 45 of
the 52. That is the control GOV.UK dropped from their number patterns
after user testing. inputmode is what summons the keypad on a phone, and
"decimal" carries the separator a dealer needs to type 0.25 at all.

With type="text" a stray letter now reaches the handler instead of being
eaten, so Money.readAmount reads the boxes: empty is null, non-finite is
null, and NaN never becomes a blind. A field skipped while focused catches
up when it is stepped out of.

After, on the same six: 0.25, 0.50, 12.50, 2.5, 7.50, 0.75 — and the
panel glance reads "10 seats - $0.25/$0.50".

1385 tests pass (23 new, 19 of which fail against the code they replace).
Sweep clean across 428 audits on 13 viewports; contrast unchanged.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-15 06:44:44 -06:00
jheapsandClaude Opus 5 6a59b0d5ba Stop the table being drawn from a measurement of a bar that isn't there
The felt is sized from --feltRoomPx, which actionBar.js publishes from the
bar's real height, because the bar is fixed to the bottom and grows with the
hand so nothing else can know how much window is left. That measurement is
right. What it was measuring was not.

ActionBar.mount() called watchHeight() before render(), and the bar ships
`<div id="actionBar" hidden>`. So the first publish measured a bar that was
not there yet and published 0px — not "I don't know" but "the bar takes none
of the window", which is the one answer that makes the felt draw itself too
big. Traced on a cold load, nine-handed:

  t=181ms   --actionBarHeight 0px     bar hidden, 0 tall
  t=186ms   --feltRoomPx      721px
  t=1056ms  --actionBarHeight 151px   bar visible, 151 tall — ResizeObserver
  t=1059ms  --feltRoomPx      570px

The observer does come round on its own once `hidden` comes off, but a frame
later at best, and with nine seats to lay out it was 875ms behind. What a
dealer saw at 360x640 was a table drawn 336x504 and collapsing to 336x374
after it was on screen and readable: 130px of felt with every seat on it.
1280x900 lost 13px the same way.

Two rules, separate on purpose. publish() now declines rather than guessing
while the bar is hidden and not a rail — a rail still publishes its honest
zero, because it is a real bar that has moved out of the way. And mount()
publishes once render() has given it something to measure.

That leaves the frame before any of it, which is real: the scripts are at the
end of the body and the table is laid out inside DOMContentLoaded, so the
browser has painted by the time --feltRoomPx exists. The fallback there was
`100vh` — the worst guess available, since it says the whole window is free.
It is now the same guess two other rules in this file already make, 9rem for
the bar plus 7rem for what sits above and below, which lands 10px over the
truth at 360x640; and it is a ceiling, so over is a small shrink rather than a
seat behind the bar.

Measured after, on both phones: one draw, no jump, and a layout-shift score
of 0 where the felt used to move 130px.

1362 tests pass, 8 of them new — 5 fail against the old code. One existing
test pinned the literal fallback string; its claim is about the cap being the
measured room, and it now says that. The sweep is clean across 428 audits;
contrast unchanged.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-15 05:59:54 -06:00
jheapsandClaude Opus 5 e36e172670 Let the undo pill finish its own sentence
The bar's undo is a fixed-width pill because its text changes on every tap and
a control that resizes under a thumb reads as the interface twitching. On a
phone that leaves the label 78px, so the label is three elements and
fitUndoLabel drops the actor whole rather than letting an ellipsis land
wherever it lands. That machinery was right. The labels fed to it were not.

Two rules follow from "the actor is the half that goes", and six of the
fifteen labels broke one.

The act has to name the action on its own, because it is what survives. Four
labels put a verb in the actor's slot — "dealing" + "the hand", "dealing" +
"everybody in", "calling" + "the straddle off" — so dropping the lead left the
pill reading "↶ the hand" after every deal of the night, and "↶ the straddle
off". Neither names anything. Those are re-split: "the deal", "everybody dealt
in", "the straddle called off".

The act has to fit, because whatever is left is ellipsised. Measured at
390x844 and 360x640 with a thumb: "hand killed on the clock" wanted 125px of
78, "check on the clock" 99, "waiting for the blind" 103, "posting to come in"
97 — all four came out cut mid-phrase, "↶ waiting for th…". The qualifier goes
into a new optional `long`, which is the whole sentence, and the verb stays on
the pill.

Every short form is a substring of its long form, which is what Label in Name
asks of a control that has shortened its own name — and it is what lets `long`
be the accessible name rather than only a title a phone has no way to show.
Before this the name was whatever was visible: "↶ hand killed U", arrow and
keycap included. Both are aria-hidden now.

Measured after, on both phone sizes: nothing ellipsised, and every visible
form contained in the name. One exception, stated in the code: a five-figure
raise. "raise to $12,500" is 86px of 78 and the number is the whole of what it
says, so there is no qualifier to move — the leading digits survive, the
amount is on that player's seat puck, and the whole of it is on the name.

Three comments carrying the old 73px figure are re-measured while I was here.

1353 tests pass, 11 of them new — 3 fail against the old labels. The sweep is
clean across 428 audits; contrast unchanged.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-15 05:33:27 -06:00
jheapsandClaude Opus 5 6992833821 Make the way back reachable, and stop it leaving while it is being reached for
This tool prefers undo to confirmation, deliberately — runUndoable says so in
as many words — which makes the toast's Undo the safety net under the biggest
things it does. It was not one.

Every whole-step undo clears the per-action history on its way past, so the
bar has no pill left and the toast's button is the only way back. Measured on
a desktop it is the 67th of the page's 68 tab stops, and it is gone in eight
seconds. And U — the key the sidebar legend advertises as taking back the
last action — found nothing and did nothing: it only ever looked in the bar.
So U looks at the toast first, by pressing the button that is on the screen
rather than running a second copy of what it does, and only at a button that
named the shortcut: "Turn off" on the wake lock and "Reload" on the other-tab
warning are not things a stray keypress should reach.

The eight seconds also ran whatever the dealer was doing. A cursor resting on
the toast did not stop it; nor did tabbing onto the button. Measured before:
focus the Undo, wait 8.6 seconds, and the toast is gone and focus has been
handed to <body> — the offer withdrawn and the dealer's place in the page
with it. Touching it or tabbing into it now stops the clock, and letting go
starts it again with at least the time to act rather than whatever was left
of the time to notice: let go at 7.9 seconds of 8 and 100ms is not an offer.
Dismissing with focus inside puts focus back where the dealer had got to,
which is read off where focus crossed into the toast rather than off where it
was when the message arrived.

And each toast carried its own role="status" inside #toastHost, which is
already a live region in the markup. A region nested in a region, announced
twice; the one in the markup is the one that works, because it is the one
anything has been watching.

1342 tests pass, 18 of them new — and 8 of those fail against the old code.
The sweep is clean across 428 audits; contrast unchanged.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-15 04:59:18 -06:00
jheapsandClaude Opus 5 023228efc0 Give the rulings the window a sideways phone was already holding
Twelve rulings in a 460px sheet in the middle of an 844px window: 754px of
content in a 334px scrollport, 111 of it spent on a title and a caveat that
stand still, 384px of window width unused either side. Four of the twelve
names on screen, and the dealer is looking one up with an argument going on
at the table.

Material's rule for a bottom sheet in landscape is to stop being one, and
this tool already does exactly that under this query for the action bar. So
the sheet takes the window and the width pays for the height: the twelve go
into two columns of six, all of them on screen at 844x390 with nothing to
scroll at all, and the title, the caveat and Done share one line instead of
standing on three.

Two wrappers in the markup rather than one grid of twelve, because a grid
pairs its children into shared rows: opening the third ruling on the left
would push the ninth down the right-hand side to keep the row square. Two
flows means a column only ever moves itself — measured, opening one leaves
the other six names exactly where they were. Everywhere but landscape the
wrappers are `display: contents` and the twelve are the single column they
have always been.

And the same sheet upright: the header is stuck to the top of its own
scrollport now, the way the seat menu's has been. Measured before, at the
bottom of that scroll Done was 221px above the sheet's top edge on a 360x640
phone, and 262 on a 390x844 one with a ruling open — which is the only
reason anybody is in here. A dealer who has read the answer had to scroll
back past it to leave.

The layout sweep's scroll-into-view guard learns the new box, or it finds a
sheet whose scrollHeight equals its clientHeight and decides nothing is out
of view.

1324 tests pass. The sweep is clean across 428 audits; contrast unchanged.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-15 04:36:36 -06:00
jheapsandClaude Opus 5 f86e50268e Stop calling it the next hand when it deals no hand
"Next hand" named three things: the bar's green button, the pot wizard's
last button, and the header shortcut — the only one of the three that
never starts a hand. Between hands it moves the button a second time, so
the seat about to pay the big blind is skipped, and the toast opened with
the words the wizard had just used, which read as a confirmation of what
was already done.

Measured: settle through the wizard and the table is on hand 6 with the
button on seat 2; press the header's Next Hand and it is still hand 6,
button on seat 3, no confirmation asked.

The mechanism stays — a dealer does pass the button along on a hand
nobody played. The name changes. The label and its title are now read off
whether a hand is out: "Next Hand ⟳" while one is, "Move Button ⟳" when
none is, written past the dimmed-reason cache each render so they follow
the table instead of freezing on the state the page loaded in. The toast
says which of the two happened, on one line: the second line told the
dealer to press the green button the bar is already holding.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-14 04:17:32 -06:00
jheapsandClaude Opus 5 4778852436 Ask a seat what it is doing, and put the answer where it can be seen
Two findings in the seat editor, and the second is why the first was
hard to notice.

A seat is sitting out, in the hand, or folded, and "folded" means
nothing while the seat is out. The form asked that as two checkboxes,
which offers a fourth corner that has no meaning, and the code that
stopped a dealer reaching it disabled the Fold box whenever In hand was
clear. Measured: that left the label at opacity 1 and colour
rgb(170,170,170) — identical to a live one — with a title of "Folded"
on it. Pressing Fold on a seat that was sitting out gave no movement and
no reason, which is the exact failure round 16 went through the tool to
remove; it hid from the sweep rule that finds those because the title
sat on the <label> rather than on the control the rule inspects.

Three named states can't contradict each other and none of them is ever
unavailable, so there is nothing left to dim. Sitting out · In the hand ·
Folded, as a radiogroup — the same shape the card picker's suits use —
with the chosen Folded segment in the red the seat menu's own Fold
already wears.

And the form itself. The phone block's comment has always said why the
editor becomes a bottom sheet — "the seat editor lives far enough down
the page that tapping a seat looks like it did nothing" — but the sheet
was scoped to max-width 700px while #holdemLayout goes to one column at
900. The two tiers in between had exactly the fault the sheet was
written to fix. Measured, tapping a seat:

  844x390   the editor drew in the flow at y=369 in a 390px window —
            21px of 401 on screen, 5% of it, at scrollY 0 with nothing
            scrolling it into view. The seat lit up green and the form
            it opened was under the bottom edge.
  768x1024  31% visible, the rest below the fold, again no scroll.

An iPhone held sideways and an iPad held upright are not obscure. The
query is the layout's own breakpoint now, and the seat-sheet rules moved
out of the phone block into their own so it could be asked separately.
Afterwards every viewport from 360x640 to 900x500 shows 97-99% of the
sheet, and above 900 the editor is a static block at the top of a
sidebar beside the table — 100% visible there too.

1307 tests pass; the sweep is clean at 428 audits across 13 viewports.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-14 02:05:58 -06:00
jheapsandClaude Opus 5 3458a6d144 Put the two numbers the panel glances at above the prose
Table Setup is the one panel the tool opens by itself, on a table nobody
has set up yet, so it is most of what a dealer sees the first time they
arrive. Measured on a fresh table: the panel stood 886px on a 667px
phone, and the whole page 2076.

The order was the problem more than the length. The two figures the
panel's own summary glances at — "10 seats · $1/$2" — were the seat count
at the top and the blinds 218px further down, under three switches and
the three paragraphs explaining them: a dedicated dealer, a card sheet at
every street, and whether the phone is allowed to sleep. A dealer setting
up a $1/$2 game read all of that on the way to the two boxes they came
for.

The three have gone into "While you deal", with the paragraphs. That is
not a new idea in this panel — House take and Without live betting were
already nested disclosures — it is the same idea applied to the things
that were not about money. Afterwards the panel is 529px, the page 1719
on the same phone and 1275 on a 1280x900 desktop, and the first two rows
are the seats and the blinds.

A switch nobody can see is a switch nobody remembers turning on, so the
disclosure carries a glance of its own: "Asking for cards", "House
dealer", or "Screen may sleep" when one of the three is away from where
it ships, and silence when none of them is. One of them, not a list:
joined they run to 49 characters into a row 44px tall with a label
already on it, and "House dealer · Asking for c…" has told nobody
anything.

1291 tests pass; the sweep is clean at 428 audits across 13 viewports.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-14 01:31:38 -06:00
jheapsandClaude Opus 5 e9ec4afafe Ask a card the two questions it is, not one out of fifty-two
The picker put the whole deck on the screen at once — four suits across,
thirteen ranks down — and that grid is 646px tall. Measured with the
board tracked and seven cards already out:

  640x360   816px of content in a 317px sheet, 40 of the 52 below the fold
  667x375   816 in 330, 40 below
  844x390   774 in 343, 36 below
  375x667   816 in 587, 20 below
  1280x900  774 in 775, none

So on every phone the tool is actually used on, entering the flop began
with a scroll, and a dealer looking for the nine of diamonds was reading
down a column to find it. No arrangement fixes that: fifty-two targets at
the 44px this sweep holds everything else to need 572px of width before
gaps if they are thirteen across, which is wider than any phone in
portrait, and thirteen rows deep if they are four.

A card is not one choice out of fifty-two. It is two — a suit and a rank
— and that is how a dealer says it out loud. So the sheet asks them in
that order: four suits on a row, then the thirteen ranks of the one
chosen. Measured afterwards at eight viewports from 360x640 to 1280x900:
nothing below the fold, nothing scrolling, every button still at least
44x44 and still hit-testing as itself. Portrait content went 816 -> 372,
landscape 816 -> 322.

The second tap is not a second cost in the usual case. The suit stays
where it was put, so a second card of the same suit is one tap — and one
tap is what picking a card used to cost *plus* a scroll through thirteen
rows. Opening on a slot that already holds a card shows that card's suit
with it under the cursor, which is the case where the dealer came to
change something rather than add it.

Three things that had to move with it:

  - the cursor is a rank now, 0-12, and changing suit deliberately leaves
    it alone: the same rank in another suit is the card beside the one
    you were on.
  - the arrows read the column count off the layout instead of assuming
    SUITS.length. The rank row is auto-fill — five across on a 360px
    phone, seven at 460 — and the last time this number disagreed with
    the grid it slid the cursor into a column nobody asked for.
  - the suits are a radiogroup, not a tablist: the grid below is not
    another panel of content, it is the same thirteen controls filtered,
    and the unchosen red suits stay red, because reading four shapes to
    find the hearts is the search this sheet exists to remove.

deck() still returns all fifty-two and the duplicate check still reads
all of it, so splitting the grid by suit cannot let a card be dealt
twice.

1276 tests pass; the layout sweep reports clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-14 01:03:45 -06:00
jheapsandClaude Opus 5 53c99b2d91 Tell the sheets the phone is on its side
The action bar learned two rounds ago that a landscape phone is short of
height and swimming in width: it stops being a bar across the bottom and
becomes a rail down the right. The seat menu was never told.

Measured on the fullest menu the tool can build, twelve-handed with
somebody on the waiting list:

  640x360   545px of content in a 306px sheet. Cash out…, Move to…,
            Edit seat…, Small blind, Big blind and Cancel below the
            fold — Cancel by 224px.
  667x375   545 in 319, Cancel 211px under.
  844x390   529 in 332, Cancel 183px under.

And on all three the sheet was 420px wide inside a window 640 to 844
wide. Two hundred and forty-seven pixels of width held empty on the one
screen shape whose whole problem is height, while the dealer scrolled a
menu to find the way out of it.

The two tiers this menu has been sorted into since round 7 — what you
press with cards in the air, and housekeeping between hands — stop being
the top and bottom of a column and become the right and left of a row.
Fold and All-in land under the same thumb the rail puts Fold, Call and
Raise under, with Cancel beneath them. The housekeeping grid takes as
many columns as the width holds instead of the two a 360px phone has
room for.

After: 297px in a 298px sheet at 640x360 and at 667x375, 281 in 282 at
844x390. Nothing below the fold on any of them, every button still at
least 44x44 and still hit-testing as itself. The move picker's twelve
chairs and its Back went from four rows and a scroll to three rows and
none. Upright nothing moved at all — 545px before, 545px after, the same
two columns.

auto-fill rather than auto-fit: auto-fit throws away the tracks nothing
sits in, so a group of two stretched to half the sheet each while the
group above it was a third, and the two grids lined up with nothing.

1253 tests pass; the layout sweep reports clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-14 00:39:28 -06:00
jheapsandClaude Opus 5 2e372d4677 Take the glass off the middle of the table
The board — the five community cards a dealer taps to enter the flop, the
turn and the river — could not be pressed at all. #seatsLayer is an
`inset: 0` sibling of the table that holds the seat pucks, and an element
with no background still hit-tests, so the empty middle of that layer took
every tap aimed at what was underneath it. Measured at 1280x900 with five
cards out: all five hit-tested to `div#seatsLayer`, on every screen wide
enough to keep the board in the middle rather than on the strip. #betsLayer,
the other sheet over the felt, has been pointer-events: none since it was
written.

Underneath that, the cards were drawing at a little over half their size.
placeBoard measures how much middle the felt has and writes it to the
centre's max-width — but max-width caps a box and does not claim anything,
and the centre is a flex column that sizes to its content. So with 463px of
measured room it went on being 136px wide, the row inside it is width: 100%
of that, and five cards sharing 136px come to rest on their own min-width:
24px, against the 44px floor every other control on the felt is held to.

Neither was visible to anything. A scripted probe calling .click() on the
node skips hit-testing entirely, and the sweep's occlusion check had only
ever been shown three selectors' worth of the page — the action bar, the
header and the seats. It asks about every control now, which took three
refinements to be honest: a control that set pointer-events: none has opted
out rather than been covered; a shut <details> hands out a real rect for
everything in it, and checkVisibility is the browser's own answer; and flow
content under a fixed layer is a position rather than a fault, so it is said
rather than failed.

The five sheets a dealer spends half the night in were getting one rule of
twenty-five. They get all of them now. Three rules had to learn what a modal
is first: two ask about the fixed bar, which is not a question while a scrim
is covering it, and the third called the bottom four rows of a sheet that
scrolls unreachable buttons. The helper all three lean on had its own blind
spot — it asked whether an element had left its box entirely, which is true
of something well below the fold and false of something hanging eight pixels
over the edge of it.

That turned up two controls that were a glyph and nothing else: the pot
wizard's close, on the one dialog that divides money, and the cross that
removes a custom field, in a column of identical crosses.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-14 00:01:37 -06:00
jheapsandClaude Opus 5 c1d0105d12 Answer the press instead of eating it
The card picker greys out a card that is already dealt and hangs the reason
off a `title`. Measured on an iPhone with three hands out: seven of the
fifty-two were dead, none of them could take focus, and tapping one changed
nothing at all — no toast, no text, not a byte of DOM. The tool's answer to
"who has the ace of spades?" existed and was reachable only with a mouse.
The header's two buttons were the same, on a new dealer's first screen, and
Export CSV was greyed out with no explanation written anywhere.

`disabled` is the wrong attribute whenever there is something to say: it
drops the control out of the tab order, so a screen reader never lands on it
to hear the description, and it eats the tap on a touch screen, where the
hover that would have shown the `title` does not exist. `aria-disabled`
announces the same state and leaves the control reachable, so one capture
listener can turn the press into a sentence — in a toast, or inside a sheet
that is standing on top of the toast.

It paid for itself in the picker's keyboard too. The arrows hunted past
dealt cards because a `disabled` button cannot hold focus, and the hunting
did the exact thing the grid's no-wrap rule was written to prevent: Right
from A-spades over a dealt A-hearts landed on A-diamonds, so the column
shifted and the next press of Down walked a different suit. A cell that can
hold focus needs no hunting, and the APG asks for that inside a composite
widget anyway.

The line that answers is one line by measurement, not by taste. Its box is
318px on a Galaxy S8 at 20px of root text; the first wording drew 332px and
wrapped, shoving fifty-two targets 21px down mid-tap, and twenty-four capital
Ws draw 434px whatever the wording, so the box is what holds.

Two things the sweep found on the way. It opened the card picker by
un-hiding an overlay whose grid had never been painted, so for its whole
life it audited an empty sheet; it now opens it the way a dealer does, with
cards out and the answer showing. And the slot buttons above the grid were
named to a screen reader by the em dash they draw when empty.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-13 22:41:06 -06:00
jheapsandClaude Opus 5 2d80edd513 Say how many ways once, in the one control that owns it
"Split between" on Quick Split was two controls for one value stacked on
each other: chips 2 3 4 5, and under them a bar reading "2 ways" with a −
and a +. The count was written down three times at once — the lit chip,
the bar, and "Each of 2 players gets" in the panel above — and two of the
three were controls. 134px with its label, against 74px for "Smallest
chip in play" right below it, which offers five choices in one row.

The bar was never meant to be a bar. `.waysStepper` asked for a 150px
min-width inside a wrapping flex row, so on a phone it always wrapped to a
line of its own, and `flex: 1 1 auto` then gave it all 358px of that line:
252px of empty readout with the − and + pushed 304px apart, on a screen
390px wide. Now it is 136px with its ends 121px apart, and it only exists
once you are through the door — a fifth chip reading "6+", behind which
the stepper is where the count is written. Under six ways, which is every
chop a home game will run, the field is one row and 74px.

Two more things went with it:

Neither stepper ever disabled an end, so − at the floor and + at the
ceiling looked live and swallowed the tap. NN/G's stepper guidance asks
for the opposite. Both ends of both steppers now grey out.

The per-pot chop capped its + with `Math.max(contenderCount, MAX_WAYS)`,
which is always 12 — a side pot only P1 and P2 were eligible for would
happily chop ten ways at $12 each. It is `chopCeiling()` now, and a
two-contender pot is offered no stepper at all, that being the common
shape.

The sweep went straight to the all-in mode and audited only that, so the
mode the screen opens in had never been looked at in fourteen rounds,
which is how a 358px bar lasted this long. It audits both now — 428
audits, clean — and the first run of it found the step buttons had been
40px wide rather than 44 since they were written.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-11 22:24:49 -06:00
jheaps de4331de72 Make forty-four pixels mean forty-four pixels
Every input and select a dealer touches has a rule asking for min-height: 44px,
and not one of them was 44px tall. Form controls are content-box by default, so
44 was being read as the height of the text inside, and 8px of padding top and
bottom plus a 1px border each side went on the outside of it. Measured on an
iPhone: all fifteen settings fields in the sidebar stood at 62px, and the raise
pad's amount box at 66 with the text turned up.

Nothing looked wrong, which is why it lasted. 62px clears a 44px floor, so the
sweep — which asks whether a target is big enough, never whether it is the size
it was asked to be — had nothing to say, and one field is not obviously too
tall. The cost is only visible in the aggregate: Table Setup 941px to 869, the
waiting list 212 to 194, every panel in the sidebar open 2,993 to 2,813.

The raise pad's field was worth measuring rather than assuming, because it is
not only a field: it sets the action bar's height, the bar's height is
published into --feltRoomPx, and the felt is laid out from what is left. It
takes 10px off the bar and hands them to the table. On a Galaxy S8
twelve-handed at 20px text the bar goes 248px to 238 and seat 7's stack comes
out from behind it — with the taller field the sweep reported "$1,100 is 7/7
under div#actionBar", a figure a dealer cannot read at all.

Written against the element types rather than the surfaces they sit on. The
surface version was written first and had already missed the waiting list's
name box, two hundred lines from anything it named.

This is the third time this stylesheet has been caught by content-box; the
panel headers and the rulings summaries are the other two. The new test holds
every field given a 44px floor to being measured the way that floor assumes.

1167 tests, 418 audits clean.
2026-09-11 20:38:03 -06:00
jheaps e0536de14d Let the Chip Legend show a legend instead of the form for typing one in
The panel measured 793px on an iPhone and 420 of them were the colour form:
five rows of swatch, value box and a filled red delete block, then an add
button and a paragraph of instructions. Underneath all of it sat "Stacks in
chips" — the fewest chips that make each stack, which is what a dealer opens
this panel during a game to read. Setting the colours is something you do once,
on the first night, and it was sitting on top of the thing you do at every
colour-up.

The order is the other way round now. The legend leads: five colours and five
amounts on one line, which is what the panel is named for and what it had never
once displayed — the values were locked inside number inputs, so answering
"what is the green one worth" meant reading it out of a form field. The
colour-up list follows. The form goes behind a disclosure, the same shape Table
Setup already uses for House take and Without live betting. 793px to 441px,
with both the things a dealer reads above the fold.

The five delete buttons keep the 44px the touch rules give them and lose the
red fill, which made each one as loud as the row it removes and louder than the
values beside it. The red comes back on hover, on the way to pressing one. And
the colour-up list no longer runs straight into its own footnote.

The sweep's twelve-handed "packed" shape joins "all in" as a note rather than a
failure at 20px text, with the arithmetic written beside it: on a 360x640
phone the felt is 330x294, the pucks are 44x63, five sit down each side, and
315px of puck does not go into 294px of felt. The pitch between neighbours is
35px and 51px against a 63px puck. They have always overlapped; what kept the
run green was the hit-test probes landing between the glyphs, so any change
that moved the ring three pixels turned it red for something it hadn't touched
— twice this round. Held to the same standard as everything else at 16px.

1162 tests, 418 audits clean.
2026-09-11 20:15:23 -06:00
jheaps b096f054f1 Take the chair number off the corner it was never alone in
A seat's number was a grey digit in the top-left corner of the puck, and the
top-left corner is also where the blind badge drops off the rim and where a
long centred name reaches. Measured across 348 seats on 36 tables: 117 of them
had it printed under something else. All 66 seats posting a blind had it
covered outright — a 22px badge at z-index 4 over a 6px digit — and 51 more had
it sitting on the first letter of the player's own name, the whole glyph in the
worst case, on a 54px puck.

There is nowhere on the felt to move it to. The button's chip has the other end
of the top rim. Bets land on the rim between a seat and the pot, which is the
bottom edge for the seats above centre and the top edge for the ones below. The
middle of the puck is name, bet and stack, and all three are about this hand.
The chair number is the only mark on a puck that isn't, so it is the one that
gave way.

Nothing is lost that a tap doesn't return. The seat editor's heading reads
"Mike from work · Seat 2" now and the seat menu's first line leads with the
chair; a seat nobody has named still calls itself "Seat 5"; and an open chair
still wears its number large and centred, which is the one moment the number is
the thing being looked for rather than a footnote on somebody else's name.

The sweep had never mentioned any of this. The check that asks whether a puck
covers itself up knew about two of the three things a puck wears, and only ever
held them against writing — so the mark sitting on the names was invisible to
it, and so was a badge painted straight over another mark. Both gaps are
closed: put the number back and it reports five offenders on a Galaxy S8
ten-handed, naming Christopher, Alexandra, Jonah and the SB.

1148 tests, 418 audits clean.
2026-09-11 19:19:03 -06:00
jheaps 49c2d745eb Let the keyboard into the sheets, and stop dropping it on the floor
Modal answers one question for all six overlays — where focus goes and how
Tab is held — and this round it was measured as wrong in three directions.

Its list of Tab stops could not see a <summary>. The rulings sheet is twelve
of them and one Done button, so the trap decided Done was both ends of the
ring: eighty Tab presses, one element ever reached, twelve rulings that could
not be opened without a mouse. Adding `details > summary` gives the sheet its
thirteen stops back. `:not([tabindex="-1"])` moves onto every line while it is
open, because a control taken out of the Tab order still matches `button`.

"The first control" was the card picker's Clear: the sheet a dealer opens to
write a card down opened on the button that rubs one out. A sheet names its
own landing spot now, and on five of six it is the heading — which says which
seat, which card, which pot. The confirm dialog keeps a control, because a
short question with one irreversible answer should land on the answer that
leaves the dealer no worse off.

And a sheet that redraws itself was deleting the control that held focus.
Choosing a buy-in, picking a card, switching slots, confirming a side pot —
all four left focus on the document body. Rebuilt controls carry a name now
and are handed back to whoever had them; where there is no coming back, the
caller says where to go instead.

The card grid was fifty-two Tab stops of the fifty-six in that sheet. It is
one now, with the arrows moving inside it, stepping over cards already dealt
and stopping at the edges rather than wrapping onto a different rank.

1136 tests, 418 audits clean.
2026-09-11 18:49:42 -06:00
jheapsandClaude Opus 5 382238e708 Tell the browser the page is dark, and stop painting over it instead
Everything in this stylesheet is a colour it paints. `color-scheme` is the half
it cannot: the browser draws the checkboxes, the menu a <select> opens, the
scrollbars, the caret, the spellcheck underline and the autofill wash, and with
no scheme declared it draws every one of them for a light page. Measured,
`color-scheme: normal` on a #141414 ground — three checkboxes in Table Setup
rendering as bright white squares, the loudest objects on the screen and
brighter than the gold, and two more in the seat sheet doing it over the felt.

The comment on the pot wizard's hand-painted checkbox called it "the last raw
browser control in the tool". Six others were still the platform's, and three
of those were still 13x13 against a 24px floor this file had already written
down once — in a comment that explains the floor, attached to a rule naming one
checkbox by id.

One declaration on :root moves all six, and moves them natively, which is the
part worth having: a control the browser draws still answers Windows High
Contrast Mode, still matches the platform the dealer already knows, and cannot
lose its tick to a stylesheet. `accent-color` makes the checked state the green
this tool already uses for yes — the same #1e8449 the painted box fills with,
so the one drawn checkbox and the six native ones agree rather than nearly
agree.

The painted one stays painted, because it is not a setting: it is the box a
dealer ticks to move money with players watching, and it wants a fill that
reads across a table. But it now pays what painting costs. A tick drawn as a
`background` on a pseudo-element is not a colour forced-colors mode knows how
to replace, so it was simply gone — no visible checked state at all in Windows
High Contrast Mode, on the dialog that pays the table. System keywords are the
only colours guaranteed to survive, because they are the palette.

Three other things fell out of looking:

The id-scoped 24px rule existed three times over — once in the open, once under
`max-width: 700px`, once under `pointer: coarse` — and still reached one box of
six. Said about checkboxes instead, once.

A colour swatch declared 32px wide measured 22 with a mouse: flex-shrink, the
exact mechanism the coarse-pointer block had already been bitten by and written
a note about. It gets the same `flex: none` the touch case got.

Quick Split is the darker of the two pages and had never declared a scheme
either. It has no checkboxes to go white, but a dealer opening it from the felt
should not find a light scrollbar down the side of it.

1104 tests pass; the 418-audit sweep is clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-11 18:00:05 -06:00
jheapsandClaude Opus 5 8da48e02c8 Let the rulings be a list of rulings, not a scroll with a list on top
The sheet a dealer opens mid-argument held twelve rulings with all their prose
showing — 2,480px of it on a 390px phone, of which 1,754 was below the fold —
and a row of twelve chips across the top to jump between them.

The chips were an index of the twelve headings printed directly above the same
twelve headings. The same list twice: 172px of navigation before the first
word, and the dealer who tapped one landed in the middle of a scroll with
eleven rulings they hadn't asked for still wrapped around them.

The headings are the index now. NN/g's rule for this shape — a longer list of
independent topics somebody opens one of — is an accordion, shut by default,
and it suits these unusually well because half of them answer the question
without being opened at all. "A short all-in doesn't reopen the betting" is the
ruling, not the topic. So is "Chips nobody called come back", and "A straddle
is a blind raise", and "A raise has to be a full raise".

Measured after: 29px below the fold on a 390px phone with all twelve rows on
screen at once, 51px on a desktop, and 244px with the longest of them open.

Not exclusive — no `name` attribute. Closing one because another opened moves
everything under the finger that is reaching, which is the disorientation the
accordion is here to remove rather than add. And the sheet shuts what it finds
open when it is opened again: <details> keeps its state, so without that a
dealer who read the odd-chip rule an hour ago finds it still hanging open under
the one they came back for.

Two things that came with it:

The gold arrives when a ruling is opened and not before. Twelve gold headings
in a column is the accent doing the job of body text, which leaves it nothing
to say when one of them is the one being read. The sidebar's eight panel names
are --text for the same reason; this is the same component doing the same job.

Five of the twelve are two paragraphs, and they ran into each other — the
sections were spaced from one another but nothing separated the halves of one
ruling. It never showed while there was a gold heading every few lines to break
the column up.

1095 tests pass; the 418-audit sweep is clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-11 17:23:41 -06:00
jheapsandClaude Opus 5 71c198cf2e Make "up to" true on the screen that pays the table
The pot wizard printed a figure beside every name under the words "up to". It
was that player's own remaining contribution, which is not what winning pays
them, and the gap was large and in the wrong direction. Three players in for
$250 each: all three rows read "up to $250" while the prompt directly above
them said "$765 is in play" — and $765 is what the winner was handed. A dealer
who trusted the row would have short-paid by two thirds.

With a real side pot it was worse in a more confusing way. The breakdown box at
the top of the same dialog announced "Main pot $765 · all 3", and Bob's row
three lines under it said "up to $250". One dialog, two numbers for the same
money, and the wrong one next to the checkbox the dealer actually ticks.

resolveRound caps the pool at the winner's own remaining contribution and then
takes min(remaining, cap) from every entry, dead money included — so the
ceiling is that sum, and PotCalculator.ceilingFor computes it. Bob's row now
says $765, which is the main pot, which is what the breakdown box three lines
above has been saying all along.

It is also only said where it binds. A ceiling equal to everything on the table
is the prompt's own figure repeated once per row — redundant data-ink, and on a
pot everybody covered that was all it ever was. It now appears on exactly the
rows where picking that winner leaves a side pot still to award, which is the
one time a dealer needs telling. The flat pot's three rows carry a name and
nothing else; the side pot's carries one number, on Bob.

The test that matters is not that the ceiling equals a figure written out by
hand but that it equals what resolveRound pays, so every case checks the label
against the function that moves the chips — across seven shapes of pot, ragged
contributions and cent-sized stakes included.

1082 tests pass; the 418-audit sweep is clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-11 16:59:18 -06:00
jheapsandClaude Opus 5 9af65a7b82 Sort a seat's eleven actions by when, not draw them all the same
The long-press menu had grown to eleven actions and drew every one of them as
the same full-width bar. On the fullest menu the tool can build — a live seat
that isn't the dealer, blinds still the only money in, an override already set
— that came to 796px and took 85% of the screen on all four viewports measured.
It scrolled on all four, and Cancel was off the bottom of the screen on all
four: 79px past it on a 390px phone, 252px on a 360px one, 448px in landscape.
Apple puts the comfortable number for an action sheet at three to five and says
not to let one scroll at all.

Nothing is cut. Every one of the eleven is the only route to something, and
"Blinds follow the button again" is the only route in the whole tool to
clearing a blind override. They are sorted by *when* instead: a seat action is
either something you do with cards in the air or housekeeping between hands,
and those two want different targets. The hand keeps the full-width bars —
Fold, All-in, Un-fold, and seating the next player off the rail, because a
chair opens between two hands with people watching it. Everything else goes to
a two-column grid of quieter 44px tiles underneath.

The captions are not decoration. A half-width tile reading "Small blind" is a
label, and the puck already wears one of those; under "Give this seat" it is a
verb again. That is what lets three of the longest labels here lose ten
characters each and still fit a half tile on one line, which is the height the
caption pays back. The override reset spans the grid rather than sitting in it
as a fourth position to give, dashed and unfilled — the felt's own word for
"nothing set by hand here" — so it doesn't read as the Cancel below it either.

Same fullest menu afterwards: 650px, 77%, no scroll on a phone or a desktop.
What a dealer actually sees mid-hand is eight actions and 546px, 65%. The move
picker, which was eleven more full-width bars, now fits all eleven chairs of a
twelve-handed table on a 360px phone without scrolling.

Two things the height was hiding, fixed with it:

The header is stuck to the top of the sheet's scrollport and carries a real
close button. NN/g is explicit that a grab handle is not a close affordance,
and this is the sheet that can still outgrow a landscape phone — so the way
out, and whose menu this is, are now in the part that cannot scroll away.

That close button is also the first control in the markup, which is what Modal
focuses. It used to focus Fold. The APG says to keep a dialog's initial focus
off the destructive choice; a close button at the top puts it there without a
special case. Escape finds that one rather than the button at the foot, so
Escape closes the sheet while the foot steps back — and it now has somewhere to
step back to: opening the move picker used to replace the sheet and leave only
a control that threw the whole menu away.

1068 tests pass; the 418-audit sweep is clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-11 16:31:57 -06:00
jheapsandClaude Opus 5 37abc72de1 Take the dealer button out of the writing and put it on the rim
A puck can say four things about where a seat sits in the hand: small blind,
big blind, straddle, button. Three of them were pills hanging off the top-left
edge. The fourth was a 22px disc inside the top-right corner, on a layer above
the name — so the dealer's eye had two places to look for one class of fact,
and the corner one was charging the name 25px of its line to be there.

What that cost, measured across the sweep's tables with the twelve packed names:

  Galaxy S8  9-handed   name box  56px   11 of 12 fit — 7 on the button seat
  Galaxy S8 12-handed   name box  34px    7 of 12 fit — 1 on the button seat
  iPhone 14  9-handed   name box  68px   11 of 12 fit — 7 on the button seat
  desktop    9-handed   name box 105px   12 of 12 fit — 11 on the button seat

Four names in twelve, on a phone, cut on one seat and whole on the other eight.
And the button is the one marker on the felt that moves every hand, so that is
not four unlucky players — it is every player in turn, once a round, all night.

The chip now hangs off the same edge as the badges, at the same 5px, at the
other end of it. That distance is not free — SEAT_BADGE_OVERHANG_PX charges
every chair on the ring for it — but it is already spent, and it is charged
once per seat rather than once per marker, which stays right precisely because
the two markers share an edge and sit at opposite ends of it. So the ring does
not move at all. 12px is what fits between -5px and the top of the name on the
tight ring, and it is enough: a finger never reaches this chip anyway — on
touch the ghosts are pointer-events: none and the live one is inert, so giving
the button is the seat's long-press menu, and what is left is a mouse on a
ghost that only appears under the pointer.

Round where the badges are pills, and on the right where they are on the left,
because the button's gold and the big blind's now share one edge: what tells
them apart is the shape and the side, not the colour.

--

The other half of this was a name that could not be trusted to be one line.

The label was a `-webkit-box` clamped to one line, which hides the lines past
the first — but not the part of the second one that the row's own min-height
leaves uncovered. The row is a px token, 15px, "12px of name at 1.2", while
.puckNameTight steps the name down to 10.2px, whose line box is 12.24. That
leaves 2.76px. Measured on a 390px phone: "Christopher" on the button seat drew
as "Christ…" with the ascenders of "opher" showing underneath it as a stray
mark, which reads as a rendering fault on the first seat a dealer looks at.

`white-space: nowrap` with `text-overflow: ellipsis` has no second line to
leak. It also makes the box measure honestly, which the clamp did not: a name
that wrapped reported scrollWidth == clientWidth, so fitSeatNames() had only
ever been seeing the overflow as height — the right answer to the wrong
question, and only right while the row and the line agreed.

1046 tests pass; the 418-audit sweep is clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-10 16:20:37 -06:00
jheapsandClaude Opus 5 8136025544 Say what the undo would take back, and let the empty chairs be empty
Two things on the felt and the bar that were spending weight on the wrong
half of what they had to say.

The undo pill names the last action, and on a phone it had stopped doing
it. It is a fixed width — its text changes on every tap, and a control
that resizes under a thumb reads as the interface twitching — and that
width leaves the label 73px. Measured across one hand, four of the five
labels a hand produces wanted more:

  ↶ dealing the hand        104     ↶ Christopher's fold      109
  ↶ Mike from work's fold   128     ↶ Alexandra's fold        100
  ↶ Dee's fold               69  (the only one that fitted)

so the safety net spent most of a night reading "↶ dealing …". Cut as one
string it keeps the wrong half: "↶ Mike from wor…" names everybody except
the action. So an action's name is handed to commit() in two halves now —
who it was, and what they did — and the actor comes off whole when the
act cannot fit beside it. The whole sentence stays on the button's title
and aria-label, which is what the guidance asks of a control that has had
to shorten its own name.

Two things fell out of it. The label cannot overflow — the act inside it
is the item with `overflow: hidden`, so it swallows the excess and the row
measures as though everything fitted — which is why the fit asks the act
and not the label; asked the label, "↶ dealing the hand" kept its actor
and quietly cut "the hand" off inside it. And the pill was still printing
a keycap on phones: every other control on the bar has dropped its cap
under `hover: none` since that rule was written, but this one is a
different class and had been left out of it, so the narrowest label on the
bar was the only one spending 22px on a shortcut nothing could press.
That 22px is exactly what "raise to $1,200" needed to keep its digits.

Nothing is cut now at any viewport in the sweep: a phone reads "↶ the
hand", "↶ Dee's fold", "↶ raise to $20", "↶ call"; a desktop and the
landscape rail keep the actor wherever it fits.

And the empty chairs. An open seat was drawn as a whole puck of near-black
on the green — the same box a seated player gets, at the same weight,
saying nothing. Five players nine-handed is four of them, and on the felt
they read as holes punched in the table: the eye had to sort the loud
empty boxes from the loud full ones before it could find who was in the
hand. The wash was there for one reason, which is that the chair number's
contrast cannot depend on whether its chair sits over the rim, the dark
green or the light — about Lc 5 across one ring. So the wash moved off the
chair and under the number, which is the only thing an open chair has to
say. The dashes still say "open", the box a thumb lands on has not moved a
pixel, and the felt gets four boxes' worth of quiet back.

1039 unit tests; 418 layout audits across 13 viewports, clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-10 14:13:25 -06:00
jheapsandClaude Opus 5 d4c39f209a Follow the browser's text size instead of sliding out from under it
Every length in this stylesheet that isn't a structural clearance is in
`rem`. That is the right way round — it means the page follows the
browser's font size, which is what a phone's text-size slider changes —
and it means there was a whole axis of behaviour that 320 audits had
never once touched, because every one of them ran at the default 16px.

The sweep gets a third pass: the same surfaces on the eight phone-shaped
viewports at 20px of root text, which is 125% on an Android slider.
Everything it found on its first run was caught by a check that had been
sitting there for months with nothing to say.

The page slid sideways. Three header buttons that will not break and will
not shrink — `white-space: nowrap` and a `min-width: auto` floor apiece —
were told `flex-wrap: nowrap`, so when they stopped fitting they had
nowhere to go: "Resolve Pot" ended 23px past the right edge of a 360px
window and put a horizontal scrollbar under the whole document; at 24px,
60px past the edge of a 390px one. They take a second row now on the day
they need one, and still fit on the first and still grow into it at any
ordinary size.

Figures painted over the seat next door. Which of the two type steps an
amount got was decided by counting characters — eight for one, ten for
the other — which is a guess at how wide a figure is that happens to hold
at one text size and one puck width. At 20px on a 44px puck, "$1,200" is
six characters, took no step at all, and wanted 38px of the 34 it had.
Render.fitAmounts measures now, in the batched passes fitSeatNames
already uses, and takes the separators out before it takes a step of
type: "$1200" at full size beats "$1,200" at three-quarters of it.

And the pucks grew, which the ring cannot answer. The row heights are px
tokens so an amount stepping its type down shortens its digits and not
its row — but a token is a floor, not a ceiling. At 125% the puck went
from 55px tall to 65 and twelve of them came down on each other, because
at the 44px floor a thumb needs there is nowhere narrower to search. The
two sizes a puck is written at are capped at exactly the sizes they have
always been drawn at, so nothing changes for anybody at an ordinary text
size — and the felt is a diagram, so the rule that already governed a
puck's width now governs its height. Roomy pucks are exempt; they have
the room. The same figure is in Standings in full, at whatever size its
owner asked for.

Two smaller things on the way. The landscape rail opened at its top, so
its overflow came off the bottom — where Cancel and Raise to are; it
opens at the end now, which is the same reason it is anchored low at all.
And the check that called those buttons unreachable was wrong: the rail
scrolls on purpose, and a sweep that cries wolf is a sweep nobody reads.

418 audits, clean. One shape it is not held to, with the arithmetic in
runUiAudit.js: twelve players all-in on a 360x640 phone leaves the felt
290x163 once the bar has taken 283px of the 640, and twelve 44x62 pucks
do not fit around an ellipse 163px deep. Reported as a note every run
rather than a failure every run. 1026 unit tests.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-10 13:40:44 -06:00
jheapsandClaude Opus 5 42b36a1464 Stop the bar dropping the one word it exists to say
The acting line names whoever the table is waiting on, and on a phone it
had stopped doing it. The name was set up as the half that gives way —
right rule, no floor under it. Flex shrinks the only item that can shrink
until the line fits, and with the clock button on the line there was no
fitting to be had: measured with five players nine-handed, mid-street,

  360x640   "Christopher" wanted 62px, drawn 0.  "Alexandra" 54 -> 0.
  390x844   "Christopher" wanted 62px, drawn 0.  "Alexandra" 54 -> 0.
  430x932   "Christopher" 62 -> 31.

What the bar read on the first two was "· $1,200 behind" — a dangling
separator and a figure, at the moment the dealer is looking at it to say
whose turn it is out loud. The ellipsis the rule was written around never
appeared; there were no characters left to put one after.

The rule stays: a number read wrong is worse than a name read short, so
the stack is never truncated. It comes off the line whole instead, and
it is not lost — the same figure is on that player's own seat puck.

The stopwatch pays for the rest. It was on the line from the first second
of every decision, reading "0:07" in grey: 50px of the narrowest row in
the tool spent on a number nobody acts on. It is a prompt, not a readout,
and it now arrives when the button that acts on it arrives — the same
thirty-second threshold, asked once.

  360x640   "Christopher" 62 -> 44 ("Christo…"), "Alexandra" 54 -> 44
  390x844   both whole, where both were nothing
  430x932   both whole
  and "Mike from work" whole at 360 where it was cut to 68

with the bar 151px and the felt 309x463 either side of the thirty-second
mark, and 0px of seat movement across it.

Also: the space before the separator. These are two flex items and a flex
item's own leading whitespace is collapsed at its edge, so the bar has
read "Alexandra· $1,500 behind" since the sentence was split in two. The
gap is in the stylesheet now, where it can survive that.

1018 unit tests; 320 layout audits across 13 viewports, clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-10 12:43:12 -06:00
jheapsandClaude Opus 5 ab04c43ffd Stop the table resizing under the thumb that is reaching for it
Two things moved the felt, and neither of them was the felt's business.

The raise pad. It is 57px taller than the row it replaces, and the table was sized
against it — so pressing Raise shrank the whole thing, re-ran the ten-candidate width
search, redrew every seat at a different size, and undid all of it when the dealer
pressed Raise to. Measured, five players nine-handed:

  1280x900   felt 738x532 -> 669x481, pucks 120 -> 109, worst seat moved 73px
  360x640    felt 283x329 -> 283x279, pucks  44 ->  48, worst seat moved 50px
  390x844    felt 309x463 -> 309x451,                   worst seat moved 11px

Every raise, all night, at the one moment the dealer is reading the felt to decide
what to type. A phone held sideways already scored 0px: the rail is a column down the
side, and its height never was the table's business. So the felt is sized against the
bar at rest and the pad floats up over the bottom of it, which is what a transient
surface is supposed to do — the context it is over stays where it was. --actionBarHeight
is untouched and still says what is really covering the window, because that is what the
toast has to clear.

And the status row. It wraps, and flex settles its line breaks before it decides what
to shrink, so a name too long for the room left beside the clock and the undo pill did
not give way — it pushed them onto a third line. That is 22px off a felt underneath a
bar fixed to the bottom. Measured at 360x640 mid-street: "Dee · $600 behind" left the
table 283x329 and "Christopher · $1,200 behind" made it 283x310, with every seat moving
24px between one player and the next. Whose turn it was decided how big the table was.

An earlier round saw this and reserved two lines for the row; three is what it takes.
Reserving three would spend the 22px on every table for the sake of some of them. So
the actor, the clock and the undo pill get a line of their own that cannot wrap, and
the name shrinks into it — with the stack held out of the shrink, because a number read
wrong is a worse failure than a name read short. The row is 66px whoever is acting.

  It claims that line only when there is somebody on it. Claiming it unconditionally
  cost one in the state that can least afford it — a board running out has nobody to
  act, so the row is the undo pill alone, and it used to sit up beside "All-in —
  running out the board".

  The pill gives width back to the name on a phone: 10rem is 160px, and at 360 it took
  half the row, leaving "Christopher" cut to 23px of its 75. A media query, not
  `min(10rem, 34%)` — that was tried, and a percentage against a row sized to its own
  contents chases itself: the sweep caught the pill drawn 38px wide at ten viewports.

Verified by walking a hand at five viewports: one felt size, one puck width, and not
one seat moving, across a raise pad opened and shut and a full street of action.

1003 unit tests. 320 layout audits across 13 viewports with a mouse and a thumb, and
this is the first round in a while with nothing in them — the chip behind seat 11 on
the twelve-handed Galaxy S8, which master has carried for rounds, clears too.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-10 11:48:04 -06:00
jheapsandClaude Opus 5 4a2ec1295b Make every seat on the ring the same seat, and measure it before believing it
Nine chairs, and until this no two of them were the same size. Measured at 1280x900
with five players seated: 64px for Bob, 70 for Christopher because his name wrapped,
71 for Alexandra because she had chips out, 89 for "Mike from work" who had both.

The ragged edge was the smaller half of it. The larger half is that the sizes moved.
A puck grew 17px the moment its player put money in and shrank again when the street
closed, so the felt reflowed twice a hand under the thumb of someone trying to tap it
— and because the ring is sized against the tallest puck on it, the whole table could
resize with it.

Three rows are held open instead: the name, the amount, and the stack. The name gets
one line whatever it says, the amount pill keeps its row when it is empty rather than
collapsing, and each row is a token rather than an em so an amount stepping its type
down to fit shortens its digits and not its row. What the puck says changes all night;
how big it is does not.

It costs nothing. measuredSeatBox already took the tallest puck on the ring, on
purpose, so the room was reserved at every seat and the short ones simply weren't
drawn into it. What pays for the third row is the second line of name going away: an
earlier round measured uniform-with-two-lines at 88px and rightly refused it.
Uniform-with-one is 71.

  A name with no word to drop steps its type down instead of ellipsising. fitSeatNames
  turns "Mike from work" into "Mike", which is what the table calls him, and could do
  nothing at all with "Christopher" — so that one was cut to "Christo…" on a felt that
  may also have a Christine at it. The amounts on the same puck have never taken that
  trade; this is fitAmount's rule applied to the other thing a dealer reads across a
  table. One step, floored at 9px, then the ellipsis.

  The float that kept the button seat's name off the dealer chip goes with the second
  line — three rules of it, indenting the first line so a long name could drop below
  the chip onto a second. There is no second line to drop to, and left in place it drew
  "Christopher" as an empty label. The padding every crowded ring already used is the
  rule at all sizes now, widened from 22px to 25 so it actually covers the chip and the
  gap it is inset by.

Then the part that was quietly wrong. renderSeats builds nine empty pucks and fills
them afterwards, so the layout search ran against blank boxes and cached the answer
under a key naming a table it had never measured. On a 390px phone that is a ring
sized for empty seats — and the comment where it was cached said "let the resize that
follows do this properly". Nothing resizes a phone. Turning the phone sideways and
back was the only thing that ever fixed it, and it fixed it into a different layout:
same viewport, same table, 90px pucks on load and 72px after a rotate.

So the finished pucks are measured against the box the search settled on, and if they
disagree the search is asked again with the real thing in front of it. Once, and the
second answer is cached. Fresh load and rotate-and-back now agree exactly.

That also lets the cache key shrink to almost nothing. It used to name every player's
name, the dealer's seat, whether anybody had cards and whether anybody had chips out —
four things, three of which flip during an ordinary hand, each flip a ten-candidate
re-search. None of them can change a puck's height any more. Measured over three calls
in a row: no re-search, and not one of the nine seats moved a pixel.

The crowded ring gives up the 1.4 leading on its money rows to pay for the row it now
holds open — 10px of figures is 12px of ink and the rest was blank. Without it the
Galaxy S8 twelve-handed, where the pucks are already at the 44px floor, put
neighbouring seats' blind badges down onto each other's "ALL IN". Hanging the badges
further out was tried first and is the wrong direction: the badge landing on a stack
figure belongs to the seat next door, so every pixel further out is a pixel further
onto a neighbour.

992 unit tests. 320 layout audits across 13 viewports with a mouse and a thumb: one
failure, the chip behind seat 11 that master already carries.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-10 11:08:14 -06:00
jheaps 3326557043 Merge pull request 'Hold'em: give the seat holding the dealer button its own middle back' (#121) from fix/holdem-dealer-chip-eats-its-seat into master
Build and Deploy / build-deploy (push) Successful in 1m12s
Reviewed-on: #121
2026-09-09 15:18:47 -06:00
jheapsandClaude Opus 5 edf159325b Give the seat holding the button its own middle back
On a fresh table the button is on seat 1, and seat 1 is where the first player
goes. Tapping it did nothing.

The dealer chip is a 22px disc, and a 22px disc is half the 44px a fingertip
needs, so it carried an invisible pad to catch the tap. The pad was moved twice
looking for somewhere to grow that wasn't already spoken for: outward first,
where it hung over the seat next door and gave that seat the button; then
inward, over "its own seat, which is a large target that can spare the corner".

The seat could not spare it. Measured on a 390px phone, a 72x46 puck: the pad
ran from the puck's own centre to its right edge and from its top to 3px off its
bottom, and the chip's handler calls stopPropagation. The seat holding the
button kept 52% of its face — 130 of 276 sampled points went to the chip, its
exact middle among them. Every other seat kept 97%. The button moves every hand,
so the dead puck went round the table with it.

And the chip it went to had nothing to say. Its only answer is "give the button
to this seat", and this is the seat that has it. The seat menu already knew that
and hides "Give the button" there; mid-hand the same no-op would have taken the
blinds off the felt and re-posted the same chips to announce that nothing
happened.

So the pad is gone rather than moved again — a fixed pad cannot be safe at every
puck size, and no finger reaches any chip now: the ghosts are pointer-events:
none without hover, and the one live chip is this one, which is now inert to the
pointer and still there to read. Giving the button is the long-press menu on
touch and the revealed ghost on a mouse, both unchanged. makeDealer returns
early on the seat that already has it, so the keyboard can't re-post blinds to
move the button nowhere either.

The sweep had a check for exactly this — "a seat's centre belongs to that seat",
written when the pad was reaching outward — and it ran clean through all of it,
because `seat.contains(hit)` waves through anything inside the seat and the chip
is inside the seat. The check written for this bug could not see the second half
of it. A control inside a seat is a rival for the tap now, and against the old
stylesheet it fails seven scenarios.

982 unit tests, 6 of them new; the layout sweep is 320 audits with the one
pre-existing failure (a $1,300 chip behind seat 11 on a twelve-handed 360x640
all-in).

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-09 15:10:55 -06:00
jheaps 6e7b047d3e Merge pull request 'Hold'em dealer: a table that reads its own showdown, and a tool that is there with no signal' (#120) from polish/holdem-game-night-ready into master
Build and Deploy / build-deploy (push) Successful in 1m14s
Reviewed-on: #120
2026-09-09 14:23:50 -06:00
jheapsandClaude Opus 5 171e878262 Give the felt back the sixth of the phone the title was holding
The header stood 120px tall on a 390x844 phone and only 46 of that was a
control. The other 74 were a 22px page title with a gold rule under it, a back
link, and a gold "Hand #1" pill — three things read once, above a screen whose
whole subject is the green table underneath. Sixteen percent of the window,
spent before the felt started.

A phone held sideways had already solved this. The landscape block clips the
title and puts everything on one row, because at 390px tall there was no other
option. Upright never got the same answer, and upright is how this tool is
held. It is 44px now, the table starts at 78px instead of 152, and the felt
went from 496px to 570 — about fifteen percent more table on the same screen.

Nothing left the page:

- The title is still the document's <h1>, clipped rather than removed, so it is
  still the heading a screen reader lands on and still what the browser tab and
  the installed app are called.
- The back link keeps a 44px box and loses only the word "Casino", which is what
  every phone does with a back link. The name a screen reader gets is on the <a>
  either way, and it contains the visible word on the sizes that still show it.
- The hand number moved to the action bar's status row, in front of the street.
  That row already says what hand you are in — the street, the pot, the player
  count — and this is the fourth fact of the same kind; a counter read after the
  fact had no business holding a line of the header all night. It costs the bar
  nothing: the five audit scenarios that report the bar's height report the same
  249, 227, 203, 227 and 227 pixels as before.

The three buttons take the width the title gave up rather than shrink-wrapping
to the right of it, which is 26px of extra target each. `flex: 1 1 0` was wrong
here — it divides the row into equal thirds and squeezes "Next Hand ⟳", 105px of
text, into 99, and these are nowrap, so what gives is the label. An `auto` basis
makes the label the floor and the spare width the bonus. On the narrowest phone
in the sweep the arrow and all three buttons come to 323px of 336.

The arrow was 32px on the first pass and the layout sweep caught it at 31x43 on
every touch viewport there is — 56 scenarios, one control. The word had been
carrying the width; with the word gone the arrow is the whole target. 44 square.

The sweep is down from five failures to one. Four of the five were seats and bet
chips colliding on a 360px Galaxy S8, and 76px more felt was all they needed.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-09 08:11:21 -06:00
jheapsandClaude Opus 5 0f7213ec2d Open a seat on what is on it, and put the sheet in front of the table
The seat sheet is the second-most-used surface here — every buy-in, every
rename, every correction goes through it — and it was the least finished one.
Seven things were stacked flat at one weight and all open at once. Measured on
a 390px phone it came to 594px and scrolled, and the three controls a dealer
touches on nearly every visit were 132px of that. The chip counter alone was
174px, the tallest single thing in the sheet, for a job most tables do once a
night or never; the two hole-card slots were 62px of dashed grey on every table
that never asked to be shown a card.

Cards, the contribution and the custom fields are behind a fold now, and which
of the three stand open is a question about the seat rather than a setting: a
seat holding cards opens its cards, a seat with chips counted opens its count,
a seat with a note on it opens its notes. A shut section says the answer on its
own row — the cards, the total, how many notes — so nothing already entered is
further away than it was. An empty chair is 419px and does not scroll; a seat
with everything filled in is 625 and opens all three.

The fields have the width the sheet always had. `.fieldRow input` caps at 140px,
which is right in a 320px sidebar of settings and wrong in a sheet holding a
whole phone: "Name" sat against the left edge with 203px of nothing between it
and a field a third of the row wide. Same 44px control, 300px instead of 140.

The chip counter's values are text. They were a `title` attribute, which is a
tooltip, which is nothing on the device this is used on — five identical number
boxes told apart by five coloured dots. It also left the wrapping <label> with
no text in it, so they had no accessible name either.

And the sheet has a scrim. It was the only one of the tool's seven overlays
without one, while covering the felt from 250px down and the action bar with
it — modal in everything but appearance. What stayed lit above it was the
header, with Misdeal, Next Hand and Resolve Pot live under a thumb reaching for
a name field. It dismisses on a tap and on Escape now, like the other six; a
card picker opened from inside still takes Escape first. In landscape the scrim
stops where the sheet stops, so Fold, Call and Raise keep the rail.

It is put away with visibility rather than opacity, which is the same lesson the
sheet above it already learned about transforms — and the layout sweep caught it
the same way. At `opacity: 0` a fixed, full-window box is invisible and still
entirely there: bottomFurniture() read it as furniture standing 640px tall at
the bottom of a 640px phone, in every state and on every screen, and asked
scroll-padding to reserve the whole window for it. Ninety-eight scenarios failed
on it; hidden is hidden, and the sweep is back to the five it started with.

The toast steps over the sheet the way it already steps over the action bar.
Both are fixed to the bottom of a phone, and a "Keeping the screen on" toast
was landing squarely on the seat form with its Undo and its × live above the
fields. The height is measured and published, because it changes the moment a
section is unfolded.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-09 07:29:03 -06:00
jheapsandClaude Opus 5 853fcedea0 Measure the text the way an eye reads it, not the way a ratio scores it
The sweep has checked WCAG AA contrast for a long time and the page passed. That
result was worth less than it looked.

WCAG 2's ratio is a luminance gap, and a luminance gap overstates readability once
one colour is near black — which is every reading on a page whose ground is #141414.
Tuning a colour until it clears 4.5:1 there lands on text that passes the audit and
still reads as a smudge across a table in a dim room. Several rules in this
stylesheet said as much in their own comments, one of them "measured 4.5:1 at
#a5a5a5 exactly, so it sits above that": the right instinct through the wrong lens.

The check also could not see half the backgrounds it scored against. It reads
background-color and takes the best contrast of any candidate ancestor, so text on
the felt was scored against the dark page behind the felt. Two plain AA failures sat
in a clean report for as long as the check has existed — the chair number on the
brown rim at 2.65:1, and the pot's own label, gold on green, at 2.66:1.

runContrastAudit.js scores with APCA and gets the background by screenshotting the
page with its text made transparent and reading the pixels, which no gradient or
shadow can fool. The first version walked the DOM and called the gold dealer button
black-on-black at Lc 0.4; that false positive is why it works this way.

Found: 65 of 92 pieces of text below their level, 54 passing AA at the same moment,
worst Lc 28. Now nothing is below Lc 60, nothing fails AA, and the worst is Lc 60.4.

Three fixes worth naming, none of them "make the grey lighter":

- A player's stack was secondary text at Lc 44, under a name in --text at a heavier
  weight. That says a dealer needs the name more than the number; during a hand the
  opposite is true. Hierarchy comes from size and weight now, which is free, rather
  than from fading, which on a dark ground is the most expensive way to say it.
- An empty seat had no background of its own, so the felt decided how readable its
  number was: Lc 28 on the rim, 30 on the green, 33 further in. It has a ground now,
  at 0.62 and not the 0.82 first written — contrast is nearly flat across that range
  and the heavier wash put four dark rectangles on the felt like holes.
- The pot's label sat on the one green in the design. The disc is deeper rather than
  the label bleached, because gold on dark green is the pairing this page is built
  on. Deepening only its outer stop moved it two points: the highlight is at 32%/28%
  and that is exactly where the label sits.

apca.js is checked against the eight published reference pairs, png.js against
round-tripped fixtures for every filter. Four fault injections caught; a fifth
turned out to be a no-op — the Paeth predictor is symmetric in its first two
arguments over all 16,777,216 byte triples, so that swap is untestable, which is
written down rather than papered over with a fixture that appears to catch it.

Still below the strict APCA level: 55, all of them 10-12px labels that would need
white type or a bigger seat puck. Reported, not chased, and said out loud in the
sweep's own notes.

Layout sweep unchanged: 5 FAILs, 14 offender lines, no regressions. 950 tests pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-09 06:40:28 -06:00
jheapsandClaude Opus 5 295d9eff4a Stop a scroll being an edit, and a stack changing without being touched
A focused <input type="number"> is a spinbutton, and a browser lets the mouse
wheel drive it. Measured on the running page at 1280x900 with the sidebar
open: focus the small blind and scroll, $5 becomes $5.50. Focus a seat's stack
and scroll, $500 becomes $501. Both go straight through to localStorage,
because these fields save on `input` like everything else here.

Nothing is announced and nothing looks wrong. The tool has invented a chip, on
a table whose whole purpose is that the chip count is right, and the gesture
that did it was a dealer reaching for another panel.

The rule is narrow on purpose. Every other way of driving a spinbutton is
deliberate -- the arrow keys, the stepper arrows, typing -- so scrolling is
the only one taken away. Verified after: the value holds, one ArrowUp still
moves it, typing still works, and the page still scrolls.

Blur rather than preventDefault. Cancelling the event stops the value moving
and stops the page moving with it, which trades a silent bug for a sidebar
that mysteriously will not scroll -- a problem the dealer can see and cannot
explain, instead of one they cannot see at all.

The decision lives in Money as a predicate beside selectsOnFocus, so the rule
is testable without a browser and main.js only does the blurring.

Two things checked rather than assumed. The tagName guard in the predicate is
decoration: deleting it passes every test, because nothing but an <input>
carries type="number". That is now said in the comment instead of being left
to look load-bearing. And the page's one <select> -- which way the blinds move
-- is not vulnerable: focused, hovered and scrolled 300px in Chromium it stays
where it was, so it is left out of the rule rather than defended against.

916 tests pass, 6 of them new. Four fault injections into the predicate are
caught; a fifth was applied, not caught, and turned out not to be a fault.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-09 05:46:20 -06:00
jheapsandClaude Opus 5 fafbd8df32 Stop the things that actually move for anyone who asked them not to
The stylesheet had honoured prefers-reduced-motion for a while, in the three
places it mattered least: a 2px press-down on a button, a checkbox tick, and
the acting seat's pulse. What none of them covered was motion that moves
something across the screen, which for anyone with a vestibular disorder is
the part that matters. A button dipping under a thumb is not what makes people
ill.

Four things were doing it, and the three largest were in JavaScript where a
stylesheet cannot reach: the seat editor sheet, which slides the full height
of itself up from off-screen every time a dealer taps a chair; the toast; and
three scrollIntoView({ behavior: "smooth" }) calls, the most provocative of
the lot because what moves is the whole viewport rather than one control.

The scrolls go through Render.bringIntoView now, which reads the setting per
call rather than at page load -- a dealer who turns it on mid-session should
be taken seriously without reloading a table they cannot afford to reload. The
toast keeps its fade and loses its travel, because something that appears
already in place reads as a rendering glitch rather than as news. The sheet's
visibility delay flips at once, or it would leave a sheet that has already
jumped away still painted for a quarter of a second.

The press-down block held nine :active selectors and looked deliberate. It was
written to stop a stuck press state when a tap turns into a scroll, and had
reached for the reduced-motion query as the nearest proxy -- so it covered
.dealerChip and .raiseQuickBtn while missing .actionBtn, the class on Fold,
Call, Raise and Post blinds & deal. Read as its own rule the membership is not
a judgement call: every :hover and :active transform in the file belongs
there. There are sixteen. Nine were listed.

The scan that found the other seven took two goes. A line-by-line version
walked straight past a deliberately broken build, because the injected rule
was written on one line and neither "opens a rule" nor "is a transform"
matched. Parsing blocks caught it -- and then failed on the clean file, which
is how .toastAction, .toastClose and .potWizardChoice came to light: a
multi-line group had been truncated to its last line, so nothing had ever
looked at them.

910 tests pass, 16 of them new. Six script and stylesheet fault injections are
each caught, and the reduced/no-preference split is verified in a real browser
against both context settings.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-09 05:21:07 -06:00
jheapsandClaude Opus 5 0d878a176d Say in the sweep's own notes where the last five failures actually live
315 of 320 audits pass. The five that do not are one root cause on the two
smallest screens: twelve seats want about 60px of footprint each and the ring
has 44px of rim to give them, in the two states that make the bar tallest.

Written down rather than left to be rediscovered, because closing it is a
design decision -- drop the bet chips at extreme crowding the way the tight
tier already drops the hole cards, cap the seat count by viewport, or let the
felt scroll -- and each trades something a dealer can see.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-09 05:03:33 -06:00
jheapsandClaude Opus 5 7216f3da4d Stop a wrapped label costing twelve seats their room
The sweep had carried the same residual for rounds: on the two smallest
phones with twelve players, seats painting over each other's names and
stacks. It read like a geometry problem. It was a wrapped label.

"ALL IN" is the only thing that ever lands on the stack line with a space in
it. A stack figure has no spaces, so it can only overflow, and the
puckAmountLong/Huge sizes exist to stop that; nothing had asked what the line
does when it can break. Measured at 360x640 with twelve players all-in, it
wanted 36px and had 34, broke onto a second line, and an 11px line became 22.

That is not a typography complaint, which is why it survived: it looks fine.
The puck went from 55px tall to 66, and puck height is what the ring is short
of -- at the ends of an ellipse consecutive seats are separated vertically, so
a puck 11px taller is a puck 11px further into its neighbour. The layout
search then did exactly what it is written to do: tried every width it has,
found none that cleared, and fell back to the narrowest, which is also the one
that wraps hardest.

So the rule every design system gives a badge applies here too: one line,
always. Not wrapping is only half of it -- a line that cannot wrap and still
does not fit paints over the seat next door instead -- so on the tight tier
the mark gives up its letter-spacing, 3.6px of the 36 it wanted, and keeps the
warm colour and the bold weight that do the same job.

Halving the overlap did not clear it, and the rest of the shortage was not on
the felt. The sweep had been saying so in a warning rather than a failure: at
360x640 the bar was 267px of a 640px window, 42%, against its own "persistent
chrome under 30%". Two things in it were being shown when they could not be
used. The side-pot list is decision support, and during a run-out there is no
decision to support -- it collapses to the main pot and one line for the rest.
The status row said the player count twice in the same breath, as a chip and
again inside the run-out sentence beside it.

Measured: bar 267 -> 249 on a Galaxy S8 and 267 -> 227 on an iPhone SE, felt
243 -> 283 there, worst seat overlap 34px -> 4px. Across the sweep, overlap
offender lines 33 -> 9, chips behind seats 30 -> 5, failing audits 6 -> 5, and
the iPhone SE's seat-text overlap gone entirely. No new failures.

894 tests pass, 9 of them new. Seven fault injections are each caught.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-09 05:01:56 -06:00
jheapsandClaude Opus 5 e2bb935b43 Be there when the signal, the screen and the storage are not
Three failures that only ever happen at a table, none of which show up on a
desk with a good connection and a plugged-in laptop.

The offline promise depended on how the URL was spelled. Razor Pages routing
is case-insensitive, so /Casino/TexasHoldem serves the identical page; a
service worker's scope is matched as a plain string prefix, so a worker scoped
to /casino/ does not control it. The registration succeeded, the worker
activated, nothing was logged, and the cache stayed completely empty --
because the precache list was handed to navigator.serviceWorker.ready, which
resolves with the registration controlling the page and so never resolves at
all outside its scope. A dealer who opened that URL to load the tool for the
night had loaded nothing.

One spelling wins now, via a 301 scoped to /casino rather than applied
site-wide: every file under wwwroot/casino and wwwroot/images/casino is
already lowercase, so lowercasing there cannot miss a real file on a
case-sensitive filesystem. That guarantee does not hold for the rest of the
site, and /chessHub would break. The handshake is fixed rather than left to be
saved by the redirect -- it takes the worker off the registration itself.

The manifest icons were never cached, because the shell list is read off the
DOM and the DOM never mentions them. They are read out of the manifest now,
cache-first so an offline launch stops logging ERR_INTERNET_DISCONNECTED, and
the link sweep picks up apple-touch-icon, which is the only one iOS reads.

The screen slept mid-hand. A wake lock is held while betting is open and
dropped the moment it is not: through a whole street of renders it takes
exactly one, two syncs in a tick take exactly one, a hand that ends inside the
request leaves nothing held, and the browser taking it back leaves us able to
take it again. Announced once with a Turn off button rather than a permanent
header indicator; a setting after that.

And localStorage is evictable, which the Save & reset panel now says out loud.
persist() is asked for on Post blinds & deal, because every browser wants that
request to come from something the dealer did.

885 tests pass, 18 of them new. Five fault injections into the wake lock are
each caught. One of the new tests was green for the wrong reason first:
globalThis.navigator = fake does nothing, because Node has defined navigator
as a getter since v21.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-09 04:31:45 -06:00
jheapsandClaude Opus 5 75ec028c3e Ask for the hands at the showdown, the way the streets are already asked for
The card feature had two halves and only one of them ever asked a question. Turn the
setting on and every street opens a sheet for the cards coming off the deck — that
worked, on a phone and on a desk, in a normal hand and in an all-in run-out. The
other half never asked at all. A dealer who entered all five community cards, pressed
"Showdown — resolve pot", and got a wizard saying

    Christopher, Mike from work, Alexandra and Bob haven't turned over, so who
    won is still yours to say.

with nothing in that sheet to turn anybody over with. The only route in was to close
the wizard, tap a seat puck, find the two card slots in the seat editor, do it four
times and re-open the wizard, with the pot on the table and four people waiting. The
one moment the tool is certain to know the hands is the moment it stopped asking.

So the showdown button asks. The sheet opens on whoever the bar has just named as
showing first, takes their two cards, and moves round the table from there; hands
already entered are skipped, because a hand entered is a question already answered.
Four sheets later the wizard opens reading "Chris wins $40 with three of a kind,
nines" and its button says "Pay Chris" instead of "Confirm Winner(s)".

Leaving the sheet stops the asking rather than skipping a player: closing it says the
tool has the wrong end of the stick, and the answer to that is to get out of the way.
It isn't a dead end — the wizard still names whoever didn't turn over, the bar still
says "Showdown — resolve pot", and pressing it again resumes at the player the dealer
stopped on, because the list is read off the cards on the table rather than remembered.

CardPicker.close() takes a reason so the chain can tell "that sheet filled its last
slot" from "the dealer left". Two callers pass nothing and don't care.

The setting kept its behaviour and lost its jargon: "Track the board and read the
showdown" was the feature's name rather than what turning it on does to your evening.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-09 04:10:53 -06:00
jheapsandClaude Opus 5 e90bcccada Spend the accent on the one thing to do next, not on two at once
The green is a promise: this is the step. Measured over a whole hand — seventeen
steps — it was on two controls at thirteen of them, the bar's Call and the header's
Resolve Pot painting the identical gradient at opposite ends of a phone, and on the
header alone at the other four while the bar held the only button there was to press.
At showdown the most consequential tap in the hand, "Showdown — resolve pot", was
blue-grey while the corner shortcut was green.

So the accent moves to .actionAdvance, which is always the next step by definition,
and comes off the header shortcut, which never is. Twenty-two measured steps of a
real hand now show exactly one.

The sweep checks it: oneNextStep() counts the accent-coloured controls that are
actually on screen, scoped to an open sheet when there is one. Six broken versions
were run at it, and the two that took three goes to catch were both the same mistake
in the injection rather than a hole in the check — a button painted green with the
panel shut isn't rendered, and one painted green below the fold is deliberately
ignored. Scrolled into view it is named alongside the bar's Call, and goes clean
again when the green comes off.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-09 04:10:53 -06:00
jheaps 14fb68dc04 Merge pull request 'Hold'em dealer: making the table and the bar fit the screen they are on' (#119) from polish/holdem-round-next into master
Build and Deploy / build-deploy (push) Successful in 1m40s
Reviewed-on: #119
2026-09-08 19:08:12 -06:00
jheapsandClaude Opus 5 4a1cb842cb Say on each shut sidebar panel the one thing you'd open it for
The sidebar is eight <details>, and every one measured exactly 60px shut with
nothing on it but its own label — 480px of a phone's screen, and 480px of a 660px
desktop column, spent saying "Standings", "Hands", "Waiting list". Whether the box
balances, whether anyone is waiting for a seat, how many hands have been played:
the page had all three worked out already, and charged a tap for each of them, on
rows two thirds of which were empty.

That is the disclosure pattern's named failure — a header you have to open to find
out whether it was worth opening — and the answer the design systems give is the
same one: put the status on the header. The panel keeps the sentence. "Off by
$300" is the alarm; which seat is holding chips nobody wrote down is a paragraph,
and that is the half that belongs behind the tap.

Two rules, and panelGlance.test.js checks both. A glance appears only when it has
something a dealer would act on — eight rows that always say something are eight
rows that say nothing again, and a row reading "Nobody waiting" all night is the
clutter this went in to remove. And a glance never says something the panel under
it would contradict: it is read off the same state, never off the panel's DOM,
which for a shut <details> isn't rendered at all.

Absolutely positioned rather than floated. A <summary> only draws its disclosure
triangle while it is display: list-item, so it cannot be a flex row; the float
that replaced it kept the row at 60px but aligned to the label's line box, and a
12px string in a box sized for 15px text rode 2.5px low on all six panels. Out of
flow is also the smaller promise — an absolutely positioned box cannot make its
row taller at all.

Three paths deliberately skip renderAll, because it would take the field out from
under whoever is typing in it, and all three move something a glance reports. The
notes textarea, the chip legend's value fields and commitSeatChange each call
renderPanelGlances on their own instead; without that the note you had just typed
was summarised by the note before it until the next tap landed. A space between
the label and the glance, too: the summary is announced as one button, and without
it a screen reader was given "Table Setup9 seats".

Costs 0.08ms of a 4.79ms renderAll on a twelve-handed phone. Four injected faults
in the wording are caught one test each.

glancesFitTheirRows took three goes. The first asked only whether a row had grown
taller than a bare one, and a glance floated back into flow and allowed to wrap
sailed straight through it, because a float does not expand the box it sits in.
The second was caught by the sweep on its first clean run — 94 failing configs, 91
of the offenders this check's own: it takes the shortest row as its idea of a bare
one, and the Keyboard panel is display: none under pointer: coarse, so on every
viewport visited with a thumb the bar was a row that wasn't drawn, and all seven
real rows read as grown. Rows that aren't rendered aren't rows. The four broken
layouts are re-run with that panel hidden and each is still caught.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-06 01:56:58 -06:00
jheapsandClaude Opus 5 e7ddff2fed Let a large felt have larger seats, and the type that goes with them
The felt grows with the window and the pucks did not: 96px was the only
ceiling there was. Nine-handed, the seats covered 19% of the felt on a phone,
11% on a 1280px desktop and 8% at 1440px and beyond — and the stack figure a
dealer reads sat at 10px on a 2560px monitor, the same 10px as on a 360px
phone, well under every published floor for text meant to be read.

Two things stop this costing more than it buys.

Every candidate width is a forced layout. Simply raising the ceiling took the
layout pass from 24ms to 73ms twelve-handed on a desktop, so a screen that
cannot use the wider widths must not measure them: the extra pass is offered
only when the arithmetic already says the seats have room to spare, and a phone
measures exactly the ten widths, in the same order, that it measured before.
Timed with the order alternated so neither carries the warm-up: 10.7ms against
10.4ms on a phone, and +3 to +6ms where the widths are actually used.

And a wide puck on a narrow felt is worse than a small one. The first version
of this let phones through, and a 144px puck on an iPhone's 278px ring is half
the width of the table: six-handed, all six seats sat over the rail by 15px
against 5px before, reading as a stack of cards rather than a table with people
around it. So the ceiling is a fifth of the felt's shorter span, capped at 144.
Five is measured rather than chosen — the shorter span is 233-303px on every
phone in the sweep including sideways and 617-714px on tablets and desktops, so
any divisor between 3.2 and 6.4 separates them — and the test pins both ends.

Above the ordinary ceiling the puck takes the larger type as well, which is the
point of the extra width. Nine-handed: an iPad goes 96px/12px to 134px/15px, a
1440px desktop 96px/12px to 140px/15px, and the seats go from 8% of the felt to
12%. Every phone, at every seat count, is untouched.

The sweep is byte-identical to before this change: same six configurations,
same 63 offenders, empty diff.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-06 00:56:59 -06:00
jheapsandClaude Opus 5 f8ed4abc85 Stop two empty rows in every seat puck setting the size of the table
A seat puck carries the name, the hole cards, the amount that player has put
in, and their stack. Two of those are empty most of the time and both held
their space anyway.

The amount pill was worse than empty space. It has a background, a pill radius
and side padding, so with nothing in it, it painted an 8x11px green lozenge —
measured on seven of nine seats at an ordinary table. One of those is a
component holding its place; nine is a rash, and a dealer has to learn to
ignore a colour that on the other two seats means "this player has money out".

The space was not free either. seatRingRadii sizes the ring from the tallest
puck on it, so two rows nobody had filled were setting how far apart the whole
table had to sit. Across 21 tables — seven viewports, six, nine and twelve
handed — every single puck comes back shorter and six come back wider: an
iPhone 14 nine-handed 78x88 -> 78x63, twelve-handed 44x65 -> 66x55, which is the
one name in the set that was clipped and now is not.

The collapse is only safe because the layout cache moved with it.
resolveSeatLayout measures ten candidate widths, each a forced layout, against
seatShapeSignature() — which listed the names and the dealer seat, everything
that could wrap a name onto a second line. It did not mention these two rows,
because while they always held their space they could never change a puck's
height. Now they can, so the signature names them: booleans over the whole
table, because measuredSeatBox takes the tallest puck on the ring, and because
a per-seat key would be correct and would throw the cache away on every tap.

The ring settles by 24-26px when the blinds go in — and it settled by 23-25px
there before this round too, since the action bar changes height at that moment
and the felt is measured against it. The one place it moves is a place it
already moved.

uiAudit gains emptyDecoration(): nothing on the felt may paint a background or
a border with no content in it. Its one exemption is on the list because the
check reported it rather than because it was predicted — .betChipDisc is a
circle filled with the denomination colour of the chip beside it, so the colour
is the content. Three others were written from memory first and matched no
element on the page at all.

The sweep is unchanged at 6 failing configurations and 63 offenders. All four
table failures are the all-in shape, where every pill is filled and the
collapse buys nothing; the ordinary tables this helps were already passing, so
what moved is room a name has rather than whether anything was covered.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-06 00:09:25 -06:00
jheapsandClaude Opus 5 942919f3f7 Stop the side-pot list growing the action bar over the felt
The bar is fixed over the table, so its height is the felt's budget — and it
carried a pot breakdown that drew one row per all-in with no ceiling. On a
360x640 phone: five players idle put the bar at 151px, six all-in at 303px,
twelve all-in at 411px. That is 64% of the screen, leaving 96px of felt to draw
twelve seats in, and at that size the seats stop being a table: five pairs
collapse onto each other's centres, ten hang past the rail and a dozen labels
sit completely covered.

Past four layers the bar now draws the main pot, one line for everything
between, and the newest side pot. The two ends are the live ones for the same
reason — side pots are paid from the newest back down to the main pot, so the
top layer is the next thing that happens and the main pot is the last, and
those are the two a dealer says out loud while the chips are stacked. The
middle is the rest of that count-down, wanted at showdown, which is the pot
wizard: same builder, no cap, in a dialog that scrolls. The elided line carries
the count and the money, so the visible rows still add to the announced pot.

Nothing in the sweep could see any of this, and the reason is worth keeping.
Every check asks whether an element is covered or clipped; none asked how much
room the covering element had taken. But the deeper miss was the sweep itself —
all 294 audits drove their table with Call and Check, so no table it ever
measured had a player all-in, and the one thing on the bar whose height depends
on the table was empty in every measurement. There is now a fourth table shape,
twelve players shoved in for twelve different amounts, and furnitureBudget()
measures the fixed band against the window: failing over half of it, warning
over the 30% that persistent chrome is usually held to.

Galaxy S8, twelve all-in: bar 411px -> 267px, felt 96px -> 192px, 91 layout
offenders -> 25. iPhone SE: 73 -> 10. The rest is the seat ring on a short
phone, which the sweep can now see.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-05 23:34:51 -06:00
jheapsandClaude Opus 5 02c2a034cb Stop the fixed bar and the toast sitting on the keyboard's focus
WCAG 2.2 SC 2.4.11 asks that a focused control not be completely hidden
by something the page has put on top of it. The bar is fixed over the
bottom and is not in the flow, so the browser does not know it is there:
Tab to a control near the bottom and it is scrolled flush against the
edge of the window, under the bar, and the browser considers it done.

Tabbing the whole page measured 16 stops resting completely covered on a
390x844 phone and 27 more partly, plus four under a toast on the desktop.
It is now 0 and 0 on desktop, phone and a phone on its side — clean
against 2.4.12 (AAA) as well, which allows no overlap at all.

Three things were wrong and they needed different answers.

scroll-padding-bottom on the scroll container, sized from the bar's own
published height and the toast's, handles the case where the browser has
to scroll to reach the control. That is half of it, and the half that
looks like all of it: a control the browser already considers visible is
one it will not scroll to, and a fixed overlay on top does not enter into
that decision. Measured — tabbing to Common rulings with a toast up left
it 100% covered and scrollIntoView({block:"nearest"}) moved nothing. So a
focusin handler measures the overlap and scrolls by it, and when the page
has no room left (26px against 94px wanted) the toast gives way instead.
Sizing the page padding for the toast would have fixed that by making the
page grow and shrink 64px whenever anything was announced.

The page's room to scroll past the bar was 9rem on the sidebar alone,
which left the table's column — most of a desktop — with none. It is now
on the whole app and taken from the bar's real height, so it is also 0 in
the layout where the bar is a rail down the side.

And the seat editor's bottom sheet was put away with a transform, which
moves a thing without putting it away: its Done button sat at y=850 on an
844px screen, focusable, announced, and impossible to scroll to, because
a fixed element does not move when the page does. visibility:hidden puts
it away, transitioned so the slide-out still runs.

uiAudit.js gained focusHasRoom() and focusablesReachable() for the two
static halves. Audit unchanged at 2 failing configurations and 3
offenders, both pre-existing. 805 unit tests pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-05 22:57:58 -06:00
jheapsandClaude Opus 5 cd2db6346e Stop a chip colour taking the table's money with it, quietly
Deleting a chip colour was one click of an unlabelled 24px button, and it
was never a change to a legend. computeContribution() prices a seat by
running the legend over that seat's chip counts, so a colour that leaves
takes every chip counted in it off the table.

Measured in a browser on a three-handed table: deleting the $500 row took
$22,500 of $26,874 with it. No confirmation, nothing said afterwards, and
no way back — a colour added again is a new colour with a new id, while
the counts stay keyed to the one that left. The counts were left behind
too, so the saved file carried money nothing could price and nothing
could show.

Now: a colour nobody has counted still goes on one click, and one that is
holding money asks first, naming the amount, the chips and the seats, and
saying that Undo will put it back while adding the colour again will not.
Conditional on purpose — a confirmation that fires every time is one
nobody reads by the third night, and four others already compete for that
attention here. Removal goes through runUndoable, and the counts go with
the colour so the file matches the screen.

The number in that warning is the difference between pricing every seat
with the colour and without it, not chips times value. The obvious
arithmetic is wrong both ways: a seat with a typed amount is never priced
from its counts, and a seat whose chips all leave falls through to the
table's standing ante rather than to zero. A randomised check over 2,000
tables asserts the warning equals what the table then does, and it found
a third case — fourteen $1 chips against a $200 ante, where removing the
colour puts the table UP $186. The wording says so in that direction too.

The × also had no accessible name, so uiAudit.js gained controlNames():
every control needs a name containing a letter or a digit, resolved the
way the accessibility tree resolves it. A symbol is not a name. Taking
the new aria-label off again makes it report all five buttons.

Audit unchanged at 2 failing configurations and 3 offenders, both
pre-existing. 805 unit tests pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-05 22:26:16 -06:00
jheapsandClaude Opus 5 6eb97c8a35 Give the sidebar eight names instead of five and a cupboard
"Session" was a container word rather than a name. It held the standings,
a waiting list, the hand log and the save/load/reset buttons — a topic, a
task, a log and three utilities under one label, which is a navigation
layer with more than one logic in it.

It measured 1206px and 103 visible elements, against 555 and 29 for the
next biggest, and "The table itself" sat 1052px down inside it. Reaching
Reset table meant opening a panel called Session and scrolling past the
money and the log to get to it.

It is now Standings, Waiting list, Hands and Save & reset, each named for
the thing a dealer came looking for, in the order of a night: set the
table up, watch the money, fill the seats, look back at a hand — and,
last and apart, the two buttons that end it. Reset table goes from 1052px
inside a panel called Session to 62px inside one whose label says what it
does. The biggest panel drops from 1206px to 607px. The four subheads
that used to carry those names are gone, because the summary says it now.

Every panel name is also a heading. A <summary> is announced as a button,
so the sidebar's names — its whole navigation — were unreachable by
heading, and splitting Session would have removed the only four headings
down there. The outline is now h1 page, h2 per panel, h3 for the two
subheads that remain. Visually identical: the h2 gives up everything a
heading brings with it.

The cost is 216px more collapsed summaries at rest, three more one-line
labels. Audit unchanged at 2 failing configurations and 3 offenders, both
pre-existing. 795 unit tests pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-05 21:52:34 -06:00
jheapsandClaude Opus 5 bd8ee11f77 Say who can win each pot in the fewest names that answer it
The pot breakdown listed every eligible player for every pot. Pot
eligibility is nested — the main pot is everyone still live and each side
pot above it drops whoever was all-in below — so the main pot's line was
every name at the table and each side pot repeated it minus one.

The lines too long to fit were exactly the ones nobody needed spelled
out, and the short ones at the bottom were the side pots a dealer
actually announces by name. Measured on an iPhone 14: a single all-in
five-handed already clipped the main pot, and twelve-handed the first row
wanted 521px of a 254px slot. There was no count and no title, so a
dealer reading "Alexandra…" could not tell whether three people or eleven
were eligible — the number the showdown turns on.

Each line now names whichever of "who is in" and "who is out" is shorter,
which nesting guarantees is never more than half the table, and leads
with the count because the line is right-aligned and it is the tail that
gets cut off. "all 9", "8 in, not Mike from work". On a phone this takes
one, two and three all-ins from every main-pot line clipped to nothing
clipped at all; twelve-handed goes 6 clipped rows to 5, and even a cut
row still opens with "9 in".

The wizard's copy of the same announcement wraps instead — it is a dialog
that scrolls, and it is what the dealer reads out before anybody shows a
hand — so nothing there is ever cut off. The full list is also on the
title for anyone with a pointer, which is the extra rather than the
answer, since a tooltip is not a mechanism a keyboard reaches.

potCalculator.test.js now proves the nesting across 20,000 random tables
and asserts on its own generator, so the loop cannot go vacuous. Audit
unchanged at 2 failing configurations and 3 offenders, both pre-existing.
787 unit tests pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-05 21:23:19 -06:00
jheapsandClaude Opus 5 13d704301d Stand the felt up on a phone, and check which rule actually wins
A portrait phone got a square felt, so the vertical room the layout had
already measured was published and then dropped: 522px of room under a
366px table on an iPhone 14, 638 under 406 on a Pro Max. The cost was
seat names — 44px pucks with five of twelve clipped.

--feltAspect: 1.5 spends it. Twelve seats at 390x844 go from a 44px puck
with five names clipped to 66px with one; five seats go from three
clipped to none; the Pixel 7's twelve-seat ring goes to zero. 1.6 and
1.7 measure byte-identical, so 1.5 is where the sweep stops paying. The
three smaller phones are room-bound at any cap and measured identical
from 1.15 to 1.7.

The first version of this was a no-op. The rule went into
@media (max-width: 700px), and @media (max-width: 900px) below it set
the same property back to 1 — both match a phone, same specificity,
later one wins. It measured as 1 on every phone while reading perfectly.
The 900px rule is now a 701-900px range, and feltShape.test.js resolves
--feltAspect through the media queries the way the cascade does so the
next one of these is caught.

Also tried and removed: a floor under the max-height, so a tall bar
couldn't press the table flat. Twelve all in with nine side pots takes
the bar to 341px, leaving 142px of room at 360x640; floored to 242 the
felt keeps its shape by putting five of twelve seats behind the bar,
unfloored it is flat with all twelve visible. The slit it defended
against came from forcing the custom property by hand. A test asserts
its absence with the numbers.

Audit: 4 failing configurations and 5 offenders down to 2 and 3, both
remaining ones pre-existing. 776 unit tests pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-05 21:02:34 -06:00
jheapsandClaude Opus 5 7bd205a91a Line the bar up with the table, and stop the button squeezing a name
Two things that were wrong at every desktop width, both of them the same shape: a
number that was right about one thing being used for another.

The action bar was centred on the window. The table is centred on its own column, and
the sidebar takes 320px out of the right of that — so the two things a dealer is
actually looking at sat 172px apart, at 960px wide and at 1600px and everywhere in
between, because the offset is half the sidebar and the sidebar is a constant. The bar
also finished 80px inside the sidebar's column at 1280, painting over whichever panel
happened to be at that height. It leans away from the sidebar now, by the sidebar's own
width, which comes out exact above and below the 1400px cap — measured on the running
page at four widths, 0px off at all of them. No 100vw in it, which is the point: the
bar's own centring comment warns that on a page with a scrollbar 100vw is wider than
the viewport, and the offsets are exact without it.

The dealer button's chip sits in the puck's top-right corner and the name kept out from
under it with `padding-right: 22px`. That takes the chip's width off *every* line of the
name, and the chip is on one of them. Measured at 1280x900: the name box is 78px,
"Christopher" is 66 and fits, and on the seat holding the button it was cut to 56 and
broke as "Christop / her" — mid-word, no hyphen. The button moves every hand, so that is
a player whose name reads properly for four hands and comes apart on the fifth.

A float is what indents only the lines it sits beside. Line one gets 56px and the rest
get 78, so a name too long to fit beside the chip drops below it whole instead of being
broken to fit next to something that isn't there.

Not on a crowded ring, though, and finding that out cost a sweep. A tight puck gives the
name one line on purpose, and one line has nowhere to move to: with two lines granted
the name dropped onto a second line that the chip's lower half was still sitting on, and
the audit called it at every viewport in the list. So the float is for the ring with room
for two lines and the padding for the one without — two rules, matching two regimes the
puck already had.

The sweep also learned two things from this:

- Text is measured where it was painted, not where its box is. The seat-label check
  modelled "text kept out of somewhere" as padding, which is one way of doing it and not
  the only one; a float does it per line, and the check called every seat in the sweep an
  offender while the text sat exactly where it was meant to. Range rects ask the question
  directly, so padding, floats, centring, wrapping and a short label in a wide box all
  come out right without any of them being modelled.

- Offenders say how much is covered, not that something is. "Is under a badge" reads the
  same for a stack a dealer cannot read and for a badge's rounded corner grazing the last
  digit. Probing the whole line and counting turns the residual this sweep has carried for
  three rounds into "1/7 under SB" — one probe of seven, one configuration, thumb only.

294 audits, 4 failing configurations, 5 offenders: twelve players on a 360px phone with
the raise pad open (3/7 and 2/7, pucks genuinely overlapping — the search reports it
rather than papering over it) and the 1/7 hairline above. 770 unit tests.

Fault-injected, all restored and re-verified: writing the sidebar's width out again
instead of the token, growing the float to cover the chip's whole height, and taking the
padding off the crowded ring are each caught by a test; and with both the float and the
padding removed the rewritten label check still reports the name 6px under the chip,
which is the fault it exists for.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-04 18:17:16 -06:00
jheapsandClaude Opus 5 f07144acd8 Give the table the screen back when the phone is on its side
A phone held sideways is 640x360: short of height and swimming in width. The layout
spent the whole of that shortage on one column — 70px of header, 85px of felt, 205px
of action bar. Two rows of seat pucks 55px tall have to fit inside 85px of felt, which
they cannot, so seven of nine finished past the rail with up to 17px of puck floating
on the page. That single fault was 96 of the 98 offenders the layout audit could find
anywhere.

No amount of shrinking fixes it, because the shortage was never in the table. The bar
is 205px tall and 529 wide to hold three buttons and a status line, and it was taking
that height from the one element that needed height. So in landscape it moves: the bar
becomes a rail down the right-hand side, the table takes the column beside it, and the
felt goes from 529x85 to 409x205 — an actual oval, with room for two rows of pucks and
a gap between them. It also puts Fold, Call and Raise under the right thumb rather than
along the bottom edge, where a landscape grip has the palm.

Which layout the bar is in is declared by the stylesheet in a custom property and read
back by actionBar.js, because the one thing that must not exist is a second copy of
that media query written in JavaScript.

Then the ring stopped predicting itself. seatRingRadii works out the widest ring whose
outermost puck still finishes inside the rail, and it is right about a puck it measured
before the width search settled and before a name wrapped or was shortened — each worth
up to 15px of height arriving after the answer. Four viewports finished with the top
seat 5-6px over. render.js now checks the drawn result the way the audit judges it: if
the worst puck hangs 6px past the rail, the ring comes in by 6px and they are placed
again. A layout that predicts itself has to be right about the future; one that measures
itself only has to be right about the present.

Also in this round:

- Names shorten at a word instead of being chopped mid-one. "Mike from work" on a
  nine-handed phone was "Mike f…"; he is Mike. A name never ends on a joining word,
  because "Mike from" looks cut off rather than shortened, and the whole name is on the
  tooltip either way.

- The blind badge is counted in what a seat needs. It always points away from the table,
  which was taken to mean it costs nothing between two seats — true of a straight rail
  and false of a ring, where neighbours sit at different heights. Three tables had a
  player's stack painted under the next player's SB or BB, and that badge outranks
  everything else on the felt. It also got shorter, 15px to 12: clearing its own name at
  5px of overhang instead of 9 hands every chair in the ring four pixels back, which at
  640x360 is the difference between a 66px puck and a 48px one.

- Border-box, inherited, for the whole app rather than nine elements opting in one at a
  time. Both elements that hadn't went wrong in the same afternoon: the rail overran the
  page's padding by 25px and put Resolve Pot underneath itself, and the raise pad's
  amount field pushed a horizontal scrollbar across a 640px phone.

- The raise amount field is thumb-sized. The rule that sizes fields for a finger names
  the settings panels and the seat form, so the one field on the one surface built for
  thumbs was 36px on every phone in the list. The sweep never saw it because it had no
  step that opened the raise pad; it has one now, and that step also caught the rail
  squeezing its own status line under the pad.

- mobile-web-app-capable alongside Apple's older name, which Chrome has been calling
  deprecated in this page's console on every load.

The audit goes from 19 failing configurations and 98 offenders to 2 and 3, across 294
audits — thirteen viewports, three table shapes, a mouse and a thumb. What is left is
twelve players on a 360px phone with the raise pad open, where twelve pucks at the 44px
minimum genuinely do not fit on the felt; the search reports that rather than papering
over it. Each new mechanism was checked by breaking it on purpose: removing the settle
pass puts 26 offenders back, dropping the badge from the footprint 5, and un-sizing the
raise field is caught by the step that now opens it.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-04 17:19:11 -06:00
jheaps fbc4bd9a3e Merge pull request 'Hold'em dealer: a felt that fits, and a table that reads its own showdown' (#118) from polish/holdem-seat-collisions into master
Build and Deploy / build-deploy (push) Successful in 1m11s
Reviewed-on: #118
2026-09-04 15:28:46 -06:00
jheapsandClaude Opus 5 cd57c16050 Let the table tell the dealer who won
An option — off by default, and off means the tool is exactly what it was.

Turn it on and the community cards go on the felt. Each street asks for the cards as
it is dealt: the picker opens on the flop the moment the flop button is pressed, and
again on the turn and the river, because the moment a dealer is holding those cards is
the only moment they know what they are without turning the board round. Three taps,
then one, then one. The picker is the same sheet as the hole cards, which now knows
about the middle of the table too — the seven of clubs cannot be on the flop and in
somebody's hand, and until now it could.

Turning the players' cards over is unchanged. That already worked the way it should.

What is new is what happens next. Resolve Pot stops asking who won and says so:

    Alexandra wins $50 with three of a kind, nines.

with every hand at the table read out under its player's name, the winner already
ticked, and the button reading "Pay Alexandra". A chop comes out as "Christopher and
Mike from work split it, $22.50 each, with a pair of aces", both ticked. The dealer
still confirms — the tool reads the cards, a person still says the pot is good, and a
hand entered wrong is corrected by unticking rather than by starting over.

It declines rather than guesses. A board that isn't five cards, a player who hasn't
turned over, the same card entered in two places: each says which of those it is and
hands the question back, and the wizard is the one it always was. Who is *ahead* and
who *won* are different claims and only the second one can pay somebody.

Side pots are read per round, not once. The best hand takes the main pot and is then
out of the question; the side pot goes to the best of who is left, which is a different
answer and the one the tool has to ask for again.

handRank.js is the only module here that can pay the wrong person without anybody
noticing, so it is built to be checkable rather than clever: rank five cards, then take
the best of the twenty-one five-card hands in seven. Beside the written cases — every
category against every other, the wheel and the steel wheel, three kickers deep, the
counterfeited two pair, the board that plays — it is checked against a second evaluator
written a different way, on twenty thousand random seven-card hands and four thousand
random pairs of them. An ace that keeps its value in the wheel, a dropped two-pair
kicker and a flush read only by its top card were each introduced on purpose and each
caught.

The board takes the middle of the felt, sized from the room the seat ring actually
leaves — measured against every seat rather than the ring's widest point, which is only
the answer for a seat sitting exactly on the horizontal axis and had two pucks on top
of the board while claiming 77px of clearance either side. Where there is no middle to
take, it moves to a strip above the table: a phone on its side has a felt 528x83 with
55px pucks, so the ring closes over the centre entirely and a board in it is a board
with players painted on top.

That strip found an older bug. It costs the felt height, and a felt that changes size
had only ever been re-measured, never laid out again: the seats follow it on their own,
being placed in percentages, but a bet chip anchored inside a seat is placed in pixels
off that seat's centre. Measured at 640x360, the chips were positioned for a felt 83px
tall and painted on one of 60 — seat 3's $5 came to rest on seat 4's puck, and two
pairs of seat pucks were overlapping. The bar growing mid-hand does the same thing: a
side pot appearing is 55px off the felt. Both fixed.

The sweep grew a third table — the same five players in the same nine chairs as the
second, with the board on, so the only thing that differs between those two rows is the
board itself. It earned its place on the first run.

728 unit tests. 216 audits: what is left is the seat ring hanging past the rail on a
landscape phone, the known residual, plus five or six pixels more of it on the layouts
where the strip takes the felt's height, and a blind badge over a stack on a packed
twelve-seat portrait table.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-04 11:18:33 -06:00
jheapsandClaude Opus 5 2a080eab6a Give every bet on the felt somewhere to be
The chips were the last thing on the table nobody had measured, and the sweep had
never seen one: it audited a table that had never had a bet placed on it, so a third
of what is on the felt during a hand went unlooked at for the tool's whole life. It
posts blinds, deals and calls three times now before it measures anything.

What that found, and what this fixes:

- Chips accumulated. Seats are kept and updated in place rather than rebuilt, so
  clearing the bets layer stopped clearing the ones anchored inside a seat and
  nothing else ever did. An iPhone 14 had one chip per seat after the deal and five
  by the third render; a dealer's every tap left another disc on the felt.

- The ring-or-cling decision asked how wide the window was. A phone held sideways is
  844px across and gives the felt 117px: it passed the width test, took the ring, and
  every chip on it was painted under a puck — the seats carry z-index 1 and the bets
  layer carries none, so a chip that lands under one is gone, and with it the only
  number saying what that player has in. Measured now, and only measured.

- Getting clear of a box was `edgeReach(puck) + edgeReach(chip)`, which is right only
  straight up, down or sideways. Two axis-aligned boxes are clear when they are apart
  on one axis, so the travel is the reach of the box you get by adding their widths
  and their heights. At 768x1024 a $10 leaving a seat on the diagonal wanted 88px and
  got 77, and came to rest 11px inside its own player's puck — every seat off the four
  compass points, which is most of them.

- Every chip was laid out at the first chip's size. A chip is as wide as the number on
  it: "$5" is 21px and "$10" is 27px on a phone, so each $10 was placed as if it were
  6px narrower than it is. The widest is no better: at 768x1024 one 47px chip made the
  whole table give up a ring the other four had room for.

- Anchored chips settled where they landed. At 640x360 the felt is 528x83 and a puck
  is 55 of those pixels tall: clearing it takes 41px, the players facing each other are
  70px apart, and both chips set off 41px into a gap that is 70 wide. Three overlapping
  pairs on a twelve-handed table. Each chip now gives ground only to something it is
  actually standing on, a pixel at a time — a midline rule or a single scale factor
  both punish the seats at the ends of a rail, which were never going to hit anything.

- Nothing knew the pot total was there. Seat 1's $10 was sitting on "Pot $65".

Two things off the felt, from the same sweep:

- The toast outlived its news. "Next hand — button on seat 2. [Undo]" stayed up while
  the hand was dealt and three players called: an offer to move the button, wired to a
  snapshot that predates the hand, so taking it would have thrown the hand away without
  saying so. It was also in the way of thirty small screens' seats and controls. The
  bar drops it as it takes the next action; the bar's own Undo names what it would
  actually take back.

- The pot dialog cut a name off. Three players in the pot on a Galaxy S8, two rows
  visible, the third below a hairline that reads as the end of the list — a dealer
  ticking the two they could see would have paid two thirds of a three-way pot. The
  85vh budget was still subtracting the action bar's height from a dialog that has sat
  above the bar since its z-index was fixed, and a player's ceiling moves beside their
  name on a short screen. All three fit now, and where a list does run on, it runs on
  under a fade rather than stopping dead on a row boundary.

The pot badge goes on a phone held sideways: 86x66 of a 692x113 felt, for a number the
action bar prints three inches below it, standing in the only band a bet chip has.

The audit stopped calling one thing another. An element clipped away by a panel that
scrolls is not covered by whatever is painted at that height — the wizard's third
winner came back for four viewports as "fully covered by button Confirm Winner(s)".

190 audits: 25 failing configurations to 7, and every one of those seven is the seat
ring hanging past the rail on a landscape phone, which is the known residual from the
last round. 663 unit tests, 44 of them new.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-03 17:05:47 -06:00
jheapsandClaude Opus 5 f93e0935ef Give every seat its own pixels
Six players at a nine-handed table is the most ordinary table this tool draws, and on
every phone in the sweep one of them had their stack painted under the player next to
them. Measured across nine viewports and six table sizes: seventeen of fifty-four
tables hid a name or a stack behind something belonging to another seat.

Two causes, and the layout only knew about a third of one of them.

The felt decided how big a puck could be from a single number — the straight-line
distance between the closest two *neighbouring* seats, judged against a fraction of a
puck's width. Distance has one number in it and a puck is half again as wide as it is
tall, so the same 84px was generous side by side and nowhere near enough on the
diagonal. And neighbours are the wrong pairs to ask about: on a phone held sideways the
felt is 528x87, the ring is very nearly a straight line, and the seat that lands on top
of you is the one across the table. Ten of the tables in the sweep had a collision that
a neighbours-only check cannot see — seats 1 and 7, overlapping by 68px, while every
adjacent pair had room.

seatsCollide() asks the real question instead: do any two of these boxes want the same
pixels. Two equal axis-aligned boxes overlap exactly when their centres are closer than
one box in both axes, so it is an answer rather than a proxy, and it looks at every
pair.

The second cause was the small blind badge, sitting at `left: -6px` — hanging off its
own puck's left corner onto whatever was beside it. On an iPhone Pro Max at six seats,
with the pucks not touching at all, "SB" was painted across the next player's $1,137.
All three badges now sit inside the puck's width, and in the same place as each other:
a seat only ever wears one, so two positions bought nothing except making the dealer's
eye find which of two spots this seat was using before it could read which blind it is.

Then the layout had to be able to act on the answer, and it couldn't. It chose between
two widths — 96px or 58px — and if neither fitted it widened the ring and let the seats
overlap, on the reasoning that a dealer can see an overhang but cannot see a tap landing
on the wrong player. That trade is real; it was also a false choice, because the tables
that overlapped did not want either number. At nine seats a Galaxy S8 had room for a
61px puck, an iPhone 14 for 68, a Pixel 7 for 74, an iPhone Pro Max for 79. Three media
queries declared three more of these numbers — 78px, 64px, 62px — at three breakpoints,
guessing at something a media query cannot see: the same 390px phone has room for a 90px
puck at four seats and 61 at nine.

So the width is searched for now, not chosen. render.js walks candidates from the top,
applies each one, measures what the browser actually drew, and takes the widest that
leaves every seat its own box. There was a width that worked on every table in the
sweep; the code was never asking for it. Ten candidates is ten forced layouts, so the
result is cached against the felt's size, the seat count and the names — measured 12.5ms
cold, 0.8ms warm, and renderSeats runs after every tap.

Three things this got wrong on the way, all caught by measuring rather than reading:

  The badge overhang was folded into the space between two seats. That collapsed the
  ring on a phone held sideways — 87px of height, and 18px of badge is most of the room
  the ring has to move in — until a six-handed landscape table was unsatisfiable at any
  width. It didn't belong there: the badge points *away* from the table at both ends, so
  two seats facing each other across the felt wear them back to back.

  box-sizing: border-box, which the puck needed so the searched number means the width
  it is drawn at, quietly turned every min-height from a content height into a drawn one.
  An empty chair went from a 52px tap target to a 42px one on every phone in the sweep.
  The audit found it; the four min-heights have each gained the padding and border they
  used to be quoted without.

  The new audit check reported a name under a neighbour on a table whose seats were 9px
  apart. A puck's name is a `-webkit-box` with `line-clamp`, and getClientRects() hands
  back a rect for every line the text *would* have taken, including the ones the clamp
  throws away — so the probe was hit-testing 30px below the puck, where nothing is
  painted. It clips to what is painted now.

uiAudit gains "no seat covers another seat's writing", the complement of the check
already there for a puck's own decoration on its own name — which is why nothing caught
this: seatLabelsClear() only ever looked inside one seat. Fault-injected against the
previous version: eighteen configurations, naming both causes.

And the sweep itself now runs two table shapes. It had run one for its whole life —
five players at a nine-handed table — and how crowded the felt is depends on how many
people are sitting down far more than on anything a viewport can report. A full table
was never once looked at.

632 tests, 0 failures. 190 audits across thirteen viewports at two table shapes, with a
mouse and with a thumb. Every table from two seats to twelve now keeps its own text on
every viewport in the sweep. One configuration still reports: a dozen players on a
640x360 phone, where the felt is 87px tall and two rows of 55px pucks cannot both fit
and stay on the rail — the ring grows 8px past the edge to keep the seats off each
other, which is the trade the old code named and got right. Left reported rather than
tuned away.

Names on a crowded phone table truncate harder than they did, because the puck is now
sized to fit instead of allowed to overlap: "Christopher" is "Christ…" at nine seats on
an iPhone 14. The full name is in the DOM, in the seat's form and in the sidebar. The
next lever on that is the seat layer's inset, which is still a fixed 44px of "half a
puck" for a puck that is no longer 96px wide — worth 54px to 60px where it was measured.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-03 13:23:31 -06:00
jheaps 190ccebf6c Merge pull request 'Hold'em dealer: a reachable Confirm, a table that fits, offline, and a quieter first screen' (#117) from polish/holdem-offline-and-render into master
Build and Deploy / build-deploy (push) Successful in 1m42s
Reviewed-on: #117
2026-09-03 12:24:00 -06:00
jheapsandClaude Opus 5 c052ee32c8 Put the Confirm button back on screen
Opened the pot wizard — the one dialog in this tool that divides money — and its
Confirm button was below a scroll. Not on a tall table, not on one screen: at
every size I measured, with six players and a side pot.

  iPhone 14 390x844   244px of the dialog below the fold
  Galaxy S8 360x640   417px
  844x390 landscape   583px
  desktop 1280x900    172px

Nothing on screen said there was anything under the list of names, so a dealer
with the table waiting had to scroll a list of players to find the only control
that finishes the hand.

Two goes at this, and the first was wrong. A sticky footer is the obvious
answer and it cannot work here: the actions were the last child of the
scrolling content, so their containing block ended exactly where they did and
`bottom` had no room to lift them. Sticky on the *header* did work, for the
mirror-image reason — its container extends below it — which is what made the
failure confusing to read. Capping the player list wasn't enough either; the
pot breakdown and the log still overflowed on a short screen.

So the dialog is a proper column now: a title that doesn't move, a body that
scrolls, and a footer outside the scroll. potWizard.js appends its buttons to
the footer instead of into the body, and clears both together so a step can't
inherit the previous step's buttons. Measured after: Confirm on screen and
hit-testable at all six configurations, twelve-handed on a Galaxy S8 included,
and the dialog itself no longer scrolls at all.

The checkboxes are painted now, too. `accent-color` only ever coloured the tick
— the box around it stayed the platform's own bright white square, which on a
near-black dialog was the loudest thing on screen and the last raw control in a
tool where everything else had been drawn. Same surface ramp, same corner
radius, and a tick drawn with clip-path so it can't pick up a substituted font.

uiAudit gains "an open dialog's buttons are on screen", which hit-tests rather
than trusting geometry — a button can be inside the viewport and still be under
something. Fault-injected: putting the footer back inside the scrolling body
reports Confirm 105px below the fold.

Two things checked and left alone, because they turned out to be right: the
standings table hides three columns on narrow screens through a container query
keyed to the sidebar rather than the viewport, which is why a desktop shows
fewer columns than an iPad — the sidebar is the narrower box there. And In/Won/
Net read $0 for hands settled outside the wizard, which the comment above them
already explains.

Caught by my own guard on the way past: the token test read `scroll-padding` as
`padding` and failed the spacing scale. Correct by its rules, wrong in substance
— those values are a sticky bar's measured height, not a step in the rhythm — so
the lookbehind now rejects a hyphen as well as a word character. Re-fault-
injected to confirm it still catches a real ad-hoc gap.

619 tests, 0 failures. 65 layout audits across 13 viewports x 5 table sizes and
35 across the five overlays. The whole wizard walked end to end: side pot
resolved, hand logged, $1,560 on the table before and after.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-03 12:20:02 -06:00
jheapsandClaude Opus 5 1f3dda180f Make the table big enough for the game around it
Looked at the felt at three times scale and the seats were falling off it. Not
sitting at a rail — hanging past the outer edge of the table into the page,
half a puck on green and half on nothing. Measured:

  1280x900, nine-handed   five of nine seats past the rail, worst 49px
  844x390,  six-handed    all six, worst 20px
  430x932,  six-handed    two, worst 12px
  360x640,  nine-handed   none

The ring sat at a flat 44% by 42% of the seat layer at every size, two numbers
picked against a phone. A fixed pair can only be right for one ratio of puck to
felt, and the felt's ratio changes with every viewport — so it was a different
amount of wrong on each one, which is exactly what makes something look like a
mistake rather than a choice.

The radius is derived now: the widest ring whose outermost seat still finishes
inside the table, worked out from the measured table, seat layer and puck. It
can only ever pull seats in — the old pair is kept as the ceiling — and it falls
back to that pair whenever there is nothing to measure, which is every call from
the unit tests and the first render before the felt is laid out.

Three things this turned up on the way, each caught by the sweep:

  * The seat layer is not centred on the table. Its insets differ top from
    bottom, so the first version — which assumed symmetry and worked from half
    widths — left a phone with 12px still over the rail. It works in absolute
    pixels against the nearer edge now.

  * The radius and the puck size depend on each other: the ring is limited by
    how big a puck is, the puck is sized by the `seatsTight` class, and that
    class was decided from the gaps between seats, measured at a radius that
    hadn't been worked out yet. Fixing the gap measurement then made a
    nine-handed phone overhang that had been fine, because the two were chasing
    each other. It is settled by trying the candidates in preference order
    instead: the roomy layout if everything fits, the tight one if not, and if
    neither fits, tight pucks on a ring widened only as far as it takes to stop
    seats colliding — because two seats fighting over a tap is a worse failure
    than a puck over the rail. A dealer can see an overhang; they cannot see
    that the seat they pressed wasn't the one that answered.

  * The spacing threshold was a flat 84px, the figure a 96px puck was judged
    against. Asking a 68px tight puck to keep a 96px puck's distance is a test
    it can never pass, so every landscape table fell through to the fallback and
    took an overhang it didn't need. It is a ratio now, not a number.

Result: worst overhang anywhere goes 49px to 9px, and 59 of 65 configurations
are completely clean. Desktop, tablet and every phone held upright: nothing over
the rail at all.

Residual, reported rather than tuned away: six configurations still overhang by
1-9px, all of them landscape at 640x360 and 667x375, where the felt is about
110-125px tall and no ring puts readable pucks inside that. The audit check is
left strict so they stay visible.

uiAudit gains "seats and chips stay on the table", which runs at every viewport
in the sweep. Fault-injected: putting the flat 44/42 back reproduces the original
failure exactly, including seat 3 at 49px.

Faster, too, because the measuring got hoisted out of the per-seat loops while I
was in there:

  renderSeats   7.3ms -> 1.8ms
  renderAll    12.4ms -> 6.8ms

619 tests, 0 failures. 65 layout audits across 13 viewports x 5 table sizes.
Offline re-verified: a whole hand with the network cut, $800 conserved.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-03 11:55:51 -06:00
jheapsandClaude Opus 5 3ffce9d7c1 Stop the empty table shouting, and point at the next step
The first screen a dealer ever sees was the one nobody had looked at. Opened
fresh, with nothing saved, the tool showed:

  * the word EMPTY, in capitals, ten times across the felt
  * Resolve Pot as the accent-green primary — on a table with no players and no
    pot, so the loudest control on screen was the one that could do least
  * Next Hand, with no button to move
  * no indication anywhere of what to do first, except a grey caption under the
    felt saying "tap a seat to edit"

An empty state is the one screen a new user is guaranteed to meet, and this one
described itself ten times without once saying what to do.

**The felt stops repeating itself.** An open chair is already dashed instead of
solid, faded, and has no stack under it — that reads at a glance, which is how a
dealer looks at a felt, and it reads the same on the one open chair at a full
table, which is the case the label was actually for. The word is gone from all
ten, and the seat numbers underneath it are now the thing you see. What the word
carried for anyone reading by ear moves to an aria-label on the seat, which now
says more than the sighted version ever did: "Seat 3, empty", "Seat 1, Ann".

**The bar says what the table is.** It used to hide itself when nobody was
seated — silent at exactly the moment a dealer has never used this before. It
now says "Nobody's at the table yet." with one full-width button, Seat a player,
which opens the seat editor on the first free chair. That is the same panel
tapping a seat gives them, so the button teaches the gesture rather than
replacing it.

**Two dead controls stop pretending.** Resolve Pot and Next Hand are disabled
until somebody is at the table, which takes the accent green off the button that
cannot work and leaves the only green on screen on the one that can. Disabled
rather than hidden: hiding them would move the header the moment the first
player is named, and a control that vanishes teaches nothing about what it was
for. Next Hand's own explanatory title is kept and handed back when it
re-enables.

One bug of my own, found by walking the flow rather than by reading it: after
typing a name both header buttons stayed greyed out, still captioned "nobody is
at the table yet". commitSeatChange refreshes narrowly on purpose — a full
render on every keystroke takes the name field out from under the dealer
mid-word — and the comment directly above it already warns about this exact trap
for the action bar. The header needed the same line.

609 tests, 0 failures. 65 layout audits across 13 viewports x 5 table sizes,
0 failures — the empty table is now one of those sizes, and had never been
audited before. Re-verified with the network cut: the first-run screen, the CTA
and seating a player all work offline.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-03 11:14:30 -06:00
jheapsandClaude Opus 5 5d5093f3cc Make the dealer tool work with no signal, and stop it shouting
Three findings this round, all measured on the running page.

**1. The tool needed the internet to exist.**

The night already survived anything — it lives in localStorage — but the page
did not. Reload in somebody's back room with one bar of signal and a dealer
holding a phone in one hand and a deck in the other got a browser error page
with nine players waiting. Verified: with no worker, an offline reload is
net::ERR_INTERNET_DISCONNECTED.

There is now a service worker (wwwroot/casino/sw.js) and a manifest, so the
tool installs to a home screen and runs with the network switched off. Measured
end to end: 19 assets cached from one ordinary visit, then with the context
offline the page loads, draws six seats, applies its CSS, and plays a whole
hand — blinds posted, four calls, $800 still on the table.

Three decisions worth writing down:

  * The precache list is read off the document rather than kept in the worker.
    Every stylesheet and script is already in the DOM; a hand-written file list
    stays right until somebody adds a script tag, and then the tool is quietly
    offline-broken until a dealer finds out at a table.
  * No skipWaiting. A new worker taking over a live page can hand it assets
    from a different release than the ones it started with, and this page loads
    seventeen scripts that expect to agree with each other.
  * Stale-while-revalidate for assets, not cache-first — see finding 3.

Scope is /casino/ rather than /casino/texasholdem/, because a scope has to be a
prefix of the page it controls and "/casino/texasholdem" does not start with
"/casino/texasholdem/". Anything in scope the worker wasn't told about goes
straight to the network.

Program.cs needed .webmanifest added to the MIME map: an unknown extension is
not served with the wrong type, it is not served at all, and a 404 manifest is
exactly the silent failure that stops a browser ever offering "Add to Home
Screen".

**2. A screen reader read out the whole action bar after every tap.**

The bar carried aria-live on the element whose entire contents render() rebuilds
each action. Measured, after one tap:

  "PRE-FLOP Pot $15 9 players Bob · $250 behind 0:00 ↶ dealing the hand U
   Fold Call $10 Raise"

Nothing in it is wrong; it is unusable at the speed a hand is dealt, and it is
the documented anti-pattern — a live region has to be a stable element whose
text changes, not a container that gets replaced. There is now a small
role="status" element that exists from page load and says one sentence:
"Dee to act, $10 to call. Pot $15." Guarded on the sentence rather than on the
state, because render() runs for many reasons that don't change whose turn it
is, and a live region handed the same words twice reads them twice.

**3. Every tap rebuilt ninety DOM nodes.**

renderSeats did `innerHTML = ""` and then cloned a template, attached three sets
of listeners and set a dozen attributes per chair — on every render, and
renderAll runs after every action. Profiled on a nine-handed table:

  renderSeats   7.3ms -> 4.0ms
  renderAll    12.4ms -> 9.5ms

The pucks are built once and updated in place, which is what the rest of that
function was already doing to their contents. A seat's node belongs to its index
for as long as the table is that size, so the listeners still close over the
index; only a change in the number of chairs rebuilds the row. Verified that
listeners don't stack: one click after six renders fires the hook exactly once,
and resizing 9 -> 4 -> 10 renumbers correctly.

Found along the way and deliberately left alone: ViewData["cssVersion"] renders
empty on every page of the site, so the ?v= cache-busting has never worked. It
is inert — Program.cs sends Cache-Control: no-store on all static files — and
the service worker was the only reason to care, which is why it uses
stale-while-revalidate instead. Noted in TexasHoldem.cshtml.

608 tests, 0 failures, including a new announcing.test.js. 52 layout audits
across 13 viewports x 4 table sizes: 0 failures, and the action bar still
doesn't change height mid-hand. Gestures re-verified after the seat refactor:
double-tap fold, long-press menu, give-the-button, and the active dealer chip.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-03 10:51:27 -06:00
jheaps aaf96543a8 Merge pull request 'Hold'em dealer: blind fix, design tokens, and the decrowd' (#116) from fix/naming-a-blind-deals-the-hand into master
Build and Deploy / build-deploy (push) Successful in 1m36s
Reviewed-on: #116
2026-09-03 10:26:50 -06:00
jheapsandClaude Opus 5 2e9c55af21 Give the felt back the screen the chrome was using
Measured on a 390x844 phone, six-handed, same table before and after:

                        before   after
  header                  193px    120px
  felt starts at          225px    152px
  controls on screen         27       18
  bordered elements          37       22
  text elements              61       49

A quarter of the screen went on chrome before the dealer saw the table.

**The header.** Six equally weighted buttons in two rows, of which two are
per-hand controls. The other four have gone where they belong rather than into
a "more" menu — that relocates crowding instead of removing it. Reset Table is
in the Session panel now, beside Clear history and the save/load pair: it is
the control that throws the night away and it was sitting in the same weight as
Resolve Pot, the most-pressed button in a session. Quick Split is down with
Common rulings, where the other "open something else" link already lived. The
way back to the site is a quiet link at the top, where a back link goes.

The subtitle is gone. It was one sentence of orientation on a tool used
nightly, and the landscape and short-phone rules already hid it — the layout
had decided it was expendable on every screen this is actually used on.

**Teaching copy, once.** Table Setup shipped open, so the first screen was the
most crowded one; it now opens only on a table nobody has set up yet, decided
once at load so it cannot reopen itself under a dealer who just shut it. The
gesture hint under the felt goes after the first deal of the session — keyed to
the hand number, not to bettingOpen, which was the obvious test and the wrong
one: it would have appeared and disappeared twice a hand, shunting the panel
stack up and down all night.

**Borders.** Panels had a border, a lighter background and a shadow — three
ways of drawing one edge. The standings had a rule under every row, which is
what makes a list read as ruled paper. Both drop the line and keep the
grouping; the standings header keeps its rule, because that one marks a change
of kind. Fields and buttons keep theirs: those say "interactive", which is not
decoration.

**The felt itself.** Names wrap to two lines instead of ellipsising, so
"Christopher" and "Mike from work" are readable rather than "Chri..." and "Mike
fro..." — the truncation was eating exactly the half a dealer typed to tell two
Mikes apart. And a stack of $1,234,567 rendered at 7px; it now drops the
thousands separators before it drops the type size, so the same number is eight
characters at 10px with every digit intact. The comment above fitAmount was
right that abbreviating loses precision — but precision the dealer cannot read
is not precision.

Two things tried and reverted, both measured:
  * Reserving the second name line made every puck the height of the tallest
    (69-73px became a uniform 88). Fifteen pixels on all nine seats is felt
    spent on chrome, which is what this round is trying to give back.
  * Dropping the name a type step to fit more on one line still did not fit
    "Christopher" on the button seat, where the dealer chip takes 22px of the
    row — so it cost legibility and bought nothing.

Known limit: a single word too long for a puck breaks across the two lines, and
gets a hyphen only where the browser has hyphenation dictionaries. Real phones
do; headless Chromium does not, so audit screenshots show "Christo / pher". The
whole name is visible either way, which it was not before.

599 tests, 0 failures. 52 layout audits across 13 viewports x 4 table sizes
(2, 6, 9, 12 seats) and 20 across the five overlays: 0 failures, and the action
bar still does not change height mid-hand at any of them. The back link was
caught by the audit at 56x13 when it stopped being a button; its hit area is
44px now, paid for with negative margins so the header keeps the 73px.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-03 10:17:08 -06:00
jheapsandClaude Opus 5 8b43614b1c Give the stylesheet a vocabulary, and stop the bar moving
The tool read as crowded on screens where nothing overlapped, and the reason
turned out to be arithmetic rather than taste: holdem.css used 42 different
spacing values, 31 font sizes, 14 border-radii and 139 colours, and defined
three custom properties. Every number had been picked by hand at the moment it
was needed. A 0.42rem gap beside a 0.45rem gap beside a 0.5rem gap gives the
eye no rhythm to lock onto, so it reads the whole surface as noise.

So: constrained scales, and everything snapped to them. Seven spacing steps on
an 8px rhythm, eight type sizes, four corners, and a four-step surface ramp
that stays off pure black — shadows are invisible on #000 and white-on-black
smears on the OLED phone this is used on. 661 values rewritten; colour mentions
halved, 381 to 174. Nothing here changes what the tool does.

Two things were measurably moving under the dealer's thumb, and both are fixed
by reserving space rather than by animating anything:

  * The undo pill named the last action, so it resized on every single tap.
    Measured across one hand: 148, 110, 109, 108, 109, 110, 120px. It is now a
    fixed 10rem, with the label in its own element so it can ellipsize without
    truncating through the keycap beside it.

  * The action bar is fixed to the bottom, so anything that changes its height
    moves its top edge. The status row wraps, and it wrapped differently as the
    street and pot changed. It now holds two lines' worth of room whenever a
    hand is running. Across 13 viewports x 3 table sizes, the bar no longer
    changes height mid-hand at all.

Numbers that change while the dealer watches — pot, stacks, bets, standings —
are now set in tabular figures, so a pot ticking $18 to $21 stops shuffling its
digits sideways.

Two regressions the sweeps caught on the way, both from the snapping:
  * The Add Chips field cleared 44px by way of its font size plus its padding,
    so moving either dropped it to 42. It states its own min-height now; a
    touch target shouldn't be an accident of two other numbers.
  * The bar came out 2px shorter in landscape, which grew the felt, which
    lifted the top seat's dealer button 2px into the header. That clearance was
    structurally 1-7px, so the top seat inset became a floor rather than a
    ceiling instead of chasing the 2px.

tests/holdem/designTokens.test.js keeps the scales honest — a new value has to
be a step or an exception with a reason. Fault-injected four ways.

599 tests, 0 failures. 39 layout audits across 13 viewports and 3 table sizes,
plus 35 across the five overlays: 0 failures.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-03 03:11:13 -06:00
jheapsandClaude Opus 5 42b9e83e8f Don't deal the hand when the dealer names a blind
The seat menu's "Make small blind" / "Make big blind" / "Blinds follow the
button again" all re-posted the blinds whenever `Betting.onlyBlindsIn(state)`
was true. That condition means "nothing has happened in this hand except the
blinds" — which is true of a hand one second old and equally true of a table
sitting in setup with no cards out and nothing committed.

So naming a blind before the deal ran repostBlinds -> beginHand: betting
opened, two stacks docked, the action bar up and waiting on the seat after the
big blind. The dealer had said where the blinds go and the tool heard "deal".

Betting.blindsArePosted() is the question these three actually wanted — the
blinds are down only once a hand is open — and it's the guard makeDealer was
already using by hand. Before the deal, naming a blind now moves a badge and
nothing else; the toast says "Dee has the small blind next hand." rather than
claiming she posted it. Mid-hand behaviour is unchanged: chips come back and
go in again from the new positions, and the chip count stays straight.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 11:11:34 -06:00
jheaps 419c1de2a0 Merge pull request 'Hold'em dealer: 55 polish rounds, one merge' (#101) from release/casino-holdem-polish into master
Build and Deploy / build-deploy (push) Successful in 1m32s
Reviewed-on: #101
2026-09-02 10:00:14 -06:00
jheaps 470fbc0d26 Add the seat-name round to the release 2026-09-02 09:52:35 -06:00
jheapsandClaude Opus 5 c4e842f3e3 Stop the button's chip sitting on top of the name underneath it
A phone screenshot, mid-hand, eight-handed: the seat with the button reads
"Christop" and then a white D. The name is centred across the whole puck and the
chip sits in its top-right corner on a layer above, so a long name runs
underneath and stops — measured, "Christopher" lost 20px of its 64px box.

The bad part is the silence. The text believed it had the room it was being
painted out of, so no ellipsis appeared: nothing on screen said the name went on.
On the one seat at the table a dealer looks at most.

The name now keeps out of the chip's corner on the seat that has the button, so a
long one ends in an ellipsis before it gets there. Only that seat — every other
puck has its full width, because the chip on those is invisible.

And the audit has a check for it now. Nothing it had would have caught this: the
occlusion check is about controls a thumb has to reach, and this is a label a
dealer has to read. The new one measures every seat's name against its own chip
and badges, and the fix was undone by hand to confirm it fires — "20px under
button.dealerChip.active".

182 audits across 13 viewports, both input kinds, plain and straddled tables,
every overlay: clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 09:52:00 -06:00
jheaps 1d9353c94e Add the hidden-means-hidden round to the release 2026-09-02 09:37:29 -06:00
jheapsandClaude Opus 5 8616636212 Make hidden mean hidden, and check that it does
Three rounds inside the money, so this one was a look at the thing. On a desktop
screenshot of a table mid-session there is a small empty box under the bank
check: 21 pixels wide, 44 tall, no text, no purpose. It is the "Take $600 from 3
players" button from three rounds ago, which sets `hidden` when there is nothing
to record — and `hidden` was losing.

The attribute carries its `display: none` from the browser's own stylesheet,
which is the weakest rule in the cascade, so any class that sets a display beats
it. Every button class here sets `inline-flex`. This is not a new discovery in
this file either: seven overlays already carry their own `[hidden] { display:
none }`, each one presumably found the same way. One global rule now covers all
of them, so there is no ninth.

The audit had been telling me. Its touch check reported the box as "could not be
probed (drawn 21x44)" — in the warnings, which my sweeps printed only sometimes
and never failed on. So the check now says the plainer thing: an element that
claims to be hidden and is drawn anyway is a fault, not a note. It is in runAll
with the rest, and the bug was put back by hand to confirm it fires.

The same warning stream had a second thing in it: the waiting list's Add button
was 43px wide. Three letters, one pixel under the rule, and a thumb doesn't know
the word is short.

182 audits across 13 viewports, both input kinds, every overlay and a table with
a rake, a cash-out, a waiting list and six logged hands on it: clean, and the
only note left is the one that says the 44px rule stood itself down on a mouse.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 09:36:51 -06:00
jheaps 6174981cdc Add the assumed-contributions round to the release 2026-09-02 09:21:57 -06:00
jheapsandClaude Opus 5 4db6f6e4d3 Take the chips the pot was priced with, not just pay them out
Two rounds of asking where the tool disagrees with itself, and here is the last
of it — the one that was actually creating money.

There are two ways a contribution gets in. Tapping the action moves chips: the
bet leaves the stack, sits in front of the player, and is swept into their hand
total. The other is the "without live betting" panel — a base pot amount, or
chips counted out on a seat — for a dealer recording a hand they are watching
rather than driving. Nothing leaves anybody's stack there.

The pot wizard prices both the same way, and awarding it credits the winner. So
in the second mode the table simply grew. Measured at a three-handed table with a
$50 base pot: stacks 200, 200, 200 before, pot priced at $150, and afterwards
350, 200, 200 — six hundred dollars of chips became seven hundred and fifty, and
the bank check, newly able to see it, blamed a rebuy nobody had made.

So before the pot is pushed, whatever was assumed rather than taken comes off the
stacks that assumed it, and is recorded where a swept bet lives. Chips already
committed or swept are left alone — they have been paid once, and taking them
twice is the same bug the other way round. A base pot on top of a live bet
collects only the difference. Nobody is charged more than they are holding, and
an empty chair or a seat sitting out is charged nothing at all.

The same hand now ends 300, 150, 150: the table has the six hundred it started
with, and the bank check is balanced before, during and after.

13 new tests, with the already-paid test disabled on purpose to confirm five of
them catch the double charge. Walked through the UI in base-pot mode: pot badge
$150, wizard pricing three seats at $50 each, and afterwards the felt holding
exactly what it held before.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 09:21:19 -06:00
jheaps 0ad5592687 Add the one-stack-rule round to the release 2026-09-02 09:06:07 -06:00
jheapsandClaude Opus 5 1c2e716508 Ask the same two questions the same way everywhere
The round before this found the bank check and the standings reading a seat's own
stack where the felt reads the table's starting stack — two parts of the tool
disagreeing about how much money was on the table. That was worth a sweep, and
the sweep found more.

The colour-up panel had the same fault. It is the panel that says "count out
2×$100 for this player", and on a table opened the ordinary way — a starting
stack and some names — it showed nothing at all. The one table where every stack
is a round number made of the largest chips there are was the table it had
nothing to say about.

The other question, "is anybody in this chair", existed in four spellings: in the
seat count, in the seat menu twice, in the waiting list, and twice more in the
state module. Three of them looked at name, chips and cash; one also looked at a
hand being dealt. A chair that counts as occupied for one and empty for another
is how somebody's chips get given away — measured, a seat marked in hand with no
name and no stack yet was on offer to the next person in the waiting queue, on
top of whoever was already playing it.

Both questions now have one implementation and one name:

    "What is this seat holding?"   HoldemState.chipsHeldBy
    "Is anybody in this chair?"    HoldemState.seatInPlay

10 new tests, including one that pins the bank check, the standings and the
settle-up plan to the same total, and one that pins the seat count and the open
seats to the same set of empty chairs. The occupancy test was cut back to its old
three-part form on purpose to confirm the waiting-list case fails.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 09:05:32 -06:00
jheaps 236c52567e Add the starting-stack accounting round to the release 2026-09-02 08:53:35 -06:00
jheapsandClaude Opus 5 1689ecb4f0 Count the chips the table is actually playing with
"Starting stack" is how a night opens. The setup panel offers it and says what it
means — "seats without their own stack start here" — and everything a dealer
looks at reads it: the puck, the action bar, what a player is allowed to bet.

The money side didn't. The bank check and the standings read `seat.stack`
directly, which is null for exactly those seats. Measured on a three-handed table
with a $200 starting stack and nobody's own stack typed in: the felt played for
$600, the bank check reported no chips in play and pronounced itself balanced,
and the standings — the list a dealer settles the night from — listed nobody at
all. Three players, six hundred dollars, and the tool's own accounting said the
table was empty.

Both now ask the same question the felt asks. The check reports $600 in play
against nothing taken, which is the truth: those chips have not been paid for.
Empty chairs are excluded, or the starting stack would make every one of them
look like somebody holding two hundred dollars — a ten-seat table would have
opened with $2,000 of imaginary chips.

Being told is only half of it. The verdict names the seats rather than saying
"somebody's rebuy probably wasn't recorded", because the dealer's next move is to
go to a particular chair and the tool knows which. And the seats holding the
table's starting stack — the dealer's own instruction, said once in the setup
panel — can be written down in one tap instead of six trips through Add chips.
Only those: what somebody's forgotten rebuy was is a question only the dealer can
answer, and guessing it would be the tool inventing money, which is the fault
this round is about.

14 new tests, with the stack source and the empty-chair test broken on purpose to
confirm they fail. Walked through in the browser from a cleared table: set the
starting stack, name three seats, and the panel goes from "balanced, no chips" to
"$600 more in chips than cash taken. Ann, Ben and Cal are holding chips with no
buy-in written down" — then to balanced, with the buy-ins recorded and undoable.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 08:52:56 -06:00
jheaps b8655be258 Add the waiting-list round to the release 2026-09-02 08:38:21 -06:00
jheapsandClaude Opus 5 55eb569d3b Keep the list of who's next, instead of leaving it on a napkin
A full home game always has two or three people standing behind the chairs, and
the list of who's next lives on a napkin or in the dealer's head. That is where
the argument an hour later comes from. The tool had just learned to open a chair
— somebody cashes out and the seat is free — and had nothing to say about who
should sit in it.

The Session panel keeps the list in order of arrival, which is the only ordering
anybody has ever agreed to. Whoever is at the front is offered the first open
chair by number, and everybody else waits their turn: a list that isn't kept in
order is a pile of names, and jumping the queue is the thing a dealer gets
shouted at for. With the table full it says so rather than offering a seat that
doesn't exist. The same offer appears on the empty chair's own menu — "Seat
Priya" — because tapping the chair somebody is about to sit in is what a dealer's
hand does anyway.

Sitting somebody down puts their name on the chair and nothing else. No stack, no
buy-in, not dealt in: they haven't paid yet, and inventing chips for them would
put money on the table nobody has handed over, which is the one thing the bank
check exists to catch. Add chips is the next thing the dealer does, and it is one
tap away in the same menu.

Two people called Mike are two people — the list is keyed by id, not by name.
Names are trimmed, blanks are refused, and a saved list from a table that never
had one comes back empty rather than undefined.

15 new tests, with the occupied-chair guard removed and a stack handed to the new
player on purpose to confirm they fail. Walked through the browser: two names
added to a full table, a player cashed out to open seat 2, the front of the queue
seated there with nothing in front of them, the bank still balanced, and the
second name taken off the list. 156 audits with the list populated, including a
name long enough to test the row.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 08:37:44 -06:00
jheaps 7e420ce747 Add the misdeal round to the release 2026-09-02 08:24:05 -06:00
jheapsandClaude Opus 5 86886ac126 Take a hand back, which the rulings sheet has been asking for all along
This tool has told dealers to redeal on a misdeal since the rulings sheet was
written — first card to the wrong seat, a seat entitled to a hand dealt out, one
of the first two cards exposed — and it had no way to do it. The only control
that ended a live hand was Next Hand, which says out loud that it takes the money
off the table without paying anybody. That is the right answer for an abandoned
pot and the wrong one for a misdeal, where the blinds and the calls belong to the
people who put them in.

Undo covers the first second of it: "↶ dealing the hand" is one tap. But the
action history lives in memory, so a dealer who is three calls in, or who has
refreshed the page mid-hand, has nothing — and a refresh mid-hand is exactly the
situation this tool has spent rounds making survivable everywhere else.

Misdeal appears in the header while a hand is out and goes away when it isn't:
before the deal there is nothing to take back, and afterwards it would be
offering to unwind a hand that has already been paid. It says what it will do to
the money — "$25 goes back to the players who put it in, cards are cleared, and
the same button deals again" — and adds a note when two or more players have
already acted, because most rooms let a deal stand at that point. It doesn't
refuse: which conditions make a misdeal is the dealer's call and the sheet is
right there.

What comes back is everything, including chips already swept into hand totals on
an earlier street — the first version returned only the current street's and
would have quietly kept a flop's betting. What stays is the button, the blinds,
the straddle and anyone posting in: a misdeal is the same hand dealt again, not
the next one, so the hand number doesn't move and nothing is logged.

13 new tests, with the sweep and the straddle broken on purpose to confirm they
catch it. Measured through the UI: pot $25 with three players in, take it back,
every stack is exactly $200 again, the bank still balances, and dealing again
posts the same blinds on the same players from the same button. 156 audits with
the button live, including a landscape header that stays one row at 46px.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 08:23:27 -06:00
jheaps c36d7f60bd Add the cash-out round to the release 2026-09-02 08:09:26 -06:00
jheapsandClaude Opus 5 f966d8c340 Pay somebody out and keep the chair, the cash and the record straight
The most ordinary thing that happens at a home game after midnight — somebody
counts up and goes home — and the tool had nothing for it. Freeing the chair
meant wiping the seat by hand, and the bank check then declared their whole stack
missing: "someone has paid for chips they haven't been given", for the rest of
the night. Measured on a balanced table, cashing a player out with $350 in front
of them left the check $350 short. Wiping their buy-in as well silenced it, at
the price of losing their entire night from the standings and from the file the
dealer hands over at the end.

Cashing out is two facts, and both have to survive the empty chair: the box took
their cash, and the box handed their chips back. Both are kept now, so the chair
opens and the money still adds up.

Cash out… is in the seat menu. It says what to count — "$350 — 3×$100, 2×$25" —
what they bought in for, and whether they leave up or down, because that is the
sentence the dealer says while their hands are moving. The bank check gains a
Paid out line and reads "balanced ... once the $350 paid out to players who have
left is counted", alongside the discards and the house's cut. The standings keep
them with a stripe and the words "· paid out", so nobody counts money out to them
twice at 2am, and the export marks them the same way.

It refuses to pay anybody with money still in the middle: taking chips out of a
pot other people can win, or handing the same chips over twice, are both worse
than waiting for the hand to finish. The chair gets a fresh id, because the
person who had it has gone and the next player to sit down is not them.

Two things this turned up. The first draft worked out what a seat was holding by
itself, read `Number(null)` as a perfectly good zero, and offered to pay a player
with no stack set nothing at all — it asks Betting now, which owns that rule.
The second was mine and the audit caught it: marking the departed row with
opacity took the In and Won cells to 3.83:1, under AA, on the very list a dealer
settles from. It is a stripe instead.

19 new tests, with the departed buy-in dropped from the check and the pot guard
disabled on purpose to confirm they fail. Walked through in the browser: menu,
confirm, payout, bank, standings, undo, and the refusal.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 08:08:46 -06:00
jheaps 15cec18256 Add the clock round to the release 2026-09-02 05:40:45 -06:00
jheapsandClaude Opus 5 0e2821257f Run the clock on a player, instead of counting in your head
The bar has had a stopwatch on the acting seat for a while: how long this player
has been thinking, so the dealer knows when it's worth a nudge. What it never had
was the thing a dealer actually does about it. Calling the clock is a procedure —
a player asks, the dealer grants it, the player gets a set time and then a
countdown said out loud, and if they still haven't acted the hand is decided for
them. Running that from this tool meant counting in your head while the table
argued about whether it had been thirty seconds yet.

Thirty and then ten, which is the common form and what the TDA writes down. The
countdown is the part the table hears, so it is marked apart rather than being
ten more seconds of the same: the readout switches to a bare number and turns
red. At zero the bar says the ruling in the dealer's own words and offers it as
one button.

The ruling depends on what they were facing, and getting that backwards is the
part worth being careful about. Facing a bet, the hand is dead. Facing nothing,
it is checked — nobody gets folded for being slow when checking was free.

Three things about where it sits. The button appears only once somebody has been
thinking long enough for the table to start asking, which is the same threshold
the stopwatch already marks, so an ordinary hand costs no room at all: measured
at 390x844, the bar is 152px with or without it. It is 44px like everything else
a thumb has to find — it was drawn at 36 and the audit called it at every touch
viewport the moment the check ran, which is the rule doing its job on a control
that kills a hand. And once the count is over the Stop button goes, because there
is nothing left to stop: at 640x360 its 94px pushed the undo control onto a
second line and the taller bar covered the two bottom seats.

The ordinary Fold/Check/Raise stay underneath the ruling. A player who speaks up
as the count ends is still acting, and whether it was in time is the dealer's
call, not the tool's.

11 new tests, and both halves of the ruling were inverted on purpose to check
they catch it. Watched end to end in the browser at shortened intervals: the
button appears, the count runs down, the ruling lands, and the hand is killed —
or checked — with the undo control naming what happened.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 05:40:06 -06:00
jheaps 9c8d981e2d Add the house-take round to the release 2026-09-02 05:13:11 -06:00
jheapsandClaude Opus 5 c19575ab10 Count the house's cut instead of calling it missing chips
A game that takes a rake has chips leaving the table every hand, and the bank
check had no idea. It added up the buy-ins, added up the chips on the felt, and
told the dealer somebody had been paid for chips they were never given — a
shortfall that grows all night and points at a miscount that never happened.
Every home game that takes a drop for the beer fund hits this on hand one.

So the tool takes it, rather than watching it go. Under Table Setup: a
percentage, an optional cap, and "no flop, no drop" — the rule almost everyone
plays, on by default, where a hand that ends before the flop pays nothing. Zero
percent is a game that doesn't rake, which is most of them and the default, and
nothing about that game changes.

Where it shows up is the point. The pot wizard says "House takes $2 of $40 — 5%,
capped at $5" above the payouts, before a chip moves, because the summary is the
dealer's last chance to disagree with the arithmetic and money taken quietly is
the one thing a tool like this must never do. The bank check gets a House take
line and says "balanced ... once the $2 in the house's cut is counted". The hand
log keeps the pot as the table saw it with the cut recorded beside it, and the
export has a column for it, so the night's total is one sum in a spreadsheet.

Two decisions worth stating. The rake rounds *down* to a chip the table actually
has: a house that can't make 40c takes nothing and the players keep it, which is
the only way to be wrong about this that nobody argues with. And on a split pot
it comes off the top, then what is left is shared in proportion — with the
rounding leftovers going back to the winners rather than to the house, which
asked for a number and gets that number, not that number plus dust.

Measured end to end through the UI: 5% capped at $5 on a $40 pot pays $38 and
banks $2; four stacks plus the cut come to exactly the $800 bought in; the bank
check reads balanced. With no-flop-no-drop on, a hand folded round pre-flop pays
the full $15 and takes nothing; with it off the same hand pays $14 and takes $1.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 05:12:33 -06:00
jheaps 907e0165db Add the stadium ring round to the release 2026-09-02 04:59:37 -06:00
jheapsandClaude Opus 5 8aa90e90a4 Give a phone on its side the table shape it needs
The last two rounds left one thing failing and said so: a landscape phone with
six or twelve seats stacked two of them 25px apart at each end of the felt, with
pucks 64px tall. It has been there since seats were first spaced by distance
rather than by angle. Three things were wrong at once, and it needed all three.

The rim is the wrong shape when the table is very flat. An ellipse 616px wide and
96px tall has nowhere to put the seats near its ends: the curve doubles back on
itself, so two seats a long walk apart come out one above the other. Past two and
a half times wider than tall the rim is now a stadium — two straight rails with a
half-round cap at each end, which is how a real poker table is built and exactly
why it is built that way. A cap is a half-circle of the felt's own height, so an
end takes one seat and the rest is rail. Only past that ratio: measured, the
cut-off lands between a landscape phone at 4.4 and every other layout the tool
has, 1.45 at 1280x900 and 1.0 to 1.4 upright, so no working table changes shape.

The ring had less height than the felt did. The seat layer is inset by half a
puck to keep the outermost seats on screen, and 26px off the top and bottom of a
96px felt left 44px to work with — less than half the table, which is why no rim
shape could have fixed it alone. The inset is a percentage where the felt is
short. The landscape branch never had that rule at all, because it is matched on
height while the inset lives in a width query, so an 844px-wide phone on its side
drew its ring against the felt's whole box; with a flat-topped stadium rail that
put a dealer button over Resolve Pot in the header.

And the puck was a line too tall. In the tight state the hole cards give up their
row: of the four things on a puck, the cards are the one the dealer is holding in
their other hand, and the seat editor still shows them. That took the puck from
64px to 55px, which is what finally cleared the ends.

Measured across 36 combinations of nine viewports and 2, 6, 9 and 12 seats: the
closest two seats at 640x360 go from 25px apart to 55px, and every combination is
clean. Then 216 audits across thirteen viewports, three seat counts and both a
mouse and a thumb: no failures, no console errors.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 04:58:59 -06:00
jheaps 86e1570618 Add the phone felt round to the release 2026-09-02 04:34:05 -06:00
jheapsandClaude Opus 5 dc5db16ffd Let the phone table use the width it has
Last round capped the felt so the ring would clear the action bar, and capped the
wrong dimension. The wide layout sizes the felt from its width, so capping the
width is how you shorten it there — on a phone the felt is already the full width
of the column, and capping that threw away 96px of a 360px screen: a 240x240
table inside a 336px space, with nine pucks crowding it.

A max-height instead leaves the width alone and lets the aspect ratio give way,
which turns a short screen into a wide oval — the shape a poker table is. At
360x640: 336x240 rather than 240x240, same clearance over the bar, and the
twelve-seat ring that was crowded at 240 comes out clean, which closes the one
combination left failing last round. Where the room isn't the binding constraint,
390x844 and up, nothing changes and the table stays round. The landscape branch
now says max-height: none, because it already spends the same measured room on
its own height and applying it twice took the felt to the floor value.

The measurement that decides whether pucks shrink was also wrong, in a way that
only showed up once it was asked a hard question. Rim length per seat measures
the walk between two seats, not the gap between them, and on a long oval the rim
doubles back at the ends: twelve seats at 640x360 had 193px of rim each and sat
60px apart. It now measures the closest two neighbours directly, off the ring
itself — and off the seat layer rather than the felt, because the layer is inset
by half a puck on a phone and the felt's box put the answer out by 30px.

That makes the rule geometric rather than about screen size, so it is no longer
scoped to a phone media query: a wide landscape felt crowds at its ends while its
rim claims there is room, and now it is told so.

Pucks still touch at nine and twelve seats on a small screen. Tried 46px against
58px: on a roomy phone 46 cleared every overlap where 58 left two, but in a hand
with a side pot, where the bar has grown and the felt has shrunk to suit, 46
overlapped four times and 58 twice. That is the felt being small, not the puck
being wide, and paying for it with an ellipsis on every long name buys nothing.
What has to hold is that each seat keeps its own centre, and that is measured at
every size.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 04:33:25 -06:00
jheaps 6df400322e Add the audit round to the release 2026-09-02 04:03:25 -06:00
jheapsandClaude Opus 5 b9b2237dbe Run the two checks the audit had and never used
runAll() did not include the contrast check — it simply wasn't in the list — and
touch targets sat behind an opt-in flag nothing passed. So "the audit is clean"
has meant nine checks rather than eleven for a long run of rounds, and the two
left out are the two whose faults a dealer meets in a dim room with their thumb.
Both were written, tested, and then never called; the contrast checker was built
across two rounds and every sweep since has been silent about colour.

The standalone runner had a second version of the same hole: the 44px rule
stands itself down where `hover: hover` says there's a pointer, and a headless
desktop browser is a pointer, so a single pass had never measured a touch target
at any viewport. It now runs every viewport twice, once with a mouse and once
with a thumb.

A check that decides it doesn't apply now says so, rather than reporting an empty
offenders list — which reads exactly like a pass.

Faded text is measured instead of filed as unchecked. `opacity` on a box fades
the box and its text together onto whatever is behind it, so a dimmed seat is not
grey text on a dark puck: it is a faded puck and faded text both landing on the
page. Both halves are composited that way now. Two exemptions remain and both say
so out loud: a disabled control, which WCAG itself excuses, and a link inside a
sentence, whose size is set by the line it sits in.

What running them found, and this round fixes:

  - the chair number on every seat: #8a8a8a at 9px, 3.39:1 once the empty seat's
    fade is composited, on the label that answers "which seat is that?";
  - Quick Split's amount field at 37px tall and its delete button at 30px across,
    on a page whose targets had never been measured;
  - and on a 360x640 phone with the bar grown by a side pot, the bottom seat
    sitting 38px inside it.

That last one brings back a cap this project measured and rejected once: capping
the felt to the room left fixed the six-seat ring and broke the nine-seat one.
That trade was real then and isn't now — seats are spaced by distance around the
rim rather than by angle, so a shorter felt no longer piles them up. Re-measured
across 30 combinations of five phone sizes and 2 to 12 seats: every one clean
except twelve seats at 360x640, where a 240px felt and a 64px puck cannot both be
true. Where the rim can't give each seat a puck's width, the pucks come down a
size, measured from the rim itself rather than from a media query.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 04:02:43 -06:00
jheaps ca9ac7b6af Add the straddle round to the release 2026-09-02 03:32:58 -06:00
jheapsandClaude Opus 5 fc866597eb Deal the straddle, instead of leaving the dealer to fake it
A straddle is a blind raise put out before the cards by the player left of the
big blind, for twice it. Home games run them constantly, and the tool had no
idea they existed — so a dealer's only way through was to deal the hand and have
the straddler "raise" to $4, which gets the money right and the other two rules
wrong.

There are three at once:

  - the amount doubles as it goes left, and re-straddles carry on doubling;
  - first to act is left of the *straddler*, not left of the big blind, and the
    straddler keeps the option when it comes back round even if nobody raised;
  - the minimum raise is twice the straddle. Measured off the big blind
    underneath it, a $2/$4 game prices the next raise at $6, and every player at
    the table will argue — correctly.

Tap Straddle on the deal prompt and the tool posts it, puts the right player on
the clock and prices the raise. The seat wears an STR badge for the hand, read
off what the deal actually posted rather than off the count, so the badge can't
outlive the chips it stands for. It's a one-hand decision, like posting in, and
it clears when the button moves.

The control sits on the deal row rather than a line of its own: measured at
390x844, its own line cost 44px of felt on every deal prompt, straddle or no
straddle, and this bar is docked over the seats. Three buttons across a 360px
phone want to wrap, which made the bar 64px taller and put the bottom seat three
fifths behind it — this layout deliberately doesn't shrink the felt to suit, so
the deal button is given a 7rem floor instead, which keeps the row to one line
and the cost to nothing.

20 new tests, checked by breaking each of the three rules on purpose first: the
wrong first-to-act, the minimum measured off the big blind, and a flat rather
than doubling chain each fail a different one.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 03:32:17 -06:00
jheaps 80dae56f0d Add the landscape round to the release 2026-09-02 03:13:05 -06:00
jheapsandClaude Opus 5 b97453fba1 Seat the table properly on a phone held sideways
Landscape is the one shape this layout has never really fitted, and the audit
was calling two separate faults there.

The felt worked its own height out as `100vh - bar - 7rem`, guessing that
everything above it came to 7rem. That is 108px at 640x360, which the guess
covers, and 119px at 844x390, which it doesn't — so on an iPhone on its side the
bottom row of seats sat up to 26px inside the action bar. The measurement it
needed was already being published for the wide layout: the bar's real height,
the wrap's real offset and the bottom seat's overhang, all taken off the window.
It uses that now. The floor under it drops from 220px to 96px, because 220 was
not a size a 390px-tall window has to give — holding out for it was itself how
seats ended up behind the Fold button, which is the one thing the floor exists
to prevent.

That still left the header taking two rows, and the second row is 44px of the
390. The title block and the five buttons came to 829px inside a 765px header,
missing one line by 64px. The page title goes — the one thing on this screen a
dealer already knows — hidden from the eye rather than removed, so the heading
is still there for a screen reader. The buttons tighten across but never down:
they stay a 44px thumb target.

The seats themselves were spaced by angle, which is only even spacing on a
circle. On a 601x126 felt, ten seats at 36° apart put four of them down each
steep end, 20px apart, with pucks 72px tall — a seat's centre belonged to its
neighbour, so tapping a player got the wrong one. They now walk the rim by
distance instead, which puts them along the two long rails the way a real table
seats people. Closest neighbours go from 33px apart to 59px.

Measured at 640x360, 667x375 and 844x390 with 9-, 10- and 12-seat tables: every
combination the audit used to fail is clean, bar a 10-seat table at 640x360,
where two seats at the end of a rim that flat still overlap. Portrait, tablet
and desktop are unchanged — the floor never bound there, and on a rounder felt
walking the rim gives back the same angles it always did.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 03:13:00 -06:00
jheaps 6ca99fde6b Bring master's already-merged rounds into the release branch
Master merged #58-#61 (manual deal, less guessing, seat editor, landscape
table) while the stack kept going. Those commits are already in this branch's
history; this merge just reconciles the two shapes so the release can go in as
one.
2026-09-02 01:19:56 -06:00
jheapsandClaude Opus 5 419852a1f4 Let a dealer take the table off the device it's stuck on
The night lives in one browser's localStorage and nowhere else. A dealer whose phone
dies, or who wants to carry on from the tablet after the break, loses the stacks, the
buy-ins, where the button is and the whole hand log. The CSV export can't bring any of
it back: it records what happened, not where the table stands.

The table can now be written to a file and read back. Round-tripped through the real
UI, a hand in progress comes back mid-hand — blinds posted, the same seat on the
clock, the button where it was.

Loading replaces everything, so it asks first and names both sides rather than saying
"are you sure": how many players are in the file and when it was saved, against how
many players and how many logged hands are about to go. It goes through the same
undoable path as the header buttons, so the toast offers the way back, and the
confirm says so instead of overstating it.

Reading a file goes through exactly the same migration a save from storage does —
load() had that logic inline and it is now a function both call. A second, looser path
for imports is how a format grows two meanings of the same field.

What it refuses, each with something a dealer can act on and never a half-loaded
table: anything that isn't JSON, JSON with no seats in it, a save from some other tool
that happens to carry a `kind`, and a file from a newer version — that last one
because loading it risks reading a field whose meaning has changed, and the dealer
would find out from the money. What it accepts anyway: a bare table with no envelope,
for somebody who copied the storage value out by hand.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 01:07:13 -06:00
jheapsandClaude Opus 5 8a2fc3e540 Give undo and the card picker's buttons a fingertip to land on
The first thing the repaired audit found, run on a real touch context at 44px: three
controls a dealer taps on a phone that are too small to tap.

    .actionUndo             145x30   the "I recorded that wrong" pill
    #cardPickerClearBtn      50x40   wipes a player's cards
    #cardPickerCloseBtn      50x40   closes the picker

Undo is the worst of them. It is the control a dealer reaches for having just entered
the wrong action with five people waiting, and it was fourteen pixels short of a
fingertip. The picker's two are short because the touch rule that fixes every other
small button is scoped to .panelBody, and the picker is a sheet — it never reached
them. One of them is destructive.

All three now sit at 44px on touch. Nothing changes on a pointer device.

I tried an invisible hit-area pad on undo first, to avoid spending bar height, and the
measurement said it did nothing: the pill clips its overflow to keep a long label on
one line, and giving it a positioning context made it clip the pad too. Growing the
box is the honest fix, and it turned out to cost nothing anyway — the bar goes from
138px to 152px and the felt stays at 366px, because the felt had slack against its
220px floor rather than sitting on it as I had assumed.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 00:55:56 -06:00
jheapsandClaude Opus 5 b64da7a787 Make the layout audit usable inside modals, and stop it passing what it can't measure
Last round turned up that Quick Split had never been audited. Chasing that, the reason
the dealer table's modals had never been audited either turns out to be the harness,
not neglect: it reported the pot wizard covering the seats as ten faults and the
rulings sheet as seven. A modal covering the page is what a modal is for, and an
instrument that calls that a fault is one nobody can use in those states.

It now finds whatever is open on top and scopes the reachability checks to it — the
overlays, plus the phone seat sheet, which is a body class rather than an element. The
seat-ownership check is skipped outright while a sheet is open: seats behind it aren't
fighting each other for taps, nothing is meant to reach them at all.

Two worse faults came out of proving the fix rather than assuming it. Injecting a 20x14
button into the open wizard, the contrast check caught it and the touch-target check
did not:

  * effectiveTarget returns null when elementFromPoint can't find the control, and the
    check treated that as a pass. Silent skips are how faults hide — the same lesson as
    the aria-hidden and faded-text exemptions before this. Unmeasurable controls are
    now reported as warnings.
  * The probe failed on anything scrolled out of its container. That was hiding a real
    control: "Confirm Winner(s)", the button that pays the pot out, had gone unmeasured
    for thirty-six rounds. It is scrolled into view before probing, and passes.

A checkbox inside a label is now measured by its label, since tapping the label is what
activates it. Without that, the dedicated-dealer toggle read as an unprobeable 24px
target when it has a 335x44 label around it.

The tool itself came out clean: no failures and no warnings across the table, the pot
wizard, the rulings sheet and the seat editor, on a real touch context at 44px. The
value here is that those states can be checked at all from now on — verified by
injection, which the fixed check catches and the old one did not.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 00:37:47 -06:00
jheapsandClaude Opus 5 3be93cdadf Chop a side pot on the screen that worked it out, and fix Quick Split's contrast
Quick Split lists the pots when somebody is all-in — main, sides, uncalled — and then
leaves the dealer there. Two players tying for a side pot is ordinary, and working out
each one's share meant switching to the other mode and retyping an amount the screen
had just calculated. Retyping a number with money on it is a transcription error
waiting to happen, and the number was already on screen.

Each pot with two or more people who can win it now carries a Chop, opening underneath
its own row: as many ways as there are contenders by default, a stepper for a chop
between fewer of them, and the same odd-chip and dust wording the main split uses,
because it is the same rule and the dealer shouldn't have to notice they are reading a
different screen. Per-row state, so opening the main pot's chop doesn't disturb what
the side pot below it says. Not offered on the uncalled row, which goes back to one
player, nor on the total.

Separately, this page had never been through the layout audit the dealer table gets
every round, and it was carrying nine contrast failures:

    .unevenCurrency   4.11:1   the $ beside every amount typed
    .unevenRemoveBtn  3.66:1   the ✕ that deletes a player's entry
    .splitFootnote    3.94:1   the instruction for what to do with the pot list

All under the 4.5:1 small-text minimum, the worst of them on a destructive control.
Lifted to pass. The large $ on the even-mode pot input is left alone: at 1.6rem it is
large text, where 3:1 is the bar and 4.11:1 clears it.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 00:24:07 -06:00
jheapsandClaude Opus 5 40fa16920b Offer bet sizes the table's chips can actually make
Half the pot lands on a half chip constantly. Pre-flop at 1/2 the honest half-pot is
$4.50, and the smallest chip in the default legend is a dollar — the tool's own chip
breakdown calls it "4×$1, $0.50 no chip covers". The button offered it anyway, so the
dealer rounded it themselves every single time.

The pot-derived quick amounts now round up to something the table can build. Up, not
down or nearest: these are bet sizes, a pot-sized bet that has been shaved is no
longer a pot-sized bet, and rounding up cannot drop an amount below a legal minimum
the way rounding the other way can.

Only when the legend describes this game. A smallest chip larger than the big blind
means the denominations were never set for these stakes, and rounding a 5c/10c table
up to whole dollars would be the tool doing real damage on the strength of a field
somebody forgot to fill in. Those tables keep exact amounts, and the wrong legend
stays visible rather than being quietly applied. A table with no legend at all is
told nothing about chips and so has nothing applied either.

Verified across five tables: default dollar chips at 1/2 give $4/$5/$7, cent chips at
5c/10c keep $0.20/$0.23/$0.35, five-dollar chips at 5/10 give $20/$25/$35, and both
the stale-legend and no-legend tables are left exact.

The minimum raise is deliberately not rounded. It is what the rules require, and
moving it to suit the chip tray would change what is legal rather than what is
convenient.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-02 00:05:26 -06:00
jheapsandClaude Opus 5 09eac536b5 Make the pot button offer a pot-sized raise
The raise pad worked its quick amounts out as "the current bet plus the pot as it
stands". That leaves out the raiser's own call, which is in the pot before the raise
is measured off it, so every amount came out short. Measured pre-flop at 1/2:

    offered:  Min $4    ½ pot $4    Pot $5
    correct:  Min $4    ½ pot $4.50  Pot $7

A pot raise there is: call the $2, which makes the pot $5, then raise $5. And the
half-pot was so short it clamped up to the minimum and sat next to the Min button
showing the same $4 twice, which is how it went unnoticed — it looked like a table
where those two happened to coincide.

"Pot" is not a suggestion. It is a definite number players say out loud, and a dealer
tapping the button that claims to be it should be recording what was actually said.

The rule moves into Betting.potRaiseTo, next to the other rules and testable on its
own, rather than living as arithmetic inside a DOM renderer:

    raise to = the current bet + (pot + what they owe) × fraction

Facing no bet it comes out as a plain pot-sized bet, because both the bet and the
amount owed are zero. Facing a bet it comes out as three times the bet plus the dead
money, which is the shape a player checks it against.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 23:51:41 -06:00
jheapsandClaude Opus 5 8efc14f61f Show the banker the amount to count out, not just whether the player is up
The standings had a column headed "Cash out" and it did not hold an amount to pay.
It held the difference against the player's buy-in. Measured on a table where Ann
bought in for $200 and sits behind $340:

    PLAYER   NET   CASH OUT
    Ann      $0     +$140

She is handed $340. The column called "Cash out" says $140, under a heading that
invites reading it as the payout, at the exact moment somebody is counting money onto
the table. The number a banker's hands actually follow — what the player is holding —
was computed all along and written to the CSV, and never shown on screen.

Three columns now, and they say what they are: Player, Chips, Up/down. Chips is a
plain amount and never signed, because it is a quantity of money to hand over. Up/down
keeps the sign and the colour, because it is a result. The two differ by exactly the
buy-in, which is asserted rather than assumed.

Chips counts what is out in the middle for the hand in progress as still theirs, for
the same reason the up/down figure does: the panel is read mid-hand constantly and a
stack that drops by the size of a bet looks like money going missing.

Net — won less paid in, from the hand log — comes off the screen to make room. It is
the weakest of the three: it only moves when a pot is resolved through the wizard, so
at a table where the dealer settles pots by hand it reads $0 all night beside two
columns that don't, and up/down already answers "how are they doing". It stays in the
CSV export, which loses nothing.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 23:36:14 -06:00
jheapsandClaude Opus 5 c70cb150db Stop telling a brand-new table its players are broke, and give it a way out
Following on from seating the table: a dealer who names six people and hasn't yet
said what a buy-in is gets told

    Nobody at the table has chips left. Add a buy-in to deal.

with no buttons on the bar at all. Both halves are wrong for that table. "Left" says
the chips were had and lost, which sends the dealer looking for a rebuy — and a
blocked prompt with nothing to press is a dead end, when the fix is one field in the
sidebar the message never pointed at.

A table where nobody has ever bought in is now told what is actually true, and given
the way out:

    Nobody has bought in yet. Set what everyone starts with, or give each seat
    their chips.                                      [ Set starting stack ]

The button opens Table Setup, scrolls the starting stack into view and selects it, so
the next thing typed is the answer. A seat with no stack of its own falls back to the
table default, which is why one number is enough to seat everybody's chips at once.

A table that genuinely busted out is a different situation and still says so, with no
starting-stack button — there the fix is a rebuy, not a default.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 23:20:13 -06:00
jheapsandClaude Opus 5 e9bc9afcdb Seat a player when the dealer names one, and stop hiding the bar on a full table
The first thing anybody does with this tool is seat the table, and it didn't work.
Measured on a fresh table: click a seat, type a name, repeat six times — six names on
the felt, six seats still not in the hand, and no action bar at all. No "post blinds
and deal", no explanation, nothing to press. Six people at the table and a blank
screen with no way forward.

Three things were wrong and each hid the next.

Naming a chair and dealing that chair in were separate acts. Putting a name on an
empty chair is somebody sitting down, so it now deals them in. Only a chair that was
empty — no name and nobody in it: renaming someone who is sitting out is a correction,
not an arrival, and dealing them back in over a typo would be the tool overruling the
dealer.

The action bar hid itself whenever no seat was live, which was written for an empty
felt and caught a named one too. A table with names on it is not an empty table; it's
a table the dealer needs to be told something about.

And the seat editor never redrew the bar. It refreshes narrowly on purpose — a full
render on every keystroke would take the name field out from under the dealer
mid-word — but the bar answers "is there a table here at all", and seating somebody is
exactly what changes that answer. Even ticking "In hand" by hand left the bar hidden
until something else happened to redraw. Verified the fix keeps focus and the caret
where they were, typing a character at a time.

For the states that can still reach it — a table where everyone was sat out, a save
from before naming seated people — the prompt names them and offers "Deal in all 6"
rather than a hunt through six seat editors.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 23:08:42 -06:00
jheapsandClaude Opus 5 9f3d0dce4b Call who shows first at the showdown, instead of leaving the dealer to remember
Every showdown that isn't a fold needs somebody to turn over first, and it is the one
call at the table nobody volunteers for — everyone would rather see a hand than show
one. The tool held both facts the rule needs, who last raised and who sits left of
the button, and said neither. The dealer worked it out from memory while five people
waited, every hand.

The action bar now names the seat and the reason above the resolve button:

    Dee shows first — last to bet or raise.
    Ben shows first — checked round, first left of the button.
    Dee shows first — last to put money in; nobody could bet the river.

The reason is there so the call can be repeated to whoever argues with it, which is
half of what makes it stick.

Three cases, in the order the rule asks them. Somebody bet or raised on the final
round: they show. Nobody did: first live player left of the button. Nobody *could*,
because the chips were already in: it falls to whoever last put money in, on whichever
street that was — "no bet on the final round" isn't a choice anybody made when there
was no round to bet in, so the seat left of the button hasn't earned it.

Aggression is tracked per street and per hand, because those two answers differ
exactly when it matters. A short all-in counts: not reopening the betting is a rule
about who may raise, and says nothing about who shows. Posting a blind doesn't — a
hand limped and checked all the way down has no aggressor at all, which is the case
that goes to the button.

A matching ruling goes in Common rulings, since the dealer will be asked.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 22:53:14 -06:00
jheapsandClaude Opus 5 01f5139740 Let the dealer find one hand in the log instead of scrolling the night
The hand log exists for one moment: somebody at the rail says they won a pot an hour
ago and wants it looked up. A night runs to sixty hands or more and the panel showed
five of them at a time, in a box fixed at 260px whether the dealer was on a phone or
a 27" screen with room for twenty. There was no way to ask for a hand — only to
scroll and read every row.

A filter box over the list now takes a name, an amount or a hand number. Matching is
loose on purpose: "fay" whatever case it's typed in, "190" for a $190 pot or a $190
payout, "$1,90" for the same typed badly, "#12" when the dealer knows the hand and
wants that exact one. Beside it, how much of the night is hidden — "8 of 60" — since
a filter matching one hand otherwise looks like a table that only played one.

The list also takes the room it has: clamp(200px, 42vh, 560px) rather than a flat
260, which is seven or eight rows instead of five on a desktop and no worse on a
phone.

Export stays the whole night, always. A CSV quietly missing the hands scrolled past
would be a record with holes in it and nothing on the file to say so — if a filter is
up when it's exported, the toast says how many went in.

Matching deliberately covers only what the row displays. Searching the full ledger as
well was the first attempt, so the hand that broke somebody could be found under the
loser's name — but the ledger holds everyone who put a chip in, and measured on a
seven-handed night where nobody folded, "Fay" returned 60 of 60. A filter that
searches text the panel doesn't show looks broken even when it's right.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 22:39:55 -06:00
jheapsandClaude Opus 5 c76ebf4d18 Say when somebody is being dealt in for free, and give the dealer the post
Sitting down between the button and the big blind means the blinds have gone past
you. The tool dealt those players straight in, silently. Measured: Eve takes a seat,
and the next hand she is dealt cards in late position having put nothing in — a free
hand, out of everyone else's night a couple of dollars at a time, with nothing
anywhere on screen to tell the dealer it had happened.

At a table there are two answers and the player picks one: post the big blind and
come in now, or wait for the blind to reach them. The tool did neither and named
neither.

The deal prompt now names anyone about to be dealt in who has not paid for it, and
carries the post as one tap. Nothing is forced — a home game that doesn't care can
deal as before, and the sentence is still worth having on its own, because the
dealer at least knows. The post goes in as a big blind out of that player's own
stack, is live like any blind, and is spent at the end of the hand rather than
becoming a standing charge.

Somebody back from sitting out is owed one the same as a newcomer. The big blind of
the hand isn't — they're already paying — and neither is anyone at a table that has
not dealt a hand yet, where the whole table is new and nobody has been skipped.

Deliberately one line for however many have walked up, not one each: the bar is
docked over the felt and the felt has already shrunk as far as it goes on a phone,
so every line costs seat area. A line apiece put the bar over two fifths of the
bottom seat at 390x844.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 22:27:22 -06:00
jheapsandClaude Opus 5 cc060b29b8 Keep the big blind moving one player at a time when somebody leaves out of the blinds
Passing the button to the next player is right until a chair between the button and
the blinds empties. Then it pulls the whole blind order forward a place. Measured,
four-handed, button on Ben, blinds Cal and Dee, with Cal cashing out:

    tool:    button Dee, small blind Ann, big blind Ben
    correct: dead button on Cal's chair, small blind Dee, big blind Ann

Ann is never asked for the big blind she owes, and Ben pays one a full orbit early.
That is a real big blind out of a real stack, and it happens every time somebody
stands up out of the blinds — which in a cash game is constantly. It is also the
kind of thing the dealer gets blamed for, because the tool is the thing everyone at
the table is reading position off.

The dead button rule holds the rotation to the seats for one hand instead of to the
players. The button follows the small blind round, which at a table with nobody
missing is the same next-player-along it always was; when that player has gone the
button rests on their empty chair for a hand, and when the big blind has gone no
small blind is posted. Either way the big blind advances exactly one player, which
is the whole point of it.

Worked out from who actually posted last hand rather than from the seating as it
stands, because by the time the question is asked the people it turns on are the
ones who have left. Heads-up is exempt — the button is the small blind there and
there is no position to leave dead — as is a table too sparse to have a rotation to
hold to, and a dealer who moves the button or names a blind by hand overrules it.

Both dead positions are said out loud rather than left to look like a fault: the
deal prompt explains them in a sentence, and the dead button goes hollow and dashed
so the felt agrees with the words.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 22:04:57 -06:00
jheapsandClaude Opus 5 3149b48c14 Remember the pots the dealer throws away, so the bank check stops blaming them
Starting the next hand with money still in the middle destroys it. The tool asks
first, and says afterwards how much went. Nothing wrote it down, though, so when
the bank check came round at cash-out it saw only that chips were gone:

    $5 more cash than chips in play. Someone has paid for chips they haven't
    been given.

and settle-up told the dealer to fix it before anyone cashed out. Both were
accusing the dealer of a miscount for money the tool itself had removed, at the
dealer's instruction, minutes earlier — the one kind of shortfall that isn't a
mistake, reported as the one kind that is.

Discards are now recorded on the table as they happen (undoably, with the rest
of the Next Hand step), the reconciliation counts them alongside the chips, and
the panel shows a "Taken off the table" line so the number is visible rather
than merely subtracted. A genuine shortfall on top of a discard still reports,
at its true size — the discard accounts for its own $5 and not a cent more.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 20:27:46 -06:00
jheapsandClaude Opus 5 cb057b5159 Stop the think-clock claiming things it can't know
The clock beside the acting player told two lies, in opposite directions.

Refresh mid-hand and it restarts at 0:00. The clock lives in memory, so a reload wipes
it, and the bar then says the player has just started thinking when they might have
been in the tank for two minutes. It is the one number in the bar whose whole job is to
tell a dealer when to nudge somebody, and a refresh reset it to "no need".

It cannot know that time -- the action moved before this session existed -- so it does
not guess. It shows a dash until the action next moves, and says why if you hover:

  "The action was already on this seat when the page loaded, so there's nothing to
   time from yet. The clock starts when the action next moves."

The moment the action moves it is measuring something it saw, and it counts from zero
again.

And past an hour it read "65:03". A table left open on a hand really does get there --
the state survives a refresh by design -- and sixty-five minutes written as though it
were minutes and seconds is a number the eye gets wrong. It reads 1:05:03 now.

Checked in the browser rather than reasoned about: dealt in this session it counts 0:00
then 0:02; refreshed mid-hand it shows the dash and keeps showing it rather than
drifting up from an invented zero; one action later it is back to a real 0:00; and with
the clock wound forward it reads 1:05:04 with the slow-tank styling still applied.

5 tests (283 total) on the formatting, including that minutes are padded once there are
hours and not before, and that a day reads 25:00:00 rather than wrapping.

Also checked and correct already: a backgrounded tab does not lose time -- four minutes
away reads 4:01 on return, because the clock is a timestamp difference rather than a
count of ticks -- and the timer disappears entirely once the hand is over.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 20:06:42 -06:00
jheapsandClaude Opus 5 ab92259e3e Put the dealer's own notes in the file they hand over
The CSV export carries the hands, the standings and the settle-up. It carried neither
of the two places a dealer types context.

Each seat can hold custom fields -- "IOU: $20 to Ann", "Seat rent: paid to 2am",
"Venmo: @ann" -- and the table has a notes box for the rest: side bets, a high-hand
promo, who owes the box from last week. Both are typed precisely because they matter to
settling up, and both were visible only inside the tool. The one place the context was
written down was the one place it could not leave.

Now the standings rows end with a Notes column carrying that seat's fields, and the
table's notes go in a section of their own at the end.

  Player,Paid In,Won,Net,Bought In,Chips Now,Up/Down,Notes
  Ann,20.00,60.00,40.00,200.00,150.00,-50.00,Seat rent: paid to 2am; Venmo: @ann
  Ben,20.00,0.00,-20.00,200.00,200.00,0.00,IOU: $20 to Ann
  Cal,20.00,0.00,-20.00,200.00,250.00,50.00,

  Table Notes
  "High-hand promo until midnight.
  Mo owes the box $15, from last week."

Notes go last so the money columns keep the shape a spreadsheet expects, and the
multi-line note is quoted as one cell rather than becoming rows that read as more
players.

An existing test caught this, correctly

"every money cell parses as a number" asserted that every column after the name was
numeric, and a Notes column is text by definition. The invariant it protects is real --
the file exists so somebody can total a column -- so it now names the six money columns
and checks those, rather than "everything after the first". That is the assertion it
always meant.

9 tests (278 total): a seat's fields ride along, the money columns keep their order, a
seat with nothing written gets an empty cell rather than the word undefined, a blank
field does not leave a stray colon, a multi-line note survives as one cell, no notes
means no section rather than an empty heading, and asking for the standings does not
change the seats.

Driven through the real Export CSV button, not just the function: the downloaded file
carries both seats' notes and the table's. 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 19:52:20 -06:00
jheapsandClaude Opus 5 65f201cdfd Show each stack as the chips that make it
The chip legend has only ever read chips *into* an amount -- counting what is in front
of somebody. Colouring up at the end of a level is the same question backwards, and it
is the one the dealer answers with a rack in one hand: $205 is two hundreds and a five.
So is counting a stack out for a player who is leaving.

The arithmetic already existed. Payouts have been shown in chips since #73; this points
the same function at the stacks.

  Stacks in chips
    Ann — $205        2×$100, 1×$5
    Ben — $1,337      2×$500, 3×$100, 1×$25, 2×$5, 2×$1
    Cal — $40         1×$25, 3×$5
    Eve — $88         3×$25, 2×$5, 3×$1
    Fay — $500        1×$500

Sits under the chip legend, because that is where the denominations it works from are
set, and it only appears when there is both a legend to work from and somebody with
chips. A table that never set its chip colours up gets nothing, which is what it had
before. A seat on zero is left out rather than listed as nothing.

Driven at five sizes across touch and pointer contexts, with a 26-character name and a
$1,337,000 stack: no row spills, no page overflow, contrast clean, layout audit clean
but for the landscape phone already known from #63, no console errors. Every breakdown
checked by hand against its total -- 500+200+75+2 = 777 and so on.

The breakdown itself is covered by the twelve tests from #73; what is new here is which
seats are listed, which the browser run covers: with a legend and stacks it lists five
of six seats, with the legend emptied it disappears, and with every stack zeroed it
disappears. 269 tests still passing.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 19:36:29 -06:00
jheapsandClaude Opus 5 d297c1a2c9 Close two blind spots in the contrast check
Last change found a folded player's stack sitting at 2.22:1, hidden for twenty rounds
behind an exemption. So I went looking for the other exemptions rather than waiting for
the next one to surface. There were two, and neither is hiding a live fault today --
which is worth saying plainly, because it means this is a fix to the instrument and not
to the tool.

Leaf elements only

    if (el.children.length > 0) return;   // leaf text only

An element's own text stopped being measured the moment a child was added to it. The
undo control went that way two changes ago, the day a <kbd> was appended to it: its
label became invisible to this check with no sign that anything had changed. Now it
measures an element's own text nodes and leaves the descendants to their own turn,
against their own colours.

aria-hidden as a contrast exemption

The bets layer is aria-hidden because the amounts on the chips are already announced by
the seat and the action bar, and repeating them would be noise. That is a good reason
to hide them from a screen reader and no reason at all to stop checking whether a
sighted dealer can read them -- and the chips are exactly what the felt is read for.
Two different questions were being answered by one attribute.

Nothing is exempt now. If something genuinely decorative turns up, it should say so in
its own words rather than borrowing an attribute that means something else. The
watermark that the exemption was written for is gone as of the change before last.

What the widened check finds

Nothing. Zero offenders at six viewports across touch and pointer contexts, and with
the sidebar panels and the rulings sheet open. Measured from rendered pixels, the two
things that had been invisible are comfortable: the bet chip amounts read 12.65 to
13.74:1 and the undo label 8.06:1.

So the fix is proved by injection instead. Colouring the bet chip amounts #3a3a3a now
reports three offenders at 1.80:1, and colouring the undo control #2f2f2f reports it at
1.13:1. Neither was reported before.

269 tests still passing, no console errors.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 19:21:06 -06:00
jheapsandClaude Opus 5 f0cfd24647 Make the seats that are out of the hand readable again
A folded player's stack measured 2.22:1.

The contrast audit has been reporting clean for twenty rounds, and it never looked at
these seats: the check skipped anything with opacity below 0.99 as "faded on purpose",
and folded and sat-out seats faded their whole box -- 0.5 and 0.58. Opacity on a box
fades the text you still have to read along with the box you wanted to push back.

Measured off rendered pixels, 11.5px text, where AA asks 4.5:1:

                  name    stack
  live            13.28   4.75
  folded           4.75   2.22
  sitting out      7.00   3.22

A folded player's stack is not decoration. It is what they will have next hand and the
answer to "how much has Ben got?", and at 2.22 it was barely there.

So the box recedes and the text does not: a flatter, darker background and a muted
border, with name and stack colours that measure.

                  name    stack
  live            13.28   4.75   (unchanged)
  folded           7.60   4.55
  sitting out      7.60   4.71

Taking the fade away took the signal with it, which the screenshot showed and the
numbers did not: a sat-out seat became almost indistinguishable from a live one. A
folded seat still says so twice, with a red border and a line through the name, but
sitting out had only faintness. It wears a dashed edge now -- the same thing an empty
chair wears, which is right, because neither is in this hand, and the name and stack
tell the two apart.

The same fault, found once the audit stopped hiding it

The key hints on the action bar are 9.6px and were measuring 2.97 to 3.33 against
their buttons -- a fade chosen for looks pushing text a dealer is meant to read under
the threshold, exactly like the seats. They are 4.93 to 7.63 now, still quieter than
the labels they sit beside.

And the audit no longer passes over faded text in silence. Something invisible is still
exempt, since there is nothing to read at zero opacity, but anything in between is
reported as unmeasured -- a number nobody checked is not the same as a number that is
fine. It now lists five: the four key caps and the disabled Export CSV button, which is
disabled and therefore genuinely exempt.

269 tests still passing. Contrast offenders zero, layout audit clean, no console
errors; the folded seat's red line-through and the sat-out seat's dashed edge both
confirmed in cropped screenshots rather than assumed.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 19:10:06 -06:00
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
jheapsandClaude Opus 5 cfb83a2ba6 Don't quietly write over a save that couldn't be read
Last change made a failing save say so. This is the other direction, and it turned out
to be worse.

load() is well defended -- try/catch, a shape check, sensible fallbacks -- and every
one of those paths ends in `return this.createDefault()`. So an unreadable save becomes
an empty table. Driven with a save truncated to 70% of its length, the way an
interrupted write leaves one:

  1762 bytes stored, 1233 after truncating
  loaded: 0 named seats, hand #1 instead of #34
  toast: none.  warning: none.  anything on the page: no.

A dealer mid-session sees an empty felt and cannot tell whether they mis-tapped Reset,
whether the browser lost the night, or what.

And then the first edit destroys the evidence. I typed a name into a seat and storage
went from the 1233 unreadable bytes to 2549 bytes of fresh default state. Whatever a
person could have picked out of that JSON -- the names, the buy-ins, the standings --
was gone one tap after the tool decided it could not read it.

Now the unreadable value is moved aside to its own key before the fallback, and the
table says what happened:

  toast:  "The saved table couldn't be read, so this one started empty."
  panel:  "The saved table couldn't be read, so this one started empty. The
           unreadable copy has been kept rather than written over."

It is deliberately not offered as a restore. Half a JSON document is not a table, and
pretending otherwise would be worse than an empty one. It just stops being destroyed.

The two warnings share the Session panel line and both can show at once -- a browser
having trouble with storage tends to have it in both directions -- with the load
message first, because "why is my table empty" is the question the dealer is standing
there asking.

8 tests (263 total): nothing stored is a new table rather than a failure, a good save
loads clean, a truncated one is reported, valid JSON of the wrong shape counts too, the
copy is kept, a later save does not touch it, the listener fires once across three
reads, and storage with no room to keep the copy is not a crash -- which is exactly the
storage most likely to hand back half a value in the first place.

Driven through the UI: warning and toast on a corrupted save, the set-aside copy still
1233 bytes intact after editing a seat, and nothing shown at all on a healthy table.

One audit correction rides along. The collapsed-column check measured inside hidden
elements, so the standings header -- hidden with its table until somebody buys in --
was reported as three collapsed columns on every empty table. It skips hidden subtrees
now, and still catches a real one: squeezing a visible column reports the header and
all three rows.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 18:22:31 -06:00
jheapsandClaude Opus 5 4071be5329 Say so when the browser refuses to save
Every save goes through one try/catch, and the catch was empty:

    } catch {
        // Storage full/unavailable (private browsing, etc.) — the table still works
        // for the rest of the session, it just won't survive a refresh.
    }

The comment is accurate and the behaviour is right -- the chips have already moved in
memory and refusing to continue would be worse. What was missing is anybody being
told. Driven with setItem throwing: two saves failed, no toast, nothing on the page,
and a refresh came back to before the hand was dealt with the dealer none the wiser.

That is the same shape of fault the bank reconciliation exists for. Silent until it is
too late to act on, and the way you find out is the last two players cashing out.

Now it says so twice, in the two places that suit the two timescales.

A toast the first time, and only the first time -- a dealer taps this bar thirty times
a hand and does not need thirty warnings. Measured: four failed saves, one toast.

  "This table isn't being saved — refreshing would lose it. Keep this tab open."

And a standing line at the top of the Session panel, because a toast is a moment and
this is a fact about the rest of the night. The raise pad dismisses toasts, and the one
warning that must not be missed is not the one to leave to a toast.

  "This table isn't being saved — the browser refused. It'll keep working, but
   closing or refreshing this tab loses the session. Export CSV below keeps a copy."

Amber, like the bank check's disagreement, because both mean the same thing: your
record of the night is wrong and you can still do something about it. The export is
named because it is the thing that helps.

6 tests (255 total): a good save reports success and leaves no mark, a failing one
reports failure rather than throwing, the listener fires exactly once across twenty
failures, it is handed the error so the reason can be reported later, a failing save
still does not stop the table, and no listener at all is not an error.

Driven through the UI with setItem throwing: hidden while saving works, toast on the
first failure, zero further toasts across three more actions, the panel line present
and readable. Contrast clean, layout audit clean, no console errors.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 18:08:05 -06:00
jheapsandClaude Opus 5 8a7d7b09bf Print the two shortcuts a dealer had no way to find
Seven keys drive this tool. Five of them are printed on the control they press -- F on
Fold, C on Call, R on Raise, the return arrow on Deal and on Raise to, esc on Cancel.
Two were printed on nothing at all:

  A   opens the amount pad on All-in
  U   takes back the last action

A dealer who learned F, C and R from the buttons had no way to discover the other two
existed. They are the two worth knowing: A is the fastest action in the tool and U is
the one that saves a misclick.

Both are on their controls now. The key lookup matches any of a button's classes, so
the raise pad's four quick amounts can share one class for their looks while only
All-in carries a key.

And a legend in the sidebar, listing all seven with what they do, built from the same
list the handlers are keyed off. There is a test that every key in the map has a line
in the legend and that every line names a key that really is bound -- a shortcut with
no line is one nobody can find, which is the whole bug this fixes.

The panel is hidden on a touch device. A phone has no keys to press, and it is the
same pointer question the touch-target rules ask rather than a guess about width.

One thing I broke and fixed on the way. .actionKey pins itself to the top-right corner
of a big action button. The undo control is a 30px pill with overflow hidden and no
positioning context, so the U escaped it and floated above the action bar -- visible in
the screenshot before I looked properly. It has its own inline class now, checked at
six sizes: the cap sits inside the pill and inside the bar, and the pill is not
clipped.

5 tests (249 total). Driven at six sizes across touch and pointer contexts: the undo
pill shows U, the raise pad shows A on All-in and nothing on Min, half pot and Pot, the
legend lists all seven, and it is display:none on a phone. Contrast clean, layout audit
clean but for 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 17:52:52 -06:00
jheapsandClaude Opus 5 ebf22e3153 Shorten the rulings index to the words a dealer would say
Last change added an index to the rulings sheet and I wrote down what it cost: 342px
at the top of a 390px-wide sheet, so a dealer reading straight through scrolls past
the whole thing first. That was an honest trade at the time. It is also fixable.

The index was labelled with the section headings, which are sentences -- "A short
all-in doesn't reopen the betting" -- so nine of them stacked one per row. A dealer
hunting for that ruling is looking for the words "short all-in", not the sentence.

  Misdeal          Board card early    Out of turn
  Short all-in     Uncalled bet        Oversized chip
  Minimum raise    Odd chip            String bet

  phone       342px -> 143px   9 rows -> 3
  desktop      ~200 -> 70px            -> 2
  landscape          -> 94px           -> 2

The first ruling now starts 309px into the sheet on a phone rather than 440.

Each short label lives on its own section as data-short, next to the heading it
shortens, and the index falls back to the heading when there is not one -- so a ruling
added later still appears, still without anyone having to write it twice. The full
sentence stays as the button's aria-label, so the short label never costs a screen
reader the heading.

Checked at four sizes across touch and pointer contexts: nine labels, nine aria-labels,
every chip 44px on touch, jumping still lands and focuses the right heading, contrast
clean, no console errors. 244 tests still passing.

Also looked at, and left alone: the card picker refuses a card already dealt --
disabled, titled "Already dealt to Ann" -- and every one of its 52 buttons clears 44px.
A messy nine-seat table with 25/50 blinds, two folded players, a busted seat, accented
and hyphenated names, a $125,000 stack and a filled notes field audits clean at every
size but the landscape phone already known from #63.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 17:36:37 -06:00
jheapsandClaude Opus 5 f0994a8aed Get to the right ruling in one tap, and cover the string bet
The rulings sheet is opened mid-argument, with players waiting. It was eight rulings
of prose in a row with no way in. Reaching the last one meant scrolling:

  1.6 screens on a desktop
  2.1 on a phone
  3.5 on a phone held sideways

A dealer looking for the odd-chip rule had to read past seven others to find it.

There is an index across the top now, one chip per ruling, and tapping one scrolls its
section into view and puts the keyboard and a screen reader on its heading. Built from
the sections rather than written out beside them, so a ruling added later turns up in
the index without anyone having to remember to add it twice.

And the missing ruling

I went looking for what home games actually argue about rather than guessing, and the
answer that kept coming back alongside misdeals -- which were already here -- was the
string bet. It was the one common dispute the sheet had nothing to say about.

  Betting in one motion - the string bet

  A bet is whatever goes in on the first trip to the stack, unless the amount was
  said out loud first. Reaching back for more chips without having said "raise" is a
  string bet, and only the first motion stands. Saying the number is always enough on
  its own: "raise to sixty" binds whatever order the chips arrive in.

  It's the rule home games invoke most and enforce least, and both are fine -- the
  point is that a player can't watch the table react and then decide how much to bet.
  Say it, or push it all at once.

That second paragraph is deliberate. The sheet's whole stance is that house rules win,
so a ruling that tells a home game it must enforce something would be the wrong voice.

What it costs: the index is 342px at the top of a 390px-wide sheet, so a dealer who
wants to read straight through now scrolls past it first. That is the trade -- it is a
jump table you use once and then read, and the alternative was scrolling blind.

Driven through at four sizes across touch and pointer contexts: nine rulings, nine
index entries, every one of them 44px on touch, tapping the last one lands it on screen
and focused. Focus stays inside the sheet across fourteen tabs, Enter on a chip jumps,
Escape closes, and opening it three times still builds nine entries rather than
twenty-seven. Contrast clean, no console errors, 244 tests still passing.

Sources: cardplayerlifestyle.com and pokernews on string bets; the sheet still follows
the TDA rules it cites.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 17:21:40 -06:00
jheapsandClaude Opus 5 ddb3907ace Say the payout in chips, not just in money
"Ann - $135" is the answer to the arithmetic. The dealer's actual job at that moment
is working out that $135 is a black, a green and two reds, while four people watch
them do it.

The chip legend has known the denominations all along. It was only being used to read
chips *into* an amount -- counting a seat's stack -- and never to turn an amount back
into chips.

  Final Payouts
    Ann - $135
    1x$100, 1x$25, 2x$5

Largest first, which is greedy and so not optimal for an arbitrary set of values. Poker
chips are not arbitrary: every real set is canonical -- each denomination divides the
next, 1/5/25/100/500 -- and greedy is exactly optimal on those. A dealer who invents a
3-and-7 chip set still gets a valid answer, possibly one chip longer than it needed to
be, which is a fair trade for not solving a knapsack in a modal.

Two cases where saying nothing is the right answer, and one where it is not.

A table with no chip legend gets the amount on its own -- it has not told us its
denominations, so there is no advice to give. But a legend that exists and cannot reach
the amount does say so: "$3 no chip covers" when the smallest chip is $5. Half a dollar
in a dollar-chip game is the dealer's problem to solve and they can only solve it if
they are told, so it is never rounded away.

12 tests (244 total): the breakdown adds up to the amount across eight amounts, cent
chips make cent amounts exactly, a jumbled legend still pays largest first, the colour
comes along for a future caller, and the two no-advice cases are told apart from the
cannot-reach one.

Driven through a real all-in hand at four sizes: $135 reads "1x$100, 1x$25, 2x$5" and
a tournament-sized $1,350 reads "2x$500, 3x$100, 2x$25", fitting without spill down to
360px. With the legend emptied the line disappears and the payout stands alone.
Contrast clean, no console errors.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 17:08:49 -06:00
jheapsandClaude Opus 5 3fe16fa7f2 Let a phone move a player to another seat
The hint under the table has said this on every screen since the gestures went in:

  Tap a seat to edit - double-tap to fold - press and hold for quick actions -
  drag to swap seats.

Three of those work on a phone. The fourth never has. Swapping seats is HTML5
drag-and-drop -- draggable="true", dragstart, drop -- and a touch screen does not
generate drag events at all. So the one clause written in the same breath as "tap"
and "press and hold" was the one thing a thumb could never do.

I found it by dragging seat 1 onto seat 4 with real touch pointer events and watching
nothing happen: no swap, no menu, no error.

Moving a seat is in the long-press menu now, which is the route a thumb already takes
for everything else. "Move to..." replaces the action list with the other chairs, by
number and name, and picking one makes the move:

  Move Ben to...
  Their chips, cards and bet go with them. The button and the blinds stay on the chair.
    Seat 1 - Ann     Seat 3 - Cal     Seat 4 - Dee     Seat 5 - empty ...

That subtitle is the rule, and it is the one a card room uses: the whole seat travels,
while everything naming a position stays put. Somebody who sits down in the big
blind's chair is the big blind.

Driven through on a phone: Ben, small blind, $109 behind with $1 posted, moves to
seat 5 with both numbers; seat 2 goes empty; the button stays on seat 1; the toast
reads "Ben moved to seat 5" and undo puts it all back. Reopening the menu on another
seat shows actions again rather than the chair list it closed on.

The hint tells the truth per device now -- dragging is only mentioned where a mouse
exists. Dragging still works there, checked.

8 tests (232 total): the whole seat moves, the bet moves with it, chips are conserved,
the button and the blinds stay on their chairs, swapping twice is a no-op, and a chair
that is not there is refused rather than swallowed.

One audit fix rides along. The "one dealer button" check counted the empty slot that a
hovered seat deliberately fades in, so it reported two buttons whenever a mouse
happened to rest on the table -- which is most of the time a mouse exists. It ignores
the hovered seat now, and still catches the real fault: with the pre-#62 behaviour put
back it reports six, or five while hovering.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 14:54:03 -06:00
jheapsandClaude Opus 5 f199148046 A pre-filled amount is a suggestion, so reaching for the box replaces it
Every amount box in this tool opens pre-filled: the raise pad offers the minimum, Add
chips offers a buy-in, the seat editor offers the stack a player already has. A dealer
taps one because they want a different number -- and a tap puts the caret after the
suggestion rather than over it.

Driven on an emulated phone:

  raise pad shows 4      tap, type "60"    -> 460
  add chips shows 200    tap, type "50"    -> 20050
  seat stack shows 200   tap, type "175"   -> 200175

The raise pad is the most time-critical control in the tool, and 460 is a number the
dealer never said. The all-in warning happens to catch that one against a $200 stack;
against a deeper stack it is simply accepted, and the pot is wrong from then on.

Number boxes now select what is in them when they take focus, so the first digit
typed replaces the suggestion.

  raise pad     4 -> "60"
  add chips   200 -> "50"
  seat stack  200 -> "175"
  a name      Cal -> "CalX"

That last line is the point of the rule. Only number boxes: type=number, or the
type=text/inputmode=decimal pair the raise pad uses to get a keypad without spinner
arrows. A name or a note is a thing you edit, and selecting all of it on focus would
be the same mistake pointing the other way.

Delegated from the document rather than wired per field. The raise pad, the add-chips
sheet and the seat editor all rebuild their inputs on every render, and a box added
later should not have to remember to ask for this.

The select is deferred a tick. Doing it inside the focus handler loses the race with
the browser placing its own caret, and the selection collapses again the moment the
tap completes.

10 tests (224 total) on the predicate, including the ones that must stay false --
name, note, colour swatch, checkbox, select, and an input with no type stated.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 14:40:04 -06:00
jheapsandClaude Opus 5 38536a5c0c Ask about the thumb, not the window width
There is a careful touch-target pass in this stylesheet -- 44px fields, 44px delete
buttons, a padded hit area for the rulings link. Every rule in it sits behind
`max-width: 700px`.

A phone held sideways is 844px wide.

So none of that work applied to the device it was written for, half the time. Measured
on an emulated touch device with the sidebar open -- twelve controls under 44px at
844x390 and again at 915x412:

  the rulings link            91x14
  the dedicated-dealer box    13x13
  chip-colour delete          28px wide
  blinds-direction select     32px tall
  five panel headers          38px

The rules are restated against `pointer: coarse`. The width-scoped block keeps its
layout changes -- one column, stacked dialogs -- because those really are about how
much room there is. This is only about what a thumb can hit, so it asks about the
thumb.

  844x390 touch   12 controls under 44px -> 1
  915x412 touch   12 -> 1
  390x844 touch    6 -> 1
  360x640 touch    6 -> 1

The one left is the bare 24x24 checkbox, whose row is 44px tall -- the row is the
target, which is the existing design and the only thing that works for a checkbox.

Two things were too small on every device, not just touch, and are fixed outright.
The panel headers -- the sidebar's entire navigation -- were 38px everywhere, phones
included; the pass below never reached them. And the rulings link was 91x14, which
misses the 24px minimum with a mouse as well as with a thumb. Under 24px anywhere is
now zero, at every size measured.

Two traps worth recording. A <summary> only draws its disclosure triangle while it is
display: list-item, so the headers get there on padding rather than a flex box. And a
<summary> is content-box, so `min-height: 44px` was *added* to the padding and gave
71px -- the same box-model trap as the header buttons one PR ago, in a different tag.
Both are stated in the CSS where the next person will hit them.

214 tests still passing; layout audit and contrast clean at six sizes across touch and
pointer contexts; no console errors; panels still toggle and the triangle is still
there.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 14:24:07 -06:00
jheapsandClaude Opus 5 286a924fe4 Give buttons and links the same box
The header on a portrait phone was 227px -- a quarter of the screen -- before the
table started, and at 360x640 the bottom of the table sat 16px behind the action bar
with the gesture hint off screen entirely.

Chasing where the pixels went turned up something better than a tight header.

.holdemBtn is worn by three <button>s and two <a>s. The class never stated a box
model, and the browser does not give those two tags the same one: Chromium makes a
button border-box and leaves a link content-box. So `min-height: 46px` on the header
row came out as 46px on the buttons and 46 + 16 padding + 2 border = 64px on the
links -- and a flex row is as tall as its tallest item, so all five became 64px.

A 46px touch target was costing 64px of screen, twice over, because the header wraps
to two rows on a phone.

  390x844   header 227 -> 191   buttons 64 -> 46
  412x915   header 227 -> 191
  430x932   header 227 -> 191
  360x640   header 173 -> 137   table clearance -16px -> +20px, so the felt now
                                clears the action bar instead of running under it

The other half is the one I did not go looking for. The 46px floor only exists under
the 700px breakpoint, and the accidental stretch was the only reason anything above
it was big enough to press. Once the boxes agree, a landscape phone -- wider than the
breakpoint, still a thumb -- would have been left with 42px buttons. So the 44px
floor is stated on the class itself. Measured across the page:

  844x390   13 .holdemBtn under 44px -> 0
  915x412   13 -> 0
  1280x900  13 -> 0

That is every button on the page, not just the header: the panel buttons, the pot
wizard, the confirm dialogs.

What it costs: 844x390 loses 2px of table clearance, -5 to -7, because its header
grows with the buttons. That size already has the bottom seat behind the bar (#63),
and 13 unpressable controls was the worse of the two problems.

214 tests still passing; layout audit and contrast clean at 9 sizes; no console
errors; every header button still hit-testable at 46x121 and 46x112.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 14:14:04 -06:00
jheapsandClaude Opus 5 3e9e2e899d Stop the big blind's own chip from covering the BB badge
On a phone the badge marking the big blind was not hard to read. It was not there.
The chip showing their $2 sat on top of it.

Both live on the same spoke. Bets sit between a seat and the pot, so on the bottom
half of the ring the chip comes down onto the seat's top edge, which is exactly where
the badge was -- and the chip is a layer above it.

  390x844, six seats:  badge 24x12 = 288px², chip covered 196px² of it
                       badge z-index 2, chip z-index 3
                       the badge's own centre hit-tested as the chip

So seats on the bottom of the ring wear their badge underneath instead, on the edge
the pot is not on. And badges now outrank chips regardless, so a layout this does not
anticipate cannot hide one again: which blind a seat is posting is the one thing on
that puck nothing else on the felt repeats, while a bet chip's amount is on the seat's
own total line as well.

Measured across 20 table sizes -- badge area lost to bet chips:

  430x932, 390x844, 360x640 at 2 and 6 seats    35% -> 0%
  8 seats                                        4-12%, unchanged: the blinds land
                                                 on the top arc there, where the
                                                 badge was already out of the way
  desktop and tablet                             0% either way, more room

Two other placements were measured and rejected. Tucking the badge inside the seat's
bottom edge put 38-42% of it on the seat's own stack line at every size including
desktop -- trading a chip covering the badge for the badge covering the money. Leaving
it on top and only raising its z-index worked everywhere, but leaves the badge sitting
on the chip rather than clear of it; the z-index is kept as a backstop, the move is
what does the work.

What it costs

At 360x640 heads-up the bottom seat's badge moves down into the action bar, where it
was visible before. That seat is already substantially behind the bar at that size --
the narrow layout reserves scroll room rather than capping the felt, which #63
measured and settled on -- but this change is what put the badge there, so I am
saying so rather than leaving it to be found. Every other size and seat count is
clean, including 360x640 at six and nine seats.

An instrument note, because it nearly became a finding. Bet chips are
pointer-events: none above 900px and auto below it, so elementFromPoint reports them
as covering things on a phone and as invisible on a desktop. The 35% figures above
are geometric overlap and a cropped screenshot of the rendered pixels, not hit tests.
Three "chip covered by DIV" results I saw earlier were that quirk and are not real.

214 tests still passing; layout audit and contrast clean at 8 sizes x 3 seat counts;
no console errors.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 14:00:34 -06:00
jheapsandClaude Opus 5 96cd7684f0 Buying chips puts you back in the game
Handing over cash and being dealt in are one event at a real table. Here they were
two, and the second one was easy to miss.

Last change deals a busted player around until they rebuy. So the dealer takes their
money, taps Add chips, and:

  Ben:  inHand false -> false     stack 0 -> 100     buyIns 100 -> 200
  toast: "Ben added $100."

He has chips, he is not in the game, and nothing on screen says so. He gets skipped
hand after hand until somebody notices and finds "Deal in" in the seat menu.

Now the buy-in does both, and says which:

  mid-hand:      "Ben added $100 - in from the next hand."   inHand true, folded true
  between hands: "Ben added $100 - dealt in."                inHand true, folded false

Folding him for the hand in progress is the rule "Deal in" already follows -- you
can't buy into a pot you have not been paying for -- so the two doors into the game
behave the same way. It is one undoable step: undo takes back the chips, the cash
record and the seat.

Not for a seat that was sat out while holding chips. That is a dealer who sat someone
out on purpose and is topping them up, and dealing them back in would be the tool
guessing at something it was not told. The test reads the seat's own recorded stack
rather than stackOf(), which substitutes the table's starting stack and would make a
seat with nothing recorded look like it was holding chips.

  busted, sat out          -> back in
  new chair, no stack yet  -> in
  sat out holding $100     -> stays out
  already in the hand      -> nothing to do, including an all-in at zero chips

8 tests (214 total).

Driven through the UI: the rebuy, the toast, the next hand actually dealing him in
(4 players, live 4), and undo putting all three fields back.

Found on the way, not fixed here

Buying in on a seat with no stack of its own credits the table's starting stack *plus*
the buy-in. A new player handed $100 on a table with a $100 default ends up with $200
of chips against $100 of recorded cash.

I am leaving it because the right answer is genuinely unclear and it is not mine to
pick. A blank stack means "no chips recorded" to the bank check, which counts it as
zero, and "the table's starting stack" to the betting code and the felt, which show
it as $100. Both are defensible for their own job, and which one a buy-in should
follow decides whether a casual dealer who never types stacks gets $200 or $100 after
a rebuy. Worth saying out loud rather than quietly picking one.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 13:39:56 -06:00
jheapsandClaude Opus 5 1af82dfddf Call a bet a bet and a raise a raise
The bar decided which word to use from what the acting player still owes. That gets
the big blind's option backwards -- they owe nothing, so it offered "Bet", but their
own blind is a live bet and putting more in is a raise. And it gets the other half
wrong too: open the pad to make a first bet on the flop and the confirm button said
"Raise to", then wrote "Ann's raise to $10" into the undo control for it.

  preflop, action back to the big blind
    was:  Fold | Check | Bet        pad: "Raise to"
    now:  Fold | Check | Raise      pad: "Raise to"
  flop, nobody has bet
    was:  Fold | Check | Bet        pad: "Raise to"   undo: "Ann's raise to $10"
    now:  Fold | Check | Bet        pad: "Bet"        undo: "Ann's bet of $10"

The arithmetic was always right -- minRaiseTo returns two big blinds for the big
blind's option and one for an opening bet, and I checked both before touching
anything. This is only what it is called. For a tool a dealer announces the action
from, that is not a small thing: a dealer who says "bet" when the big blind puts more
in gets corrected by the table.

The test is whether there is a live bet to raise, not whether this player owes
anything, and it is one helper rather than a condition written out five times.

The warnings follow the same word. Under-minimum with a bet in front of you leaves
you calling it; with an empty pot there is nothing to call:

  "$3 is under the $4 minimum. A legal raise is $4 or more -- otherwise it's a
   call, or all-in."
  "$1 is under the $2 minimum. A legal bet is $2 or more -- otherwise it's a
   check, or all-in."

Also the field's screen-reader label, which announced "Raise to amount" for a bet.

8 tests (206 total), including a table playing without blinds -- which opens with a
bet, and is the one preflop case where the old condition happened to be right.

Driven through the UI at seven viewports in both spots: every label correct, contrast
clean, no console errors.

One thing found and deliberately not touched. Fuzzing 40 hands with random actions
and rebuys -- chips stayed conserved to the cent throughout, no negative stacks, and
the acting seat was always a seat that could act -- turned up one disagreement:
isStreetComplete returns false during an all-in run-out where one player still has
chips and hasn't acted, while actingSeatIndex is correctly null. It has no symptom,
because the bar picks its layout from actingSeatIndex and shows "Deal Turn" as it
should. Changing a betting-round predicate with no observable fault behind it is a
worse trade than leaving it written down.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 13:23:02 -06:00
jheapsandClaude Opus 5 2cc2c66976 Deal around a player who busted and hasn't rebought
The most ordinary thing that happens in a home game: somebody stacks off, and they
don't buy back in that second. The table dealt them in anyway, and the result was a
hand nobody could finish.

Three players, two of them busted:

  bar before dealing:
    "Pot $0 | 3 players | All-in - running out the board (3 players)
     | Cal posts $1, Ann posts $2 | Post blinds & deal"
  after dealing:
    Cal posted nothing -- no chips to post with
    Ann posted $2 and was asked to check or bet against her own money
    liveSeats 3, isStreetComplete false, isHandOver false

Every line of that is wrong. It announced a board being run out over a felt with no
cards on it, promised a small blind from a seat that couldn't post one, took it from
nobody, and then sat on a hand with no way forward.

So a seat with no chips and nothing in the pot is dealt around. Their seat, name and
buy-in record are untouched -- sat out, not folded, because they didn't muck anything
-- and adding chips puts them straight back in the next hand.

  four players, Ben busted, button on Ann:
    "3 players | Cal posts $1, Dee posts $2
     | Ben has no chips - sitting out this hand. | Post blinds & deal"
    -> Ben inHand false, folded false; blinds exactly as promised; Ann to act

Said before it happens, for the same reason the button move is: a dealer shouldn't
find out from the felt. And the preview computes it on a view of the table rather
than by changing it, so the promise and the deal ask the same question -- the fault
these two lines exist to prevent.

When it can't be dealt at all, it says who has to buy in rather than pretending it's
a seating problem:

  "Only Ann has chips - Ben and Cal need to buy in."
  "Nobody at the table has chips left. Add a buy-in to deal."

Two smaller things fall out. The run-out banner is now gated on the betting actually
being open -- before the deal, a table of busted players is indistinguishable from a
table of all-ins to that test. And the bar's player count reads the table as it will
be dealt, so someone waiting on a rebuy isn't announced as one of the players.

Not the same as all-in, and the code says so: an all-in player has money riding on
the hand. A seat with no stack recorded isn't broke either -- those fall back to the
table's starting stack.

13 tests (198 total), and 5 of them fail with the sit-out and the run-out guard taken
back out.

Driven through the UI at seven viewports in both states: layout and contrast clean
everywhere but the landscape phone already known from #63, and no console errors.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 13:08:21 -06:00
jheapsandClaude Opus 5 a92a389245 Work out who pays whom
I went looking for what home-game dealers actually struggle with rather than reading
the code again, and the answer that kept coming up was the end of the night: hosts
reach for a separate app to finish a game "knowing exactly who owes whom in seconds
rather than minutes".

This tool already had every number that answer needs. The standings panel knows what
each player is up or down -- chips in front of them less the cash they handed over --
and then stops. Which leaves the dealer with six signed numbers at 2am and the actual
job still to do. That last step is the part people get wrong, not the poker.

  Ann  +$75    Dee  +$110      Fay -> Dee   $70
  Ben  -$10    Eve   -$40      Cal -> Dee   $40
  Cal  -$65    Fay   -$70      Cal -> Ann   $25
                               Eve -> Ann   $40
                               Ben -> Ann   $10

Largest debt against largest win, repeatedly. Greedy rather than optimal, which is
the right trade here: the true minimum number of transfers is NP-hard, while this
needs at most one payment fewer than there are people and each one it emits clears
somebody out of the list entirely. A dealer wants a short list to read down, not a
proof. Payers are the outer loop so everything one person owes prints together --
the list gets read out one player at a time.

All of it in integer cents. A settlement that has to come out exactly right is the
last place to let a float land on 41.99999999.

When the chips on the table don't match the cash taken

Then the payments can't all be made, and that is reported rather than quietly spread
across them. It is never rounding -- the arithmetic is integer cents -- so it is
always what the bank check sitting right above it is already warning about: a rebuy
nobody wrote down, or a miscount. Inventing a transfer to make the columns agree is
how a real person ends up short.

  $100 of winnings has nothing to pay it -- there are more chips on the table than
  cash was taken for. Fix that above before anyone cashes out.

The payment list goes into the CSV export too, under the standings it came from,
with the caveat if there is one. Whoever opens that file wants the answer, not to
redo the arithmetic in a spreadsheet.

13 tests (185 total): everyone ends up square, at most n-1 payments, a payer's lines
stay together, cents survive, nobody pays themselves, no payment is ever for zero,
both directions of an unbalanced bank, and one that settles a real table's standings
rather than hand-made numbers.

Driven through the UI at six viewports, balanced and not: clean layout audit and
contrast everywhere but the landscape phone already known from #63. Checked with
26-character names and seven-figure stacks on a 360px screen -- names wrap, amounts
stay whole and right-aligned, nothing overflows.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 12:52:56 -06:00
jheapsandClaude Opus 5 3d1ce1f45c Keep the table above the action bar on wide screens
The bar publishes its height so the layout can stay clear of it. Only the phone and
landscape rules ever read it. The wide layout sized the felt from its own width -- a
box 72% as tall as it is wide -- and so had no idea what was underneath it.

That was survivable while the bar was a constant. It isn't: it grows from 117px to
172px the moment a hand needs a side pot, which is exactly the hand where the dealer
most needs to see who is still in. Measured with two side pots, six seats, the bottom
seat sat this far behind the bar:

  1280x900    22px      1600x1000   54px
  768x1024    23px      1024x768    clear
  360x640     39px      390x844     clear

The first attempt at this didn't work and the failure is worth recording: the wrap was
a padding-bottom: 72% box, and percentage padding resolves against the containing
block's width rather than the element's own. Capping the width narrowed the felt from
822px to 746px and left it exactly 617px tall, so the seat stayed where it was. It is
an aspect-ratio box now, and the shape is stated once as --feltAspect so the height
and the cap both derive from it.

  1280x900   felt 857x617 -> 746x537   seat 22px behind the bar -> 51px clear
  1600x1000       ...760 -> 885x637              54px behind -> 59px clear
  768x1024   felt 689x689 -> 603x603              23px behind -> 56px clear

Six-seat tables at 1280x900, 1600x1000, 1024x768 and 768x1024 all go from one covered
seat to a clean audit.

Not applied below 700px. The narrow layout already had an answer -- it reserves the
bar's height as scroll room under the table, and a phone is a thing you scroll -- and
capping there was measured and rejected: at 360x640 it took the felt from 321px to
226px, which fixed the six-seat ring and broke the nine-seat one, two covered seats
traded for two seats whose centre landed on a neighbour.

Landscape phones opt out too. They set their own height from the bar already and are
sitting on their 7rem floor; 844x390 and 667x375 still have the bottom seat in the
bar, and nothing about a 112px-tall table with 73px seats can fix that.

The seat overhang is a constant rather than something measured off the layout it is
about to change -- a seat is ~73px tall at every width -- so this settles in one pass
instead of asking a different question each time it shrinks the felt.

172 tests still passing; no console errors; contrast clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 12:35:15 -06:00
jheapsandClaude Opus 5 29188d6049 Say ALL IN on the felt
A player with nothing behind had a blank where their stack goes. So did a seat whose
stack nobody had entered. On a table with side pots that is the one distinction the
dealer is working from -- who can still be bet at, and who is already in for
everything they have -- and the felt drew the two states identically.

  Ann  $40  $160     has chips
  Ben  $40           all-in for 40
  Cal  $40  $160     has chips
  Dee  $12           all-in for 12

The action bar knew: it had already built "Main pot $48, Ann/Ben/Cal/Dee" and
"Side pot 1 $84, Ann/Ben/Cal". The table it sits under didn't say a word.

Betting.isAllIn is the same stack test nextToAct uses to pass over a player, which is
the point: the seat the felt marks is exactly the seat the dealer will never be asked
to act on again. There's a test asserting that equivalence rather than restating the
condition.

The second half of the condition matters. A seat with an empty stack that has put
nothing in is a busted player who shouldn't have been dealt in, not a player who
moved all-in, so it isn't marked.

"ALL IN" in gold where the stack was, and a warmer border on the seat -- deliberately
quieter than the acting-seat ring, because all-in is a standing fact about a player
rather than a request for attention.

9 tests (172 total). They fail on a naive condition: dropping the committed-chips and
folded checks fails 4 of them.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 12:34:53 -06:00
jheapsandClaude Opus 5 4c1ac222a8 Give Enter and Space back to the control that has focus
Found while checking the keyboard could still reach the dealer button: it could
reach it and then couldn't press it.

Enter and Space were bound to "advance" for the whole document, with an exception
only for text fields. Those two keys are how a keyboard presses the control it is
sitting on, so every button on the page lost them -- and not to nothing. Measured:

  Tab to Resolve Pot, press Enter   ->  the table dealt the next hand
  Tab to Resolve Pot, press Space   ->  Resolve Pot, correctly
  Tab to a dealer button, Enter     ->  nothing at all

The inconsistency is the shortcut handler's return value. It calls preventDefault
only when it handled the key, so when "advance" applied it ran instead of the button,
and when it didn't apply the browser's own activation got through. A control that
does something else every other press is worse than one that does nothing.

Enter and Space now go to a focused button, [role=button], link or summary, and the
shortcut only claims them when nothing pressable has focus. The letters stay live
with a button focused -- a button does nothing with "f", and a dealer whose hand is
on the keys shouldn't lose them to a stray focus ring.

Space still advances the street: dealt, f, f, c, c, Space -> flop. The action bar
rebuilds itself after every press, so focus falls back to the body and the dealer's
Space is never quietly aimed at the Fold they just clicked.

11 tests (163 total), and they fail on the old handler -- 4 of them, exactly the
Enter/Space-on-a-control cases.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 12:16:15 -06:00
jheapsandClaude Opus 5 784315f2ea Put one dealer button on the table
A poker table has one dealer button. This one drew nine.

Every seat carried a puck: the real one in gold, and eight ghosts at 0.32 opacity
that existed to be pressed. Measured on a nine-seat table -- eight pale discs, all
eight showing the letter D, one of them the button. Finding it meant comparing nine
identical shapes for the one that's gold, when it should be the only thing of its
kind on the felt.

The ghosts also sat on their neighbours. Every remaining "something important is
covered" offender on a crowded table was a live seat covered by the seat-next-door's
ghost puck: 844x390, 915x412, 360x640 and 640x360 each reported one, and they were
all the same thing.

And a screen reader heard nine buttons named "D".

So a seat without the button doesn't draw one. Hovering a seat fades its slot in --
a preview of where the button would go, one click as before -- and on touch, where
there is no hover, "Give the button" has been in the seat's long-press menu all
along. Keyboard reaches it by focus, where the slot appears too.

pointer-events matters as much as the opacity: an invisible puck that still took
taps would be worse than a visible one.

  visible pucks    9 -> 1 at every size
  layout offenders 915x412 1->0, 360x640 1->0, 844x390 3->2, 640x360 3->2
  clean viewports  4 of 9 -> 6 of 9
  labels           nine "D" -> "Ann has the dealer button" and
                              "Give the dealer button to Ben"

The audit now checks it, because this is a rule about the table rather than about
CSS: one puck drawn, and no two announcing themselves the same way. Verified against
the old behaviour put back -- it reports 9 and 9.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 12:16:00 -06:00
jheaps b2262f8604 Merge pull request 'Wait to be told to deal, instead of guessing' (#58) from fix/casino-holdem-manual-deal into master
Build and Deploy / build-deploy (push) Successful in 1m10s
Reviewed-on: #58
2026-09-01 10:57:23 -06:00
jheaps 6ad9923aae Merge pull request 'Stop the last two things the table did behind the dealer's back' (#59) from fix/casino-holdem-less-guessing into fix/casino-holdem-manual-deal
Reviewed-on: #59
2026-09-01 10:57:17 -06:00
jheaps 168e8a380c Merge pull request 'Tapping a seat gives you that seat' (#60) from feature/casino-holdem-seat-editor into fix/casino-holdem-less-guessing
Reviewed-on: #60
2026-09-01 10:57:10 -06:00
jheaps edc08d7213 Merge pull request 'Let the landscape table use the width it has' (#61) from fix/casino-holdem-landscape-table into feature/casino-holdem-seat-editor
Reviewed-on: #61
2026-09-01 10:57:01 -06:00
jheapsandClaude Opus 5 0c6cd55951 Let the landscape table use the width it has
Last round I left a residual and said it wanted its own fix: on a phone held
sideways, seats overlapped so badly that a seat's centre belonged to the seat next
door. Here's the cause.

The landscape rule capped the felt's width by its height to hold the portrait
aspect ratio. At 640x360 that produced a table 135px wide inside a 601px column --
22% of the screen -- with 366px of rim for nine seats needing 662px. Adjacent seat
centres ended up 13px apart for seats rendering 74px wide.

Landscape has width and no height, so the table now takes the width it has and is
only as tall as the room left over. A wide ellipse, which is what a real poker
table is anyway.

  640x360   felt 135x97  ->  601x121   rim 366 -> 1263   centres 13px -> 33px
  844x390   felt 176x127 ->  765x151   rim 480 -> 1605   centres 37px -> 59px
  915x412   felt 207x149 ->  492x173   rim 563 -> 1106   centres 44px -> 55px

Seat ownership went from 8 seats wrong at 640x360 and 2 at 844x390 to zero at
844x390, zero at 915x412, and two at the very smallest. Nine of eleven sizes clean,
portrait and desktop untouched.

Two things I tried and threw away

Spacing seats by arc length instead of by angle. It sounds obviously right -- even
distance around the rim rather than even angle -- and it made things worse: 640x360
went from 2 wrong to 3, closest centres 33px to 21px, and even the desktop table
dropped from 180px to 155px. Even arc spacing packs seats through the tight turns
at the ends of an ellipse, where a fixed distance along the rim covers much less
straight-line distance. Equal angles happen to separate them better. Reverted.

Shrinking seats on the tightest landscape phones. Took the rendered seat from 74px
to 64px and moved closest centres not at all -- still 33px -- while the covered
count at 844x390 went from 3 to 4. A change that measures as nothing but costs CSS
and legibility isn't worth keeping. Reverted.

So this is one CSS rule, which is all the measurements supported.

What's left

640x360 and 667x375 still have two seats whose centre lands on a neighbour. Nine
seats on a ring 121px tall is a genuine physical constraint rather than a layout
mistake, and both remaining fixes I tried made it worse rather than better. It is
reported by the audit rather than hidden, and a dealer on a 360px-tall screen has
the seat menu and the action bar for everything that matters.

152 tests passing; contrast clean at every size; dealing still works in landscape.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 10:39:45 -06:00
jheapsandClaude Opus 5 e01d8e7c49 Tapping a seat gives you that seat
Went to audit the seat editor and couldn't open it: Playwright refused to click the
middle of seat 1 because seat 9's dealer chip was sitting there. On a nine-seat
table, tapping the centre of one player handed the button to another.

I shipped that. The enlarged chip hit area grew outward from a chip already hung
over its seat's corner at -10px/-10px -- and on an oval, a seat's corner is the
seat next to it. Measured on the nine-seat table:

  seat 1  10 of 49 sampled points taken by seat 9's chip, including its centre
  seat 4   9 of 49 taken by seat 5's chip
  seat 5   3 of 49 taken by seat 6's chip

I claimed at the time that no seat tap was stolen, and I had checked -- with an
audit that exempted anything inside a `.seat` as harmless crowding. The thief was
inside a seat. The check walked straight past it.

Two fixes, and the second matters more

The chip sits inside its seat now rather than on the rim, and its hit area grows
inward over its own seat, which is a big target that can spare a corner. Padding
inward alone wasn't enough -- I tried it and the numbers didn't move, because the
chip's own box was the problem, not just the pad. Isolating that took disabling the
pad and then the overhang separately: pad off gave 2 seats affected, overhang off
gave zero.

And the audit now asks whether a seat's centre belongs to that seat, as its own
check. The occlusion check couldn't answer it: it was built for overlays on top of
the table, and this was two pieces of the table fighting over a tap. It exempts a
neighbour's body -- seats do overlap by design -- but not a neighbour's controls,
which was the exact hole.

Seven of eight sizes now have no seat whose centre goes elsewhere, and all four
seats I tap open their own editor.

What's left, stated rather than buried

At 360x640 and 375x667 one seat still loses a single sample point of its rim to a
neighbour's chip. Centres are correct; this is the rim of a crowded oval.

At 844x390 two seats have centres belonging to the seat next door -- and those are
seat bodies, not chips. Nine seats on a 163px-tall landscape table overlap almost
completely. That's the landscape felt sizing, it predates all of this, and it wants
fixing on its own terms rather than being folded in here.

Both are now reported by the audit instead of hidden by it, which is the part I
actually trust.

152 tests still passing; contrast clean at every size.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 10:24:14 -06:00
jheapsandClaude Opus 5 c77161c3e9 Stop the last two things the table did behind the dealer's back
Your bug report was about a category, not one button, so I went looking for the
rest of the guessing rather than picking a new topic. Two left, both about the
button -- and one of them was in the fix I'd just shipped.

The deal preview lied

The prompt added last time promises who will post. With the button parked on an
empty chair it promised the wrong pair: it read the button where it currently sat,
while dealing relocates the button onto a player first and only then works out the
blinds.

  three players in seats 2-4, button parked on empty seat 1
    preview said:  s2 posts $1, s3 posts $2
    dealing gave:  s3 posts $1, s4 posts $2

Exactly the fault you reported -- the display and the thing it describes computed
from different moments -- inside the change meant to remove it. buttonSeatForDeal
answers where the button will be without moving it, so the preview asks the same
question the deal will answer. And the move is now stated before it happens:
"Button moves to Ben - seat 1 is empty."

Moving the button after the deal moved the blinds silently

Once the blinds are posted, moving the button takes them back and re-posts them,
because otherwise the badges and the money disagree. That's the right behaviour
and it was completely silent. The toast says so now: "Button on Dee - blinds taken
back and re-posted."

It also only does it when there is actually something posted to move. Before the
deal there is nothing committed, so passing the button is just moving a marker --
which is the whole point of the setup step.

Checked and deliberately left alone

Streets still advance one press at a time during an all-in run-out, which is right
-- the dealer is physically dealing the board between them. A player who joins
mid-hand is still marked folded for that hand and plays the next one. The
base-pot-amount shorthand still stands in for "everyone antes the same". Nothing
mutates state during a render.

4 tests (152 total): the preview matches what dealing produces with the button on
an empty chair, asking where the button will be doesn't move it, a button already
on a player is left alone, and an empty table doesn't break the question.

Driven through the UI: the prompt warns about the move and then the deal matches
it exactly; moving the button after the deal announces the re-post; and the blinds
survive being passed around three seats with the chip count fixed at 400. Layout
and contrast audits clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 10:06:58 -06:00
jheapsandClaude Opus 5 843ef9cd42 Wait to be told to deal, instead of guessing
Dealing and betting were one button. "Next Hand" moved the button and posted the
blinds in the same breath, so the moment between hands -- the one where people are
still sitting down, standing up and arguing about whose turn it is -- was spent
with money already committed.

That's where the guessing came from. The blind badges recompute from `inHand`
every render, but the posted chips are frozen from whenever beginHand ran, so the
two drift apart the instant the table changes:

  seat a fourth player after Next Hand
    badges said sb=s3 bb=s4, money on the table was s1=$2 s3=$1
    s4 was shown as the big blind having posted nothing

  take out the big blind after Next Hand
    badges moved to bb=s1, s4's $2 stayed in the pot, s1 had posted nothing

  press Next Hand twice
    burned a hand, moved the button twice, chips went 300 -> 297

Now the dealer says when. Next Hand advances the button and clears the table, and
stops. The bar shows who will post -- "Ben posts $1, Cal posts $2" -- and waits on
"Post blinds & deal". Enter does it too, so a dealer can carry a hand from setup
through the streets to showdown without reaching for the trackpad.

Until that press nothing is committed, so setting the table is free: seat someone,
take someone out, pass the button around, name the blinds by hand. The preview
follows every change and the chip count never moves. Settling a pot ends the hand
rather than starting the next one, for the same reason -- people cash out between
hands.

The preview is honest about what it can't do: fewer than two players and there's
no button at all, just what's missing.

A table saved before this existed has no flag, and guessing "not dealt" would
offer to deal over a live hand. Anything already committed, or anyone already on
the clock, means the betting was open when the tab was closed.

11 tests (148 total): the table waits, no money moves until the deal, seating and
unseating and passing the button all cost nothing, what the preview promises is
what gets posted, and a mid-hand save comes back mid-hand.

Driven through the real UI: seat a player and move the button while waiting, watch
the preview follow with the chip count fixed at 400, deal, play it out, settle, and
land back in setup. Triple-tapping Next Hand now costs nothing but button position
-- it used to cost a hand and three chips. Layout and contrast audits clean in both
the new setup state and after dealing.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 09:51:43 -06:00
jheaps e5d5b515ba Merge pull request 'Hold'em dealer: nine polish rounds released together' (#57) from feature/casino-holdem-acting-seat into master
Build and Deploy / build-deploy (push) Successful in 1m22s
Releases #49-#57 in one go: pot safety, the banker's cash reconciliation, the short all-in rule, the standings name column, the layout and contrast audits, dealer puck hit area, illegal-raise warnings, and the acting-seat highlight.
2026-09-01 09:31:52 -06:00
jheaps a14087fa7d Merge pull request 'Stop rebuilding the hand log after every fold, call and raise' (#48) from feature/casino-holdem-render-perf into master
Build and Deploy / build-deploy (push) Successful in 1m27s
2026-09-01 09:30:13 -06:00
jheaps 76f1952d61 Merge pull request 'Make the overlays usable from a keyboard' (#47) from feature/casino-holdem-keyboard-focus into master
Build and Deploy / build-deploy (push) Canceled after 10s
2026-09-01 09:30:07 -06:00
jheaps f464e6f2e2 Merge pull request 'Make big numbers readable, and make the export add up' (#46) from feature/casino-holdem-resilience into master
Build and Deploy / build-deploy (push) Canceled after 5s
2026-09-01 09:30:02 -06:00
jheaps f5108de288 Merge pull request 'Handle the messy table: joining, leaving, and arguing about blinds' (#45) from feature/casino-holdem-messy-tables into master
Build and Deploy / build-deploy (push) Canceled after 0s
2026-09-01 09:29:58 -06:00
jheaps ee37cf7bd9 Merge pull request 'Make every tap in the dealer land instantly on a phone' (#44) from feature/casino-holdem-touch-mobile into master
Build and Deploy / build-deploy (push) Canceled after 10s
2026-09-01 09:29:53 -06:00
jheaps 0d1218fa53 Merge pull request 'Hold'em dealer: a test suite, one implementation each, and a pot-announcement fix' (#43) from chore/casino-holdem-tech-debt into master
Build and Deploy / build-deploy (push) Canceled after 0s
2026-09-01 09:29:49 -06:00
jheapsandClaude Opus 5 a72f199139 Light up the seat that's holding the game up
Asked what actually slows a home game down and the answer from people who deal
them is consistent: not how fast the cards come out, but players not noticing the
action is on them. "Waiting for shuffling and players not realizing it's their
action is going to cost much more time than how quickly you spread the flop."

So I measured how obvious the acting seat is, and it was distinguished by a rim
colour and nothing else -- a gold border and a 2px ring where a live seat has a
green border and a 1px one. Same fill, same size, nothing moving. That is a subtle
thing to ask a dealer to spot across a table on a phone, for the one state the
whole pace of the game hangs on.

It reads as lit now rather than merely outlined: a solid gold ring at full
strength, a warm glow separating it from the felt, a warmer fill, and it sits
above its neighbours so the ring is never clipped by the seat next to it.

There's also a slow two-and-a-half second breath, which stops entirely under
prefers-reduced-motion -- the ring carries the whole job on its own for anyone who
would rather nothing moved, and that was checked rather than assumed.

The lift is box-shadow, not transform. A seat's transform is what positions it
around the felt, so setting one here would throw it off its spot -- the same trap
that nearly shipped a 38px seat displacement a few rounds ago.

One measurement I'm not claiming: the acting and live fills differ by only 1.1:1,
because the change is a hue shift rather than a lightness one. The ring and the
glow do the work; the fill just stops it looking like the same chip.

Checked at eight viewports: layout and contrast audits clean, exactly one acting
seat everywhere, and the lit seat is the same size as its neighbours at every
size -- the ring adds no layout. renderAll is 1.8ms with the pulse running, so it
costs nothing. Text on the warmer fill still passes contrast at every size.
137 tests passing.

Sources: pokerchipforum.com/threads/as-a-dealer-how-do-increase-more-hands-per-hour.114914/,
forumserver.twoplustwo.com/27/casino-amp-cardroom-poker/tips-advice-faster-better-dealer-241644/

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 09:20:46 -06:00
jheapsandClaude Opus 5 e0eb0d2079 Say something when the raise isn't a legal raise
Web search turned up the oversized-chip rule described as the single most common
cause of floor disputes, and specifically a home-game problem. Chasing that led to
checking what the bar does with an illegal raise, and the answer was: nothing.

With the minimum at $18 and a player holding $200, typing 13 was accepted without
a word. The engine then treated it as a short raise and correctly declined to
reopen the betting -- right for an all-in, wrong for somebody who just misheard
the bet, and either way the dealer was never told.

The raise entry now says what's wrong and still lets it through. This tool records
what the humans at the table actually did, and a dealer overruling the arithmetic
is right more often than the arithmetic is, so Confirm stays enabled in every case:

  $13 is under the $18 minimum. A legal raise is $18 or more -- otherwise
    it's a call, or all-in.
  All-in for less than a full raise. Legal, but it doesn't reopen the
    betting -- anyone who has already acted owes the difference or folds.
  More than they have. This goes in as all-in for $200.
  $10 is not above the $10 already bet -- that's a call, not a raise.

The second one surfaces the rule fixed two rounds ago at the moment it decides
something, rather than only in the rulings sheet.

Two minimums, which is where the first attempt went wrong

The pad already had a minimum, clamped to what the player can actually put in --
correct for the Min button, wrong for the warnings. For a short stack the clamped
minimum equals their all-in, so neither warning could ever fire for exactly the
player the rule exists for. Kept separate now: one for the button, the rules one
for the warnings.

A regression the audit caught, in the same round that introduced it

The note makes the raise pad taller, --actionBarHeight grows, and the toast --
which floats just above the bar since the occlusion round -- got pushed up over
the table. Measured at 360x640: bar 122px closed, 178px with the pad, 217px with a
note, and the toast climbing from y506 to y411, covering seats outright.

A toast is transient news about the action just taken; the pad is the dealer
deciding the next one. The older one gets out of the way when the pad opens --
nothing is lost, its Undo is also the bar's own undo control. The note is tighter
on short screens, and the pot breakdown hides during entry, since which side pots
exist isn't what's being read while typing an amount. Net: 210px rather than 217px,
and no seat covered that the pad wasn't already covering at 178px.

Two rulings entries, from the same search

The oversized chip -- a single big chip pushed out silently is a call, not a raise,
however big it is; to raise you say so before it lands. And what a full raise is,
which the pad now enforces in words.

137 tests passing. Raise warnings verified across four amounts on a deep stack and
three on a short one, at eight viewports: note shows, pad stays reachable, contrast
clean, Confirm never disabled. Toast still behaves normally when the pad is closed.

Sources: cardplayer.com/.../contracts-and-poker-the-oversized-chip-rule,
pokernews.com/strategy/be-careful-when-betting-essential-wsop-rules-to-remember

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 09:12:17 -06:00
jheapsandClaude Opus 5 d66abda05a Make the dealer puck hittable without making it bigger
Ran the touch-target check that had been sitting in the audit unused. The dealer
puck is 22x22 — half what a fingertip needs — in ten places on the table, and
tapping it hands the button to that seat and re-posts the blinds. Not a cosmetic
control.

It stays 22x22. A puck should look like a puck sitting on the rim of its seat, and
growing it would take area from the seat itself, which a dealer taps far more
often. Instead an invisible pad around it catches the tap: 43x44 hittable on every
phone size, close to four times the area, with the pad pushed down and left away
from the seat the chip juts towards. Verified that no seat tap is stolen — the
occlusion audit is clean at every size.

Three things the check itself got wrong, all found before acting on them

It read element boxes, and boxes lie in both directions. A control padded out with
a transparent ::after responds well past its painted size, and a checkbox wrapped
in a label is hit by tapping anywhere on the label — so it reported the dedicated
dealer toggle as a 24px fault when it sits inside a 320x44 label that already
forwards the tap. It probes outward with elementFromPoint now, which is what a
finger actually experiences.

The probe walked a fixed 40px each way, so everything wider than 80px reported
exactly 80. Every header button came back a suspiciously round "80x42".

And it enforced 44px on desktop, where there is no fingertip. Those same header
buttons are 64px tall on a phone and 42px with a mouse, which is not a defect.
Keyed off hover:none now, the same signal used for the tablet field sizing.

Also removed a ::after pad on the dedicated-dealer checkbox that was both inert --
pseudo-elements don't render on replaced elements -- and unnecessary, since the
label already does the job.

The puck is the one documented exemption: it is deliberately small and "Give the
button" is in the seat menu at full size, so it's a shortcut rather than the only
way through. The reason sits in the code next to the exemption.

Phone sizes: dealer chip 22x22 drawn / 43x44 hittable, zero undersized controls
even with the exemption and the pointer rule forced off. Layout and contrast
audits clean at all seven sizes. 137 tests passing.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 08:54:07 -06:00
jheapsandClaude Opus 5 6fa4532781 Fix the contrast the audit could finally measure
Contrast is the one check I've got wrong twice, both times by being clever about
backgrounds. The first version read only background-color, so text on any of the
gradient-painted sheets was measured against transparency and the dealer puck's
dark "D" was reported at 1.2:1 against nothing. The second over-corrected and
treated low-alpha gradient stops as opaque, scoring white-on-dark at 1.0:1.

So this one is built to be incapable of false positives rather than exact.
Backgrounds composite through alpha; where a gradient is involved every colour stop
is tried and the most flattering one used. A finding means the text fails even
under the reading most generous to it.

It's checked against known answers before being believed: black on white at 21:1,
white on white at 1:1, #767676 on white at the 4.54:1 AA boundary, white over a
dark gradient, dark text on that same gradient, white over a 6% white wash on a
near-black page. Nine cases, all correct — and building them found a real bug in
the check, which walked past an opaque gradient and offered the page background as
a candidate. That made it too lenient, which is as bad as the old false positives.

What it found:

Both blind badges failed against their own gradients, in 9px bold text a dealer
reads at a glance across a table. At the worst stop: small blind 3.52:1, big blind
2.15:1. The small blind keeps its blue and darkens to 4.77:1. The big blind flips
to dark text on brighter gold, 5.84:1 — how a gold chip is marked anyway, and it
makes the two badges far easier to tell apart at speed.

Quick Split's footnote names a control the dealer is told to reach for, and that
name inherited the muted grey of the surrounding aside: 3.39:1. Emphasised text
carrying an instruction gets its own colour now.

The felt watermark came back at 1.19:1 and is meant to. It's decoration; holding it
to a reading threshold would mean making it not a watermark. It's aria-hidden now,
which says the same thing where it also does some good, and the audit skips
aria-hidden subtrees.

32 contrast passes over the table, all five overlays and Quick Split at five
viewports: zero findings, zero unmeasured. Layout audit still clean. 137 tests pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 08:39:07 -06:00
jheapsandClaude Opus 5 2f6d63400a Stop re-typing the layout audit into a console every round
Last round I found a regression I'd shipped two rounds earlier: the standings
table's player-name column was zero pixels wide, at every viewport, and nobody
noticed because the audit that would have caught it only ever existed in a
previous session's scrollback. These checks have been re-written by hand at least
six times, and each time they covered only whatever had just changed.

So they're a file now. uiAudit.js runs in the page — no build step, no
package.json, no dependency — and checks touch-action and tap highlights, that
fields stay selectable and are big enough not to trigger iOS zoom, that nothing
important is covered or stranded off-screen, that no grid column has collapsed to
nothing, and that no text is painted outside its box. runUiAudit.js drives it over
the table, all five overlays and Quick Split at thirteen viewports. Deliberately
outside CI: the unit suite is dependency-free and runs in a second, this one needs
a browser and a running site.

Three real faults it found on its first run

Fields were 13.3px on every iPad. The 16px minimum that stops iOS zooming the page
on focus went in inside a max-width:700px block, and every iPad is wider than
that — so a dealer on a tablet, which is the device this is most likely to be
propped up on, got the table zoomed every time they touched the blind amount. The
reason for that rule was never "narrow screen", it was "iOS". Font size applies at
all widths now, and the 44px touch targets key off hover:none, which is what
actually distinguishes a finger from a mouse.

The seat menu couldn't be scrolled. It had no height limit and no overflow, so on
a phone held sideways its first two actions — Fold and All-in — sat below the
bottom of the screen with no way to reach them. A dealer in landscape could
long-press a seat and then not fold it.

The pot wizard sat under the action bar. Every other overlay is z-index 90-98; the
wizard alone was at 50, below both the action bar at 55 and the toast at 80. In
landscape one of its inputs was completely covered by the Fold button, and a toast
could sit over "Confirm Winner(s)". It was the odd one out rather than a decision.

Three faults in the audit itself, found by checking before acting

Worth recording, because each would have had me "fix" something that wasn't broken:

- an ellipsised name was reported as painting outside its seat. A Range measures
  laid-out text, which runs past the element while overflow:hidden quietly clips
  it. Only overflow that is actually visible can paint over a neighbour.
- every link below the fold was reported as off-screen. Something under the fold on
  a page that scrolls is reachable; the check now asks whether scrolling can bring
  it into view, which is the thing that actually matters.
- a button in the newly-scrollable seat menu came back "covered by null".
  elementFromPoint returns null for a point outside a scrollable ancestor's clip —
  scrolled out of view, not covered by anything.

A toast partly over a control is a warning rather than a failure now: it dismisses
itself and carries a close button, and on a desktop it necessarily lands over a
table that fills the content column. Fully covered by a toast is still a failure,
because that was a real bug once — a seat sat exactly under the Undo button.

Final sweep: 13 viewports, 65 overlay checks, 13 full-page audits, 4 Quick Split
audits, zero failures, one warning. 137 unit tests still passing.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 08:31:35 -06:00
jheapsandClaude Opus 5 0d6e7e0b5a Give the standings table back its player names
Went looking for more rules bugs and didn't find any — the blind rotation is fair
over an orbit, nobody is double-charged or skipped when players come and go, and
the big blind's option is handled correctly including heads-up and when the blind
puts them all-in. Those probes are written up below.

What I did find was a regression I shipped two rounds ago.

The name column was zero pixels wide

Adding the "Cash out" column in the bank-audit round made five columns. Four of
them are numbers with a 4.5rem minimum, which with gaps needs 320px — and the
sidebar is about 290px. The name column is the only flexible one, so it absorbed
the shortfall and collapsed to nothing. Player names were not truncated, they
were entirely invisible, and the row quietly overflowed.

Measured at 360, 390, 1024, 1280 and 1600 alike, because the sidebar is narrow at
all of them — this was not a phone problem, it was everywhere except the tablet
layout where the sidebar goes full width. Names were already down to 50px with
four columns, so this had been getting worse before I finished it off.

The table earns its columns now instead of assuming them

Narrow: player, net, cash out — the two numbers a banker settles up from. Wide
enough: the per-hand In and Won as well. A container query rather than a viewport
one, because the sidebar is 290px at both 360px and 1600px viewports and the
thing that matters is the space the table actually has. Names get a real minimum
and ellipsis rather than being crushed. Everything stays in the CSV either way.

    360x640    name 0px -> 152px    Player | Net | Cash out
    390x844    name 0px -> 182px    Player | Net | Cash out
    768x1024   name    -> 389px     Player | In | Won | Net | Cash out
    1280x900   name 0px -> 151px    Player | Net | Cash out

Rules probes that came back clean, so nothing changed:

- blind rotation over 12 hands with 4 players: exactly 3 big blinds each
- a player leaving and rejoining mid-session never causes a double or a skip
- the big blind gets their option when everyone limps, can raise with it,
  and checking it ends the street
- a big blind left all-in by the blind itself gets no phantom option
- heads-up, the button acts first pre-flop and the big blind still has the option
- post-flop the minimum bet falls back to the big blind

Re-checked the guarantees from earlier rounds with the sidebar open: 0 of 50
controls on touch-action auto, 0 tap-highlighted, 0 occluded, stacks still inside
their pucks, no sideways scroll, bank panel still fits and reads correctly.
137 tests still passing.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 08:07:51 -06:00
jheapsandClaude Opus 5 aeb02d023d A short all-in doesn't reopen the betting
Went looking at dealer procedure again and ended up checking the rule home games
argue about longest. The tool had it wrong.

Blinds 1/2. Seat 4 raises to 10 — a full raise of 8. Seat 1 calls. Seat 2 is
all-in for 13: three more, where a full raise would need eight. Under TDA rule 44
that does not reopen the betting to anyone who has already acted; they owe the
extra three or they fold. Seat 1 and seat 4 were both being handed another go.

Two things were wrong in raiseTo. Any increase at all reset hasActed for every
live player, so a short shove gave the whole table another chance to raise. And
lastRaiseSize was overwritten with the short amount, so the next legal raise was
computed off 3 instead of 8 and came out too small — a second, quieter error that
only shows up when somebody then tries to raise.

Both now key off whether the increase was actually a full raise. A player who had
not yet acted keeps full rights, which falls out naturally because their hasActed
is already false — that distinction is the part of the rule people get wrong in
the other direction.

Everyone still owes the difference: isStreetComplete already asks anyone with
chips left to call, so the short all-in is answered without being re-raiseable.

The rulings sheet now covers what the tool does for you

It had three entries. A dealer challenged on a payout couldn't look up why the
tool did what it did, even for the rules it enforces automatically. Added the
short all-in rule above, uncalled chips coming back rather than being announced
as a side pot, and the odd chip in a chopped pot going to the first player left
of the button — all three are things this tool already decides silently, and all
three are worth being able to point at when somebody objects.

10 tests (137 total): already-acted players aren't reopened but still owe the
difference, a player yet to act keeps full rights, the minimum raise isn't
shrunk, calling it ends the street, no chips are created, and — the other half of
the rule — a full-sized all-in, an ordinary raise, a raise exactly matching the
last one, and a full-sized opening bet all still reopen everyone.

Validated through the real action bar: after the short shove, lastRaiseSize stays
8 and the player who had already called stays settled, while the two who hadn't
acted keep their turn. The longer rulings sheet still fits a phone, scrolls, traps
focus and closes on Escape.

Sources: TDA rules via thelodgepokerclub.com/tda-rules/,
pokernews.com/strategy/deal-with-it-tips-for-mistake-free-dealing-in-home-games-30231.htm

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 07:52:08 -06:00
jheapsandClaude Opus 5 60d192a9ec Let the banker check the cash box against the chips on the table
Ran out of things I could find by auditing the UI, so I went and read what people
who run home games actually complain about. The recurring one isn't a dealing
mistake, it's a banking one: "the last two guys go to cash out at the end of some
all-night game and there isn't enough cash to cover the chips they have."

Every chip on the table is a promise to pay cash for it later. Nothing in here
checked that the promise could be kept, and there's no way to notice by eye —
it's always a rebuy nobody wrote down, or chips taken without paying, and it
stays silent until the box is empty and two people are still holding chips.

The books now add up, or say why not

HoldemState.bankReconciliation compares the cash taken in against every chip that
could be handed back — stacks, chips in front of players, and the pot waiting to
be won. The sidebar shows both numbers and what they mean:

    Balanced — the cash covers every chip on the table.
    $160 more in chips than cash taken. Someone's rebuy probably wasn't
      recorded — the box will be short at cash-out.
    $150 more cash than chips in play. Someone has paid for chips they
      haven't been given.

Money in the middle counts as chips in play, so an open pot isn't reported as a
shortfall — otherwise it would cry wolf during almost every hand.

Players who had bought in were missing from the settle-up

sessionStandings was built from the hand log, so anybody who hadn't yet been in a
recorded hand didn't appear at all. Somebody hands over $200, sits down, folds a
few, and the banker's own record doesn't mention them — then they turn up at
cash-out holding chips the list never accounted for. Anyone with money in the
game is in the list now, whether or not they've played a logged hand.

The settle-up number was already being computed and hidden

cashPosition — chips in front of them less the cash they handed over — was
calculated for every player and written to the CSV, but never shown on screen.
It's the number you read while counting out cash at the end of the night, so it's
a column now.

15 tests (127 total): a buy-in with no hands played still appears and reads as
level rather than a loss, empty chairs stay out, a player who cashed out shows
what they took, both directions of imbalance are caught, an open pot is not a
discrepancy, six played-and-settled hands never knock the books out, a proper
rebuy balances while a chip grab doesn't, and float dust isn't an alarm.

Validated with Playwright through real play — seated, blinds posted, pot open,
hand settled, rebuy — balanced at every step, plus the three seeded verdicts and
a fresh table where the panel stays hidden.

Sources: pokerlistings.com/blog/how-to-run-a-home-poker-game,
chipsoffury.com/learn/how-to-handle-poker-buy-ins-with-friends/

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 07:33:16 -06:00
jheapsandClaude Opus 5 cc6a1902bc Stop the tool from throwing a pot away without saying so
Two ways to lose a hand's money by accident, both found by walking the settle
flow and checking the chip count afterwards.

Next Hand destroyed an unresolved pot silently

Starting the next hand clears every seat's contribution, so money still in the
middle simply vanished. Measured at a four-handed table with $8 in the pot: the
table went from 800 chips to 792, and the toast said "Next hand — button on seat
3. Blinds posted." — not a word about the eight chips that had just left.

It now asks, names the amount, and says what will happen to it. Confirming still
works and the toast says what went. The undo was always there, but a dealer
can't undo something they were never told about.

The wizard's "Close" abandoned a payout that looked finished

After picking winners and confirming, the wizard shows "Final Payouts — Ann $8"
above two buttons: "Start Next Hand" and "Close". Close pays nobody. Under that
heading, with a name and an amount beside it, a dealer taps Close believing the
pot has been pushed — and walks away with the chips still in the middle and the
hand unlogged. The button now says "Cancel — don't pay yet", with a line
underneath spelling out that nothing has been paid.

The warning measures the right thing

The first version counted the pot badge, which was wrong in a way the tests
caught before it shipped: the table-wide base pot is a standing ante for dealers
who don't track stacks, and it re-applies to the next hand rather than being
lost. Quoting it would have put a confirmation in front of those dealers before
every single hand — a nag, on the simplest way to run a table, for money that
wasn't going anywhere.

HoldemState.unresolvedContributions() measures exactly the fields startNextHand
clears: banked contributions, chip counts, and whatever is still in front of
players on this street. Verified both ways in the browser — a base-pot table
showing a $100 pot is not asked at all, a stack-tracking table with $8 live is.

6 tests (115 total): the quoted number equals what actually leaves the table,
mid-street chips are included, a settled hand leaves nothing to warn about, five
settled hands conserve every chip, an empty table has no phantom pot, and a
standing ante is never warned about.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 04:39:51 -06:00
jheapsandClaude Opus 5 a488bbf832 Stop rebuilding the hand log after every fold, call and raise
Profiled a long night at a full table — 24 seats, 200 logged hands — because
that's the state the tool is slowest in and the one a dealer reaches at 2am.

A full re-render cost 30ms, and it runs after every single action. Broken down,
25.7ms of that was renderHandHistory: tearing down and rebuilding 200 rows of a
panel that hadn't changed and that nobody is looking at mid-hand. Everything
else on the table put together came to about 4ms.

The log only changes when a hand is settled or the history is cleared — maybe
once every thirty taps. It now carries a cheap signature (length, hand counter,
and the newest row) and is skipped when that hasn't moved. Entries are immutable
once written and only ever added at the front, so the signature catches a
recorded hand, a cleared history, and an undo that puts a hand back.

    renderAll, every action     30.07ms -> 4.18ms
    renderHandHistory, skipped  25.74ms -> 0ms
    renderHandHistory, rebuild  25.74ms -> 9.05ms

The rebuild got faster too, for the times it does run — once per hand, where
25ms of jank lands exactly on the "next hand" tap. toLocaleTimeString was the
single most expensive call in the loop and a logged hand's timestamp never
changes, so the formatted string is kept; and the rows are built into a fragment
so the panel lays out once instead of two hundred times.

A cache like this is only worth having if it can't go stale — a dealer reading
last hand's payouts while settling this one is worse than a slow panel. 10 tests
cover the invalidation directly: unchanged through a street of betting, changed
by settling, by clearing, by an undo back to a shorter log, by two hands that
differ only in the pot, and by the counter moving on.

Validated with Playwright against the real page: the rendered rows match the
stored history through settling a hand in the wizard, undoing it, clearing the
log and recording again. Touch and layout guarantees from the earlier rounds
still hold — 0 of 87 controls on touch-action auto, 0 tap-highlighted, every
stack still inside its own puck. 109 tests passing.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 04:24:36 -06:00
jheapsandClaude Opus 5 9ee4816fa4 Make the overlays usable from a keyboard
Measured every overlay in the tool before touching anything. For a dealer running
this on a laptop, all six were broken in the same three ways.

- focus never moved into the dialog: 0 of 5 measured
- Tab walked straight out of the dialog into the page behind the scrim: 5 of 5
- Escape did nothing: only the rulings sheet handled it, 1 of 5
- the pot wizard — the dialog that pays people — had no role and no
  accessible name at all

So opening the pot wizard left focus on the button behind it, tabbing wandered
through a page nobody can see or click, and there was no way back out except the
mouse.

One implementation, not six

The overlays are opened and closed by five different modules, which is why they
each missed the same things. Rather than patch this into all of them, modal.js
watches the `hidden` attribute they already toggle: focus moves to the first real
control on open, Tab and Shift-Tab wrap inside the dialog, Escape closes it, and
focus returns to whatever opened it. `aria-modal` is set while open and removed
on close. An overlay added later gets all of it without anyone remembering to
wire it up, which is the failure being fixed.

Escape presses the dialog's own cancel button rather than just hiding the
overlay, so the confirm dialog still resolves its promise — verified: Escape
resolves it to false rather than leaving an await hanging forever.

Shortcuts.overlayOpen() had the same shape of bug, listing the six overlay ids by
hand. It asks the document now, so a sheet added later can't quietly start
letting "f" fold the table behind it.

Focus you can see

Three focus rules existed for the whole stylesheet, so tabbing relied on the
browser default ring, which on these dark custom-painted controls is close to
invisible. `:focus-visible` keeps it to keyboard use so a dealer tapping Fold
isn't left with a ring on it.

That surfaced something small: `.holdemBtn` used `transition: all`, so the new
outline faded in over 150ms and the ring lagged a step behind the Tab key. The
transition names its properties now. Measured after: 16 of 16 tab stops show a
ring immediately, and the press state from before still animates.

Also

- a mistyped negative stack read back as negative, and flowed into putIn() where
  Math.min(amount, -50) moves a negative amount and adds chips to the table.
  Clamped at zero.
- accessible names for the chip colour, chip value and notes fields, which had none

11 new tests (99 total), covering negative stacks, negative and unreadable
blinds, blinds nobody can cover, and a save that comes back corrupt, truncated,
or from before a field existed.

Playwright: all five overlays re-measured — focus moves in 5/5, Tab and
Shift-Tab trapped 5/5, Escape closes 5/5, focus restored 5/5, role and
aria-modal present 5/5. Shortcuts still fire when no dialog is open and are
still blocked when one is. Touch guarantees from the earlier round unchanged:
0 of 49 controls on touch-action auto, 0 tap-highlighted.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 04:12:55 -06:00
jheapsandClaude Opus 5 db208a1a86 Make big numbers readable, and make the export add up
Seeded a table that looks like a real game — "Mike from work", "Jean-Philippe",
stacks from $12 to $3,200,000 — and measured what the layout does with it.

".00" was costing a third of a seat puck

Every amount rendered with two forced decimal places. Measured, the trailing
".00" is ~22px, against the 64px a seat puck has to show a stack in. So
"$1,250,000.00" needed 102px of a 64px box — and because a number has no spaces
to break on, it neither wrapped nor ellipsised. It drew straight out over the
seat next to it.

Money now shows cents only when there are cents. A chip game deals in whole
numbers nearly all the time, and "$12" says exactly what "$12.00" said in half
the width. A quarter-blind game is untouched: $0.25, $12.50 and $0.75 pots all
still read as before, checked against a 25c/50c table.

That alone took "$1,250,000.00" down to "$1,250,000" — 102px to 80px, still
wider than the 64px puck. The remaining overflow is handled by stepping the font
down on long amounts, which keeps every digit. Abbreviating to "$1.25M" would
have fit more comfortably and was the obvious move, but a dealer reading a
rounded stack off the felt is a worse failure than a small one, and the exact
figure is the entire point of the tool. Measured after: every stack now paints
inside its own puck, nothing spills onto a neighbour.

Because Money.format is the single formatter all 47 call sites go through, this
lands on the seat pucks, action bar, pot breakdown, seat menu, standings and
history at once — the payoff from consolidating it earlier.

The CSV export couldn't be added up

The file exists so a house manager or a disputed chip count has something that
outlives the tab, and every money column in it was written as text:
"$1,250,000.00". The commas were quoted correctly so the file always parsed —
it just imported as strings, so the one thing anyone opens it to do, total a
column, needed every cell cleaned first.

Money columns now go in as bare numbers (1250000.00) with no symbol and no
separators. The columns meant for humans stay human: names are still names,
payouts still read "Alexandra: $1,250,000", and a name with a comma in it is
still quoted.

12 new tests (88 total, all passing), including the case that would be ruined by
dropping cents unconditionally, and a proper quote-aware CSV parser to check
every money cell parses as a number.

Re-ran the occlusion sweep from the previous round at 9 seats, 10 seats and
tournament stacks: still clean, no regression from the font changes.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 03:54:03 -06:00
jheapsandClaude Opus 5 3d63ba50ee Handle the messy table: people joining, leaving, and arguing about blinds
Home games don't run like a cardroom. People walk up mid-hand, storm off
mid-hand, and pass the button round three times before anyone deals. Probing
each of those found four problems, one of which loses chips.

A player taken out mid-hand took their chips with them

`computeContribution` opened with `if (!player.inHand) return 0`, so the moment
a dealer switched a seat off, everything that seat had already put in vanished
from the pot. Measured: four players in for $2 each, one taken out, pot drops
from $8 to $6. Their $2 was still sitting on the seat record — it just stopped
counting. A dealer following the tool would have paid out $6 of an $8 pot.

Chips already pushed into the middle are dead money. They stay in the pot and go
to whoever wins, whether or not the player is still in the hand, and it is not
theirs to take back. The `inHand` check now guards only the table-wide base-pot
default, which does have to stay tied to being dealt in or every empty chair
would be treated as having anted. The pot wizard counts a departed seat as
folded, so its chips are in the pot but it can never be picked as a winner.

Passing the button before the cards come out

Moving the button left the posted blinds where they were while everything
positional started reading off the new spot, so the money and the blind
positions silently disagreed. While nothing but the blinds are in, moving the
button now refunds them and re-posts from where it lands — pass it round six
times and the chip count is unchanged. Once anybody has acted the blinds are
settled and it stops offering.

Naming the blinds by hand

Blinds were purely positional with no way to overrule them. The seat menu now
offers "Make small blind" / "Make big blind" while only the blinds are in, and a
badge with a ring marks a blind that was named rather than dealt. Naming one
pins the other where it is. It's refused if it would put both blinds on one
player or post to somebody who isn't dealt in, and it lasts one hand — carrying
it forward would freeze the blinds on two people for the night.

The first hand, and a table that empties out

A fresh table puts the button on seat 1 whether or not anyone is sitting there,
and a table can empty out around it. The button is moved onto a real player
before a hand is dealt.

Joining mid-hand

A newcomer was added straight into the live seats and would be asked to act in a
hand they hadn't been paying into. They now sit the hand out and are a full
player from the next one.

Nothing is covered on a phone any more

Swept 13 viewport sizes with elementFromPoint, five points per control, on the
table and every overlay. Before: the action bar covered the bottom seats on
short screens (62px of felt at 360x640), the toast covered up to six seats at
once — Undo button included, so a tap meant for a player hit "Undo" — and in
landscape the table came out 765px tall in a 390px window, putting six of nine
seats off-screen entirely.

- the action bar's real height is published as `--actionBarHeight` and drives
  both the page's bottom padding and the toast, replacing a hardcoded 9rem that
  was wrong the moment the raise pad opened
- landscape sizes the felt from the height that's left, not from width. The
  first attempt did nothing: `padding-bottom` is a percentage of the containing
  block's *width*, so narrowing the wrap left it full height, and a
  `max-width: 900px` rule later in the file was overriding it anyway. It's set
  outright now and lives at the end of the sheet.
- on short screens the toast floats over the action bar's status readout rather
  than over the seats. Nowhere is clear of both; covering something you re-read
  beats covering something you tap.
- headers go compact on short screens, which is where the vertical room came from

13/13 sizes clean, 360x640 through 844x390, portrait and landscape.

Also fixes a marker that flickered: `classList.toggle(cls, undefined)` toggles
rather than clearing, and `manual` is undefined for positional blinds, so the
hand-picked ring appeared on alternate renders.

18 new tests (76 total, all passing).

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 03:40:02 -06:00
jheapsandClaude Opus 5 073ba9a252 Make every tap in the dealer land instantly on a phone
Four measured problems, all of which only show up under a thumb.

Every tap waited on the browser (53 of 53 controls)

Not one interactive element set `touch-action`, so a mobile browser held every
tap for ~300ms to see whether a second one was coming that would mean "zoom in".
That is on the fold button, the call button, every seat, every card slot.

Worse, this tool uses double-tap on a seat as the fold shortcut — which *is* the
browser's zoom gesture. The dealer's fastest shortcut was fighting the page
zooming under their thumb. A single seat tap cost ~300ms of browser hesitation
before the app's own 220ms double-tap window even started.

`touch-action: manipulation` everywhere keeps panning and pinch-zoom and drops
only double-tap-to-zoom, which is precisely the one that was in the way.

Press feedback was the OS grey box (1 `:active` rule in 2385 lines)

Only `.actionBtn` had a pressed state. Everything else — seats, header buttons,
card slots, picker cards, menu rows, toasts, wizard choices — relied on the
translucent grey rectangle the browser paints on tap. So the tap highlight
couldn't just be switched off; the controls needed real pressed states first.
They press down 1px and brighten, in 0.04s, matching the action bar that already
did. Then the grey box goes.

Hover stuck after every tap (21 unguarded rules)

`:hover` has no meaning on touch, but it still applies — and stays applied until
you tap elsewhere. Tapping "Next hand" left it lifted and lightened as though
still under a cursor, and it would have fought the new pressed states directly.
All of them are behind `@media (hover: hover)` now. The one rule that shared a
block with `:focus-visible` was split so keyboard focus survives.

The notes field zoomed the page (iOS)

`#tableNotes` was 13.3px. iOS zooms the viewport when a field under 16px takes
focus, and the dealer is then pinching back out to see the table. Every other
input here was already 16px; this was the straggler.

Seats deliberately have no `:active` rule

Worth writing down because it nearly went in. A seat's `transform` is what
positions it around the felt, so a pressed state setting `transform` drops the
`translate(-50%, -50%)` and throws every pressed seat ~38px off its spot.
Validation caught it: seats already dip via `.seatPressing`, which `pointerdown`
adds in render.js — earlier than `:active` resolves, and it restates the
translate correctly.

Validated with Playwright at 390x844: 53/53 controls off `touch-action: auto` and
0 tap-highlighted across the table, all four overlays and Quick Split; text
fields still selectable and buttons not; 14/14 hover rules gated; presses
measured live via mouse-down (fold reads translateY(1px)/brightness(1.3), a seat
holds its translate with 2px of drift from the shrink alone). Hand played through
the UI including a double-tap fold. No console errors. Unit tests 58/58.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 03:11:39 -06:00
jheapsandClaude Opus 5 c1a484b97a Pay down the Hold'em dealer's tech debt and give it a test suite
Every module in the dealer ends with a `module.exports` guard, so they were all
written to be testable, and none of them were ever tested. The correctness fixes
that went in recently were checked by hand in a browser console that has since
been closed. This puts a real safety net under them and clears out the
duplication that made those bugs possible in the first place.

Tests (58, ~300ms, no dependencies)

Node's built-in runner, no package.json and no install step: the modules are
plain script-tag files, so `load.js` just requires them and puts them on
globalThis under the names they use to find each other in the page.

The randomised suite is the part that matters. Every bug found in this tool so
far has been shape-dependent — blinds posted to empty chairs, the button
skipping players, heads-up acting out of order — and none of them show up on the
full six-handed table anyone would write a fixed example for. It deals 1000
hands per property across random seat counts, empty chairs, stack depths and
dealer input, checking that chips are never created or destroyed, that the pot
breakdown plus uncalled returns equals the money in, that paying out settles to
the cent, and that the button and blinds always land on real players. The
generator is seeded so a failure reproduces instead of flaking.

One implementation each

- `Math.round(x * 100) / 100` was written out 16 times across 6 files. It's
  `Money.round` / `toCents` / `fromCents` now, in the module both pages already
  load first.
- The chip epsilon was declared twice with the same value under two names
  (`PotCalculator.EPSILON`, `Betting.EPSILON_CHIPS`). Both now point at
  `Money.EPSILON`.
- `Betting.seatAt` and `HoldemState.nextSeatIndex` were the same modular
  arithmetic written twice. Walking the seats is exactly where the button, the
  blinds and the action drifted apart from each other before, so there is one
  of it now.
- The undo snapshot/restore dance appeared three times in main.js, each one
  re-deleting every key on `state` in place. It's `snapshotState` /
  `restoreState` once, with the reason it can't just reassign written down.

A bug the tests found

`layers()` and `uncalledReturns()` both decide what money was never contested,
separately, and disagreed. `uncalledReturns` measured each player against the
biggest live contribution *including their own*, which makes the biggest bet its
own yardstick — so the one case it exists for came back empty, and `layers()`
announced the overbet as a side pot only the bettor could win.

With Ann shoving 200 into Ben's 60, the dealer was told "main pot 4, side pot 3,
side pot 116, side pot 140" — four pots, one of them fictional. It now reads
"main pot 123, uncalled 140 back to Ann", which is what a dealer says out loud.
Layers are derived from contributions with the uncalled money already removed,
so the two can't disagree again. A band every payer folded out of is now merged
into the pot below rather than dropped, which was quietly shrinking the
announced pot in another edge case.

Validated with Playwright against a real hand on both surfaces — the table's
live pot readout and Quick Split's all-in mode — plus undo, with no console
errors.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 03:00:36 -06:00
jheaps 6117886e70 Merge pull request 'Hold'em dealer: five correctness fixes and four polish passes (replaces #33–#41)' (#42) from feature/casino-holdem-dealer-batch into master
Build and Deploy / build-deploy (push) Successful in 1m12s
Reviewed-on: #42
2026-09-01 02:47:00 -06:00
jheaps ae8744672f Merge remote-tracking branch 'origin/feature/casino-quicksplit-side-pots' into feature/casino-holdem-dealer-batch 2026-09-01 02:38:01 -06:00
jheaps 328ef56af5 Merge remote-tracking branch 'origin/feature/casino-holdem-contrast' into feature/casino-holdem-dealer-batch 2026-09-01 02:38:01 -06:00
jheaps 7714e16547 Merge remote-tracking branch 'origin/feature/casino-holdem-touch-audit' into feature/casino-holdem-dealer-batch 2026-09-01 02:38:01 -06:00
jheaps cc31cd033b Merge remote-tracking branch 'origin/feature/casino-holdem-wizard-polish' into feature/casino-holdem-dealer-batch
# Conflicts:
#	JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js
2026-09-01 02:38:01 -06:00
jheaps f324793732 Merge remote-tracking branch 'origin/feature/casino-holdem-seat-count-safety' into feature/casino-holdem-dealer-batch 2026-09-01 02:37:41 -06:00
jheaps 56d9d740b5 Merge remote-tracking branch 'origin/feature/casino-holdem-odd-chip' into feature/casino-holdem-dealer-batch 2026-09-01 02:37:41 -06:00
jheaps e5b02b00e4 Merge remote-tracking branch 'origin/feature/casino-holdem-button-moves' into feature/casino-holdem-dealer-batch 2026-09-01 02:37:41 -06:00
jheaps 34cd7dbc59 Merge remote-tracking branch 'origin/feature/casino-holdem-allin-runout' into feature/casino-holdem-dealer-batch
# Conflicts:
#	JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
2026-09-01 02:37:41 -06:00
jheapsandClaude Sonnet 5 4f72dda6fa Ask before a smaller table deletes the players sitting in it
Typing a smaller number into "Seats at table" silently threw away every seat past
the new count. Nine players down to six removed three of them — names, stacks and
buy-ins gone, no confirmation, no way back.

It also quietly falsified the settle-up. Those players keep their rows in the
session standings, because the hand log remembers who won what, but the seat they
were read from no longer exists — so a player who had just won a pot showed as
having bought in for nothing and holding nothing.

Shrinking now says who it is about to remove and asks, and either way the change
is undoable like the rest of the table's actions. Growing the table, and
shrinking onto chairs nobody is sitting in, still happen without a word.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 02:33:03 -06:00
jheapsandClaude Sonnet 5 cc1b5011a1 Give the odd chip to the player the rule says gets it
Quick Split tells the dealer a leftover chip goes "to the first player left of
the button". The pot wizard, on the same tool, handed it to whoever the dealer
happened to tick first. Two screens, same split, different answer.

The wizard now orders the winners around the table from the button before the
calculator shares the pot out, so the leftover lands where the instruction says
it does. The calculator itself is unchanged — it already gave leftovers to the
front of the list, it was just being handed the list in the order the checkboxes
were clicked.

It's pennies, but it's pennies in front of the person who didn't get them, and a
dealer who has just read the other screen's instruction out loud looks wrong.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 02:05:57 -06:00
jheapsandClaude Sonnet 5 18bb23ad6d Pass the button to the next player, not the next chair
The button moved one seat per hand regardless of whether anyone was sitting
there. At a ten-seat table with four players — an ordinary home game — it spent
five hands out of eight on an empty chair.

That reads as untidy, but the real problem is fairness. Position and the blinds
are meant to come round to each player at the same rate. Rotating past empty
chairs breaks that: a player with three empty seats to their right takes the
button far less often than one sitting in a cluster, while both pay blinds every
time the rotation reaches them.

The button now skips to the next seat that's actually dealt in, so with four
players it visits those four in turn and each takes it once every four hands.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 01:50:16 -06:00
jheapsandClaude Sonnet 5 ae591e4212 Stop asking all-in players to act
A player who is all-in is still in the hand and still wins pots, but has nothing
left to decide. The engine didn't know that: it picked the next player to act by
walking to whoever was still in the hand, chips or no chips.

So a dealer with three players all-in pre-flop was asked to check for each of
them on the flop, the turn and the river — nine taps to say nothing — and the
action could land mid-street on someone who'd already pushed their last chip in.

Now the action passes over players who are all-in, and once fewer than two
people have chips left the betting is simply over: the bar runs Deal Flop, Turn,
River, Showdown straight through, and says "All-in — running out the board" so
the absence of a question reads as deliberate rather than as a stuck screen.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 01:37:19 -06:00
jheapsandClaude Sonnet 5 89d79a0f7d Post the blinds to the players rather than to the chairs
Blinds were assigned by seat offset — always button+1 and button+2 — which is
only right when everyone is dealt in and there are three or more of them.

Heads-up it had the blinds backwards. The button posted the big blind and acted
second before the flop; the rule is the opposite on both counts. That's the end
of every home tournament, dealt wrong.

Worse, the offsets don't care whether anyone is sitting in those chairs. With
two players at a nine-handed table, or with the seats left of the button sitting
out, the blinds landed on empty seats and simply weren't posted — no money in
the middle, and SB/BB badges pointing at nobody.

Blinds now walk the players who are actually dealt in, starting from the button,
with heads-up as its own case: the button is the small blind, acts first
pre-flop, and the big blind acts first afterwards. The badges come from the same
function as the money, so what the table shows and what gets posted can't
disagree.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 01:22:15 -06:00
jheapsandClaude Sonnet 5 d0f75d0c14 Lift the faint greys to something readable in a dim room
This is a dark interface used in poker rooms and kitchens with the lights down,
and I'd been generous with grey-on-black. Measured every piece of text against
what's actually painted behind it: thirteen declarations came in under 4.5:1.

The worst was the empty card slot's "+" at 2.63:1 — an affordance telling the
dealer the slot is tappable, and the least legible thing on the screen. Then a
run of #777 labels around 3.9 to 4.1: the standings header, the time on a logged
hand, the currency mark in the add-chips field. The rulings source line sat at
3.62 and the rulings link itself at 4.39.

All of them move up just far enough to clear 4.5 rather than being flattened to
white, so the hierarchy between a heading, a value and a hint survives.

Worth recording what the measurement can't see: it reads background-color, so
text on a gradient — the pot badge, the dealer button, the blind badges — comes
back as a false result in both directions, and those were checked by eye
instead. The felt watermark is deliberately at 6% and stays that way.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 01:06:20 -06:00
jheapsandClaude Sonnet 5 6b3a7dc09d Bring every phone target up to size, not just the ones I happened to check
Last round I measured one screen and found the smallest targets in the tool were
on it. So I measured all of them: sixty-four controls at phone width, flagging
anything under 44px. Twenty-three came back.

The sidebar had never had the touch pass the seat form got — its inputs, selects
and small buttons sat at 26 to 30px, and the buttons that delete a chip colour
or a custom field were 28px wide, which is a bad size for something destructive.
The rulings link was a 14px-tall underlined word.

The seat form itself had drifted: its mobile sizing rule named five fields, and
the stack field added two rounds later wasn't one of them, so it sat at 28px
beside its 44px neighbours. Both that rule and the new sidebar one are now
written against the form and the panel rather than a list of field names, so the
next field added inherits the size instead of waiting to be noticed.

The dealer button keeps its 22px disc — it's a chip sitting on a seat, and
drawing it thumb-sized would swallow the puck — but takes taps through an
invisible 44px pad. Verified the seat underneath still gets its own taps.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 00:38:29 -06:00
jheapsandClaude Sonnet 5 38488f2978 Give the pot wizard the same tap targets as everything else
Measured the screen where the money actually gets decided and found the smallest
targets in the tool: 37px winner rows, 13px checkboxes, a 36px confirm. Every
other surface has been held to 44px and up for several rounds — the one place a
mis-tap pays the wrong player was the one place that hadn't been.

Winner rows are now 56px and up, with a 22px box and a green fill when chosen,
because a dealer confirms this with players watching and a small checkbox is
easy to misread across a table. The row already was the tap target; now it looks
like one.

Each row also splits into name, what they can win, and their cards as separate
pieces rather than one run-on sentence, so the hand can be read at a glance. The
confirm takes the full width, and on a phone the summary's buttons stack with
the one that ends the hand furthest from a stray thumb.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 00:22:14 -06:00
jheapsandClaude Sonnet 5 7c64749267 Let Quick Split handle the pot it used to send people away for
Quick Split answered the easy half of the question and printed a footnote for
the hard half: if somebody was all-in for less, go set up the full table. That's
the moment a dealer least wants to be setting up a table.

A second mode takes what each player put in and lists the main pot and each side
pot with who can win it, plus anything uncalled that goes back. Rows can be
marked folded, so dead money lands in the right pot without its owner being able
to win it. The running total is shown so the breakdown can be checked against
the chips actually on the table.

It reuses the same pot maths as the full table rather than growing a second
implementation, which means the two screens can't drift apart and Quick Split
inherits the uncalled-bet and everyone-folded handling that was fixed there.

Also fixes hiding: these sections carry a `display` declaration, which outranks
the browser's own [hidden] rule, so toggling `hidden` on them did nothing and
both modes' controls showed at once.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 00:07:37 -06:00
jheaps e8d6098d39 Merge pull request 'Add chips to a seat without leaving the table' (#32) from feature/casino-holdem-rebuys into master
Build and Deploy / build-deploy (push) Successful in 1m7s
Reviewed-on: #32
2026-08-31 23:57:01 -06:00
jheaps 5d1a6b7399 Merge branch 'master' into feature/casino-holdem-rebuys 2026-08-31 23:56:53 -06:00
jheaps a816b32426 Merge pull request 'A rulings reference for the calls that come up mid-hand' (#30) from feature/casino-holdem-rulings into master
Build and Deploy / build-deploy (push) Canceled after 31s
Reviewed-on: #30
2026-08-31 23:56:40 -06:00
jheaps 5271d5fbad Merge branch 'master' into feature/casino-holdem-rulings 2026-08-31 23:56:32 -06:00
jheapsandClaude Sonnet 5 552ec1a542 Add chips to a seat without leaving the table
Somebody rebuying is the most common interruption in a cash game, and the only
way to record it was to open the seat editor and retype a stack from memory.

Press and hold a seat and there's now "Add chips…": quick amounts off the
table's starting stack — half a buy-in, a buy-in, two — with a keypad for the
times it isn't one of those. It's undoable like every other seat action, and it
records cash handed over as well as chips added, because those answer different
questions. The stack moves every hand; what a player has paid in only moves
when they buy.

The export gains bought in, chips now, and up/down, so settling up at the end of
the night is reading a row rather than reconstructing it.

Getting that last column right needed care. Chips in front of a player disagree
with the hand log for as long as a hand is open — and since blinds post the
instant the previous hand settles, that window is almost always. Counting what a
player already has in the current pot as still theirs makes the cash position
agree with the hand log at any moment, including across a rebuy.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 23:53:15 -06:00
jheaps 5c807d1581 Merge pull request 'Feature/casino holdem sidebar polish' (#31) from feature/casino-holdem-sidebar-polish into master
Build and Deploy / build-deploy (push) Successful in 1m5s
Reviewed-on: #31
2026-08-31 23:36:45 -06:00
jheaps a5b9c26902 Merge branch 'master' into feature/casino-holdem-sidebar-polish 2026-08-31 23:36:37 -06:00
jheapsandClaude Sonnet 5 9a5b974fda Add a rulings reference for the calls that come up mid-hand
The tool handles the money; the other half of dealing is the calls. A home-game
dealer who isn't a pro has to rule on a misdeal or a prematurely turned board
card with a table watching, and the usual outcome is an argument settled by
whoever is loudest.

A "Common rulings" link under the table opens a reference for exactly the
situations that come up: what counts as a misdeal, what to do when the fourth
board card gets turned before the betting is finished, and how acting out of
turn is normally treated. It follows the Tournament Directors Association
rules, and says plainly that house rules win and that a dispute gets settled
between hands rather than during one.

It's a sheet rather than a sixth sidebar panel — a dealer consults this rarely
and needs it immediately, which is the opposite of what a panel is for.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 23:35:59 -06:00
jheaps b563a56563 Merge pull request 'Put bets on the felt as chips instead of numbers in a box' (#29) from feature/casino-holdem-bet-chips into feature/casino-holdem-sidebar-polish
Reviewed-on: #29
2026-08-31 23:35:28 -06:00
jheapsandClaude Sonnet 5 72ee2fb2b7 Put bets on the felt as chips instead of numbers in a box
The seat puck showed a green figure that meant "what this player just bet"
during a street and "what they've put in this hand" between streets — two
different numbers wearing the same badge, sitting directly above their stack
size. Easy to misread, and nothing on the table looked like money.

Bets now sit on the felt between their player and the pot, as a chip coloured
by the largest denomination in the chip legend that fits the amount: a $30 bet
shows green against a $25 chip, a $2 bet shows white. When a street closes the
chips come off the felt and the pot badge keeps the money, the way they'd be
swept in. The puck's figure now always means the same thing — this player's
total for the hand.

On a phone the bet clings to its own puck instead of sitting on its own ring.
That isn't a preference: sweeping every radius between the seat ring and the
pot badge at 390px showed each one collides with either the pucks or the pot,
because the felt has no room for a third ring at that size.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 23:24:39 -06:00
jheapsandClaude Sonnet 5 ae927983e7 Tidy the sidebar down from five panels to four
Each round added a panel, and the sidebar became five identical dark accordions
with no hierarchy between them — which is the polish debt I've been pointing at
for a while without paying it down.

Session Standings and Hand History were always two views of the same log, so
they're one Session panel now, with subheads and the export and clear buttons
at the bottom where they act on both. Base pot amount is the pre-live-betting
way of working, so it moves into a "Without live betting" disclosure inside
Table Setup rather than sitting between two settings a dealer actually touches.
Panels are ordered by how often they're opened, and the hints are shorter.

Also stops "Small blind" wrapping onto two lines beside its input, which made
the pair look like a mistake rather than a pair.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 23:06:30 -06:00
jheapsandClaude Sonnet 5 4ceeed71a2 Add keyboard control for a dealer working on a laptop
The phone got gestures a few rounds back; the desktop still had a mouse and
nothing else. On a bar that gets tapped thirty times a hand, moving a hand to
the trackpad between every decision is the slow part.

F folds, C checks or calls, R opens the raise pad, A shoves, Enter confirms a
raise or moves the hand on, Esc backs out, U takes the last action back. Each
button wears its key in the corner, hidden on touch devices where it would just
be clutter on a button being tapped anyway.

The shortcuts press the same buttons a dealer would tap rather than calling the
betting engine themselves, so there's no second code path to drift out of step
with what the bar is actually offering — if a button isn't on screen, its key
does nothing.

Nothing fires while a name, amount or note is being typed, or while an overlay
owns the keyboard. Typing "Francesca" into a seat name must not fold the table.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 22:52:52 -06:00
jheapsandClaude Sonnet 5 95c523738c Let the dealer take back an action from the bar
An audit of what's accumulated turned up an inconsistency worth fixing: double-
tapping a seat to fold someone was undoable, but tapping Fold in the action bar
— the button a dealer actually uses all hand — was not. Nor was Call, Raise, or
dealing a street. The safest path through the tool was the shortcut, and the
main one had no way back.

Every action taken from the bar now goes through one place that records it
first, and the bar carries a standing Undo naming what it will take back:
"Ben's fold", "dealing the flop". Twelve steps deep, because a misclick usually
gets noticed a beat late and one level of undo would only cover the luckiest
case.

It's a control rather than a toast per action. A dealer taps this bar
constantly, and a toast after every fold is noise they'd learn to ignore —
which is precisely when they'd miss the one that mattered.

The history is discarded whenever a hand starts or settles, because those
snapshots describe a hand that no longer exists. The whole-hand Undo on the
toast already covers that boundary.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 22:37:03 -06:00
jheapsandClaude Sonnet 5 1fcc058dd8 Show how long the current player has been thinking, and how many are left
Dealing guides put the biggest time sink not in shuffling or spreading cards
but in players who haven't noticed the action is on them — and they list the
per-street count of players remaining as part of what a dealer announces.

The action bar now carries both. A stopwatch runs on whoever is to act, going
amber at thirty seconds and red at a minute, so a dealer can see at a glance
whether someone is genuinely deciding or has stopped paying attention. Nothing
happens when it grows; it is a prompt for the dealer, not a clock on the
player. Beside it is the count of players still in the hand.

The timer restarts only when the action actually moves, so re-rendering for an
unrelated reason doesn't put a thinking player back to zero, and it ticks by
repainting its own text rather than rebuilding the buttons under the dealer's
thumb. It stops when the hand ends or the bar hides.

Also stops the side-pot breakdown from crying wolf: it now waits for someone to
actually be all-in. Unequal amounts in front of players are normal mid-street —
the blinds alone are unequal — and layering those announced a side pot on every
hand before anyone had acted.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 22:21:39 -06:00
jheapsandClaude Sonnet 5 ee25e2a4fa Show the side-pot breakdown before the showdown, not after
Dealing guides are consistent that the most common side-pot error isn't
miscounting the chips, it's failing to announce the breakdown clearly before
the showdown. The tool had the arithmetic right but only revealed it after the
hand, one layer at a time, as the wizard asked who won each.

The breakdown is now visible while the hand is still running: as soon as an
all-in splits the money, the action bar lists the main pot and each side pot
with the amount and who can win it, and the wizard repeats it under "Announce
before the showdown" before asking for a winner. A hand with a single
undivided pot shows nothing, so the line means something when it appears.

Chips nobody could call are called what they are. Money above the largest live
contribution was never contested — usually an overbet by someone who then
folded — so it's listed as "Uncalled, back to X" rather than padding a side
pot nobody can win. The wizard's leftover warning says the same thing instead
of telling the dealer to un-fold someone to win it, which was the wrong advice
for chips that should simply be pushed back.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 22:10:19 -06:00
jheapsandClaude Sonnet 5 dabf1100c9 Add double-tap and press-and-hold shortcuts to the seats
Folding someone meant opening their editor, finding the Fold box, ticking it,
and dismissing the sheet — four interactions for the thing that happens most
often in a hand.

Double-tap a seat to fold, and again to put them back. Press and hold for a
quick menu of everything else you do to one player: fold, all-in, sit out or
deal in, hand them the button, or open the full editor. Both are undoable the
same way the header buttons are, because a gesture is easier to fire by
accident than a button.

The single tap now waits out the double-tap window before opening the editor —
a fifth of a second of latency on opening a form, in exchange for a fold that
doesn't also fling a sheet up over the table. A press that moves is a drag, so
the hold is cancelled and swapping seats still works, and the puck shrinks
slightly while held so a press that's being counted looks different from one
that's being ignored.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 21:54:56 -06:00
jheapsandClaude Sonnet 5 0759d89d13 Pick hole cards from a grid instead of typing them
Typing "AH" into a two-character box is fine at a desk and miserable at a table:
it needs a keyboard, it accepts nonsense like "ZZ", and it happily deals the same
card to two players.

The two card fields become face-up card slots. Tapping one opens a 52-card grid
— four suit columns, thirteen rank rows, one tap per card — and picking the first
card moves straight on to the second, because a dealer picking hole cards is
always picking two. Cards already dealt elsewhere at the table are greyed out and
say who has them, so a duplicate is visibly out of play rather than silently
accepted.

Cards are still stored as the same two plain characters everything has always
read; the suit only becomes a symbol on its way to a human. That formatting is
now shared, so the table pucks, the seat editor, the pot wizard, the hand history
and the CSV all say A♥ K♦ rather than three of them saying AH KD.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 21:32:43 -06:00
jheaps 01a1728971 Merge pull request 'Live betting: tap the action instead of typing totals' (#21) from feature/casino-holdem-live-betting into master
Build and Deploy / build-deploy (push) Successful in 1m11s
Reviewed-on: #21
2026-08-31 19:19:16 -06:00
jheapsandClaude Sonnet 5 7bedee2934 Track the betting live so the dealer taps instead of types
Until now a dealer kept the money in their head and typed each player's total
into a form. This adds the running hand: blinds post themselves, every seat has
a stack, and one tap per decision moves the action on.

The bar sits at the bottom of the screen where a thumb already is, and shows
only what's true right now — Fold, then Check or Call with the amount owed, then
Bet or Raise. Calling more than a player has left says "Call all-in" rather than
quoting a number they can't cover. Raising offers min, half pot, pot and all-in
before it offers a keypad, because a dealer calling "raise to sixty" wants a
button. When everyone has answered, the bar becomes Deal Flop, then Turn, River,
and finally Showdown, which hands off to the pot wizard that already existed.
Winnings go straight back in front of the winner, so the next hand's stacks are
already right.

The money hooks into what was already there rather than running beside it: a
street's chips sweep into each seat's amountOverride, the same field the pot
badge, the pot wizard, the session standings and the CSV export all read. So
nothing downstream changed, and a dealer who prefers the old typed workflow
still has it.

Tables saved before this exist without any of these fields, so load() fills
them in rather than making someone reset a table mid-session.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 19:01:08 -06:00
jheaps 7143e4e701 Merge pull request 'Feature/casino holdem undo toasts' (#19) from feature/casino-holdem-undo-toasts into master
Build and Deploy / build-deploy (push) Successful in 1m15s
Reviewed-on: #19
2026-08-31 18:41:22 -06:00
jheaps e8797b8dc2 Merge branch 'master' into feature/casino-holdem-undo-toasts 2026-08-31 18:41:13 -06:00
jheaps 48bd3682f2 Merge pull request 'Make the four seat states readable across the table' (#18) from feature/casino-holdem-seat-legibility into feature/casino-holdem-undo-toasts
Reviewed-on: #18
2026-08-31 18:39:58 -06:00
jheapsandClaude Sonnet 5 1e1bdcccce Make the four seat states readable across the table
A seat could be live in the hand, folded, sitting out, or empty, and three of
those looked nearly the same: a plain dark puck, or a dimmed one. An unclaimed
chair was indistinguishable from a nameless player, and nothing positively said
"this player is in" — the dealer had to infer it from whether a dollar badge
happened to be showing.

Each state now has one look. Live gets a green edge. Folded gets a red edge and
a struck-through name, so it reads as out rather than merely faint. An empty
chair is the only dashed outline and says so. Sitting out just fades — and
fades less than it used to, since that player is still someone the dealer may
need to deal back in, and at 0.4 the name was nearly gone.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 18:36:54 -06:00
jheaps aee172a5cd Merge pull request 'Quick Split: chop a pot from your phone without setting up a table' (#15) from feature/casino-holdem-quick-split into master
Build and Deploy / build-deploy (push) Successful in 1m14s
Reviewed-on: #15
2026-08-31 18:33:05 -06:00
jheaps d206758729 Merge branch 'master' into feature/casino-holdem-quick-split 2026-08-31 18:32:56 -06:00
jheaps 4afc3649cc Merge pull request 'Make the dealer table usable on a phone' (#16) from feature/casino-holdem-mobile-table into feature/casino-holdem-quick-split
Reviewed-on: #16
2026-08-31 18:31:52 -06:00
jheaps 3866bdb335 Merge pull request 'Replace the browser confirm boxes with undo and in-page dialogs' (#17) from feature/casino-holdem-undo-toasts into feature/casino-holdem-mobile-table
Reviewed-on: #17
2026-08-31 18:31:40 -06:00
jheaps 71b33f8623 Merge pull request 'Give the Hold'em dealer a session record: hand history, CSV export and standings' (#14) from feature/casino-holdem-hand-history into master
Build and Deploy / build-deploy (push) Successful in 1m13s
Reviewed-on: #14
2026-08-31 18:31:19 -06:00
jheapsandClaude Sonnet 5 d01c67fc45 Replace the browser confirm boxes with undo and in-page dialogs
Three system confirm() dialogs sat in the middle of dealing. The worst was on
Next Hand — the most-pressed button in a session, asking "are you sure?" every
single time, stealing focus and, on a phone, taking over the screen.

Next Hand now just happens, and a toast says where the button landed with an
Undo beside it for the few seconds anyone would notice a misclick. The same
undo covers the pot wizard, which is the more valuable case: paying the wrong
player is a real mistake, and rolling it back restores the hand history entry
and the session standings along with the seats. The snapshot is in-memory only
— an undo is for the mistake you just made, not one from last night.

The two genuinely destructive actions keep a confirmation, but as an in-page
dialog that matches the table instead of the browser's own: Escape or the
backdrop cancels, Enter confirms, and on a phone the buttons go full width with
Cancel nearest the thumb.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 18:22:50 -06:00
jheapsandClaude Sonnet 5 a39675f975 Make the dealer table usable on a phone
Two things were broken on a narrow screen. The seat ring is drawn on a radius
measured from the table wrap, so at phone width the left- and right-most seats
hung off the edge of the viewport — at a ten-handed table two players were
simply not on screen. And tapping a seat opened an editor far enough down the
page that it looked like nothing had happened.

The ring is now inset by half a puck on phones, which keeps every seat on
screen, and the pucks are narrower there so neighbours stop overlapping where
the ring flattens out at the top and bottom. The seat editor becomes a bottom
sheet that slides up over the table: grip, Done button, tap the same seat again
to dismiss, table still visible above it. Its controls grow to thumb size, and
every field goes to 16px — below that iOS zooms the page on focus and the
dealer has to pinch back out mid-hand.

Header actions become full-width tap targets, and the page padding tightens to
give the table the width it needs. Nothing above 700px changes.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 18:11:09 -06:00
jheapsandClaude Sonnet 5 870dee4a29 Add a phone-first Quick Split screen for chopping a pot mid-hand
The dealer table is a two-handed, big-screen tool. A dealer standing over a
chopped pot with a phone in one hand and players waiting needs an answer in
seconds, not a table to set up first — so /casino/texasholdem/quicksplit is
the whole job on one screen: type the pot, tap how many ways, tap the
smallest chip in play, read the payout. Nothing to configure, nothing saved.

It splits in whole chips rather than raw arithmetic, because a dealer can't
push half a chip across the felt: everyone gets the same stack and the
remainder is called out as odd chips going one at a time to the first
players left of the button. A pot that doesn't divide into the chosen
denomination says so instead of quietly hiding the difference.

Money formatting moves out of render.js into its own file so this screen can
use it without pulling in the whole seat/chip UI.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 17:53:18 -06:00
jheapsandClaude Sonnet 5 bcfc1f2f52 Track session standings so the dealer can settle up at the end of the night
The hand log answered "what happened in hand 12"; it couldn't answer "what
does Alice owe". Each resolved pot now records a per-seat ledger (what that
player put in, what they took down) alongside the payouts, and a Session
Standings panel sums it into one row per player — paid in, won, net, sorted
by who's up. The CSV export carries the same table, so one file covers both
the hand-by-hand trail and the cash-out numbers.

Keyed by seat id rather than name, so renaming or reseating a player doesn't
split their totals. Hands recorded before this change have no ledger and are
skipped rather than counted wrong.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 17:37:48 -06:00
jheapsandClaude Sonnet 5 d168fedf1f Add a CSV export for the Texas Hold'em hand history log
A dedicated dealer needs a paper trail that outlives the browser tab, not
just a scrollable in-app panel — the Export CSV button downloads the full
resolved-pot log (hand #, time, pot, payouts) for recordkeeping or a
disputed chip count after the session ends.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 17:26:11 -06:00
jheapsandClaude Sonnet 5 68e263f325 Add a persistent hand history log to the Texas Hold'em dealer tool
Give the dealer a running record of resolved pots — hand number, total
pot, who got paid what, and the time — instead of losing that
information the moment the pot wizard closes. It's the kind of audit
trail a real dealer needs when a chip count gets disputed after the
fact.

- state.js tracks state.handNumber and state.handHistory (capped at 200
  entries) and persists both through the existing localStorage save/load
  path, with backfill defaults for tables saved before this change.
- potWizard.js now hands its final payouts back to the caller instead of
  discarding them once "Start Next Hand" is clicked.
- main.js records a history entry (skipping hands where nobody actually
  won anything) before advancing to the next hand, and wires up a "Clear
  history" control that also resets the hand counter.
- A new sidebar panel lists history entries newest-first; the header
  shows a running "Hand #N" badge next to the existing subtitle.

Verified via Playwright MCP: seated two players, resolved a pot, and
confirmed the header's hand counter incremented, the history panel
listed the payout, the entry survived a full page reload (localStorage
persistence), and Clear History reset both the log and the counter. No
console errors during the flow.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01C3NiiUDimoKDJ8GjC3KL3x
2026-08-31 17:15:23 -06:00
jheaps 105a74bc51 Merge pull request 'Feature/casino texas holdem' (#13) from feature/casino-texas-holdem into master
Build and Deploy / build-deploy (push) Successful in 1m11s
Reviewed-on: #13
2026-08-31 16:06:56 -06:00
jheaps dce7e0f688 Merge pull request 'Add dedicated-dealer mode and redesign the Hold'em table' (#12) from feature/casino-holdem-dealer-mode-polish into feature/casino-texas-holdem
Reviewed-on: #12
2026-08-31 15:58:17 -06:00
jheapsandClaude Sonnet 5 3851786b90 Add dedicated-dealer mode and redesign the table's visuals
Add a "Dedicated dealer (not a seat)" toggle for tables run by someone
outside the game: it hides every seat's dealer button while blinds keep
rotating automatically off the same internal position each hand.

Also gave the table a real casino look, based on reference felt/rail and
seat-pod patterns found via web search (huggingface.co/spaces/Estazz/
PokerGameDesigner, github.com/Tehes/poker): a richer radial felt gradient,
a gold piping ring inside a wood-toned rail, a subtle center watermark, a
pill-shaped chip-well pot badge, softer seat pucks with a pill-badge total
and red/black suit coloring on cards, and casino-chip-styled dealer/blind
badges (the dealer chip now dims to a faint disc on other seats and only
lights up on hover, decluttering the ring of ten clickable buttons down to
one visible button on the actual dealer).

Verified via Playwright MCP: toggle hides/reveals dealer chips correctly,
SB/BB still track it, no console errors.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CmAti1baCjidbJM2HeJfk
2026-08-31 15:26:32 -06:00
jheaps 8e7d94e6f9 Merge pull request 'Fix Texas Hold'em dealer page: script timing crash and seat overlap' (#11) from feature/casino-texas-holdem into master
Build and Deploy / build-deploy (push) Successful in 1m8s
Reviewed-on: #11
2026-08-31 15:03:37 -06:00
jheapsandClaude Sonnet 5 25bf67062a Fix Texas Hold'em dealer page: script timing crash and seat overlap
main.js ran an IIFE immediately, but the layout renders @section Scripts
inside <head> before <body> exists, so every getElementById() call hit
null. Wrapped init in DOMContentLoaded, matching the rest of the codebase
(e.g. ChessScripts/chessMain.js).

Separately, the original seat cards packed a full data-entry form (name,
toggles, 2 cards, 5 chip counters, $ override, custom fields) into each of
up to 24 positions around a circle — at any screen size, adjacent seats
overlapped badly. Replaced with compact seat "pucks" for the table view
(name, cards, total, blind/dealer badges) plus a dedicated seat editor
panel in the sidebar that opens for whichever seat is clicked.

Verified via Playwright MCP: load with no console errors, drag-and-drop
seat swap, and a 3-way side-pot resolution (short all-in capped correctly,
remainder awarded to the next-best hand) end to end.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CmAti1baCjidbJM2HeJfk
2026-08-31 15:01:18 -06:00
jheaps 5d71572df6 Merge pull request 'Add Casino demo section with a Texas Hold'em dealer's pot-splitting assistant' (#10) from feature/casino-texas-holdem into master
Build and Deploy / build-deploy (push) Successful in 1m16s
Reviewed-on: #10
2026-08-31 14:39:02 -06:00
jheapsandClaude Sonnet 5 98f95df8fb Add Casino demo section with a Texas Hold'em dealer's pot-splitting assistant
New /casino hub page (Texas Hold'em live, other table games marked coming
soon) and /casino/texasholdem: a drag-and-drop seating table with
auto-rotating blinds, per-seat chip/dollar contribution tracking, and a
side-pot resolution wizard that asks who won and who's next until every
dollar in the pot (including dead money from folds) is assigned.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CmAti1baCjidbJM2HeJfk
2026-08-31 14:25:44 -06:00
jheaps b7c9227bb8 Merge pull request 'Learn fractional win-rate advantage instead of unbounded plus/minus' (#9) from feature/fractional-learned-weights into master
Build and Deploy / build-deploy (push) Successful in 1m25s
2026-08-31 13:43:09 -06:00
jheapsandClaude Sonnet 5 ddb309f6db Learn fractional win-rate advantage instead of unbounded plus/minus
Piece-square and feature weights now converge to a presence-weighted
fractional win-rate per cell (percent of games won with a piece there
minus percent lost), tracked via running visits/score sums instead of
an incrementally clamped nudge. The training signal is also scaled by
how much of a game's result is NOT already explained by the winner's
average material edge (via the standard centipawn win-probability
curve), so a piece no longer gets credit merely for existing on the
winning side of a material blowout in the endgame.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CmAti1baCjidbJM2HeJfk
2026-08-31 13:40:57 -06:00
jheaps 2db4048ed2 Merge pull request 'Time out Stockfish process exit so finished self-play games cant wedge in the store' (#8) from fix/stockfish-dispose-hang into master
Build and Deploy / build-deploy (push) Successful in 1m18s
Reviewed-on: #8
2026-08-31 12:22:28 -06:00
jheapsandClaude Sonnet 5 2f53c2f0ed Time out Stockfish process exit so finished self-play games can't wedge in the store
DisposeAsync awaited WaitForExitAsync with no timeout; a Stockfish process that
doesn't exit on "quit" blocked GameStore's removal task forever, leaving the
finished game stuck in the store and cluttering the live-watch list indefinitely.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CmAti1baCjidbJM2HeJfk
2026-08-31 11:23:59 -06:00
jheaps 7562ded92a Merge pull request 'Bound how long the blog cache serves stale entries' (#7) from fix/blog-cache-staleness into master
Build and Deploy / build-deploy (push) Successful in 1m4s
Reviewed-on: #7
2026-08-26 17:24:29 -06:00
Josh-HeapsandClaude Opus 5 392b9ec212 Bound how long the blog cache serves stale entries
A failed fetch fell back to the cached value with no limit, so an unreachable
blog API kept the post list rendering while every uncached slug returned 404.
Stale entries now survive an hour past their TTL, then get dropped and logged
at error so the outage surfaces the same way for every key.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-26 17:22:51 -06:00
jheaps 769c7819e0 Merge pull request 'fix workflows take 2' (#6) from fix/workflows into master
Build and Deploy / build-deploy (push) Successful in 1m6s
Reviewed-on: #6
2026-08-26 15:46:12 -06:00
Josh-Heaps c048529654 fix workflows take 2 2026-08-26 15:45:29 -06:00
jheaps e8eb90a085 Merge pull request 'Put workflow in the right spot' (#4) from fix/workflows into master
Build and Deploy / build-deploy (push) Failing after 1m7s
Reviewed-on: #4
2026-08-26 15:39:47 -06:00
Josh-Heaps fc167ae2b0 Put workflow in the right spot 2026-08-26 15:39:11 -06:00
jheaps 088f05e0d2 Merge pull request 'add deploy workflow for gitea' (#3) from gitea/workflows into master
Build and Deploy / build (push) Failing after 34s
Build and Deploy / deploy (push) Skipped
Playwright Tests / playwright-tests (push) Failing after 50s
Reviewed-on: #3
2026-08-26 15:33:17 -06:00
Josh-Heaps 04dcae7993 add deploy workflow for gitea
.NET / build (pull_request) Successful in 50s
Playwright Tests / playwright-tests (pull_request) Failing after 54s
2026-08-26 15:15:51 -06:00
Josh Heaps f956f4bffa Merge pull request #29 from JoshHeaps/chess-ui-overhaul
Build and Deploy / build (push) Failing after 1m22s
Build and Deploy / deploy (push) Skipped
Playwright Tests / playwright-tests (push) Failing after 58s
Bug fixes
2026-06-13 22:03:35 -06:00
Josh-Heaps a53b0c2e43 Bug fixes 2026-06-13 22:01:12 -06:00
Josh Heaps edc4d70678 Merge pull request #28 from JoshHeaps/chess-ui-overhaul
change deploy
2026-06-13 21:34:22 -06:00
Josh-Heaps 3bfcbaa190 change deploy 2026-06-13 21:33:53 -06:00
Josh Heaps 59b318b75c Merge pull request #27 from JoshHeaps/chess-ui-overhaul
revert everything, these changes were a mistake
2026-06-13 20:30:57 -06:00
Josh-Heaps e5e0c9cc48 revert everything, these changes were a mistake 2026-06-13 20:30:26 -06:00
Josh Heaps 3947bd0daf Merge pull request #26 from JoshHeaps/chess-ui-overhaul
bug fixes
2026-06-13 20:14:20 -06:00
Josh-Heaps b99525ae51 fix deploy 2026-06-13 20:13:54 -06:00
Josh-Heaps 9da7b1986b bug fixes 2026-06-13 20:06:56 -06:00
Josh Heaps a0b8c2602a Merge pull request #25 from JoshHeaps/chess-ui-overhaul
fix deploy
2026-06-13 19:44:26 -06:00
Josh-Heaps 7604798e68 fix deploy 2026-06-13 19:43:40 -06:00
Josh Heaps 02163a4fc4 Merge pull request #24 from JoshHeaps/chess-ui-overhaul
Chess UI overhaul
2026-06-13 18:47:56 -06:00
Josh-Heaps 1680d13b05 bug fix 2026-06-13 18:47:31 -06:00
Josh-Heaps a1e2ad9c2d Bug fixes 2026-06-13 18:44:14 -06:00
Josh Heaps 26b4c0e59b Merge pull request #23 from JoshHeaps/chess-ui-overhaul
Chess UI overhaul
2026-06-13 17:32:16 -06:00
Josh-Heaps aeef320cb4 auto train 2026-06-13 17:30:09 -06:00
Josh-Heaps d71e485611 New learning strategy 2026-06-13 17:11:07 -06:00
Josh Heaps d09a067377 Merge pull request #22 from JoshHeaps/chess-ui-overhaul
Change data location
2026-06-12 19:53:34 -06:00
Josh-Heaps acbee436bb Change data location 2026-06-12 19:53:07 -06:00
Josh Heaps d452ee414b Merge pull request #21 from JoshHeaps/chess-ui-overhaul
Chess UI overhaul
2026-06-12 18:02:44 -06:00
Josh-Heaps f09ca60315 foo bar 2026-06-12 18:02:09 -06:00
Josh-Heaps 743c395e11 Choose color against cpu 2026-06-12 10:51:26 -06:00
Josh Heaps 62e146648c Merge pull request #20 from JoshHeaps/chess-ui-overhaul
Chess UI overhaul
2026-06-10 17:01:22 -06:00
Josh-HeapsandClaude Opus 4.8 6402ef268c Rework the chess page into a chess.com-style layout
Board with player bars and captured trays on the left, a game panel with
move list, status, and action buttons on the right. The page is locked to
the viewport (no scrolling); on phones the panel collapses into a slide-up
menu reachable from a slim status bar. Cache-busts the chess scripts too.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-10 16:58:35 -06:00
Josh-HeapsandClaude Opus 4.8 c92c46e6d4 Render the board from pushed state
Clients now render from the state delivered with each move/update and
drop stale or echoed updates via a ply-version guard, instead of issuing
a full game-state fetch per event. Adds captured-piece trays, a material
advantage badge, a move list, a status line, and the mobile menu toggle.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-10 16:58:22 -06:00
Josh-HeapsandClaude Opus 4.8 e8a3bfe432 Push authoritative game state on every move
The move endpoint now returns the full resulting board state, and the
controller and CPU orchestrator broadcast it (plus captured pieces and a
ply version) over SignalR, so clients render from one payload instead of
re-fetching. Removes the client-driven hub relay.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-10 16:58:11 -06:00
Josh Heaps 8b78fd589c Merge pull request #19 from JoshHeaps/feature/customChessEngine
Add killer-move ordering and a search benchmark
2026-06-09 19:03:21 -06:00
Josh-HeapsandClaude Opus 4.8 be07f7504e Add killer-move ordering and a search benchmark
Killer moves supply the quiet-move ordering the search lacked: ~1.45x faster at depth 8 (up to 1.88x in quiet positions) with identical play. Adds bench.ps1 and test/bench_main.cpp to time the search, with a BENCH_DISABLE_KILLERS compile toggle so the script can measure before/after.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-09 19:01:57 -06:00
Josh Heaps 7bfbae7301 Merge pull request #18 from JoshHeaps/feature/customChessEngine
Feature/custom chess engine
2026-06-08 19:15:41 -06:00
Josh-HeapsandClaude Opus 4.8 b651683e63 Add PGN export and a Copy PGN button for finished games
- Record moves in standard algebraic notation (GameState.SanHistory), built at
  move time in ChessService (captures, disambiguation, castling, promotion,
  en passant, and the check/mate suffix)
- Add PgnExporter.ToPgn (seven-tag header + movetext + result) and a
  GET /api/chess/{gameId}/pgn endpoint
- Show a Copy PGN button when a game ends on both the play and watch pages,
  prefetching the PGN so it works during the post-game cleanup window

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-08 19:14:39 -06:00
Josh-HeapsandClaude Opus 4.8 d87cea572f Feed move history and halfmove clock to the engine for repetition detection
- Track HalfmoveClock in GameState/ChessService (reset on captures and pawn moves)
- Emit the real halfmove clock in ToFen and add GameState.RepetitionHistory()
  (prior positions since the last irreversible move) in ChessEngineHelpers
- Pass that history through IChessEngine.GetBestMoveAsync to the native engine;
  Stockfish ignores it

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-08 18:59:10 -06:00
Josh-HeapsandClaude Opus 4.8 5345427e0c Add transposition table, iterative deepening, and repetition-aware search
- Refactor the search to negamax (single side-relative score + alpha/beta window)
- Add a shared, process-wide transposition table: lock-free XOR-verified slots,
  persists across games, with depth-gated and difficulty-capped score reuse so a
  weak bot can't borrow a stronger game's deeper analysis
- Drive the search with iterative deepening, seeding each depth's move ordering
  from the previous one
- Seed prior-position history (Position::seed_history) over a new engine_best_move
  history parameter so the engine detects threefold/50-move draws the FEN can't carry

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-08 18:58:59 -06:00
Josh-Heaps 76b054ee9d More efficient searching 2026-06-08 17:28:04 -06:00
Josh-HeapsandClaude Opus 4.8 2db95cec34 Add spectator watch page, forfeit-on-new-game, CPU self-play, and threefold repetition
- /watch live feed of active games with click-to-fullscreen and per-game back button
- Starting a new game forfeits the in-progress game; opponent is flagged the winner
- Watch CPU vs CPU self-play games; results shown for ~30s before cleanup
- Detect threefold repetition in the C# rules layer and end the game as a draw

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-08 16:21:49 -06:00
Josh Heaps 9b69489c4e Merge pull request #17 from JoshHeaps/feature/customChessEngine
Feature/custom chess engine
2026-06-08 14:17:52 -06:00
Josh-HeapsandClaude Opus 4.8 fc2a2e122b Build libchess_engine.so in CI deploy and pin runner to ubuntu-24.04
Compile the native engine and copy it into Resources/ before publish so it
ships to prod automatically. Runner pinned to ubuntu-24.04 to match the
server's glibc.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-08 14:12:50 -06:00
Josh-Heaps c3280aa6d3 add custom chess engine 2026-06-08 13:58:22 -06:00
Josh Heaps d2ae34f530 Merge pull request #16 from JoshHeaps/feature/blog
Update blog css
2026-03-07 12:27:39 -07:00
Josh-Heaps 52d468a46e Update blog css 2026-03-07 12:26:58 -07:00
Josh Heaps 7fcfb270a1 Merge pull request #15 from JoshHeaps/feature/blog
fix css
2026-03-06 17:16:19 -07:00
Josh-Heaps ea60ee5a4b fix css 2026-03-06 17:15:51 -07:00
Josh Heaps 3dc1bc452e Merge pull request #14 from JoshHeaps/feature/blog
Add cache invalidation
2026-03-06 17:08:41 -07:00
Josh-Heaps 99a9474ef7 Add cache invalidation 2026-03-06 17:05:48 -07:00
Josh Heaps 4884c32fc8 Merge pull request #13 from JoshHeaps/feature/blog
Replace file-based blog with API-backed service
2026-03-06 15:44:44 -07:00
Josh-HeapsandClaude Opus 4.6 8043eee4cc Replace file-based blog with API-backed service
BlogService now fetches from Media.JoshHeaps.Net API via HttpClient
with in-memory caching (5-min TTL) and stale cache fallback.
Removed Markdig/YamlDotNet dependencies. All page models now async.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-06 15:33:51 -07:00
Josh Heaps 5fd3a5d7c1 Merge pull request #12 from JoshHeaps/AddProject
Add cloud storage to projects section of front page
2025-10-22 11:58:33 -06:00
Josh-Heaps 9969c6e3cf Add cloud storage to projects section of front page 2025-10-22 11:54:48 -06:00
Josh Heaps 0788015cb0 Merge pull request #11 from JoshHeaps/feature/generalUpdate
Feature/general update
2025-10-17 13:06:02 -06:00
jheaps 34de3c1abc Update href for chess github 2025-10-17 12:57:17 -06:00
jheaps 311d62a719 Update images 2025-10-17 12:48:10 -06:00
jheaps 4217bf3703 Fix headless issue 2025-10-17 12:29:49 -06:00
jheaps fd68ada82e Get fancy wit it 2025-10-17 12:14:37 -06:00
jheaps 7c77cd0e52 Add square numbers and bug fixes 2025-10-17 12:10:08 -06:00
jheaps 93bdb3518a Bug fixes 2025-10-17 11:41:50 -06:00
Josh Heaps 69e1e8450d Merge pull request #10 from JoshHeaps/tech-debt/chess-cleanup
Add demo button for media.joshheaps.net
2025-10-13 10:18:17 -06:00
Josh Heaps e74de40d2e Merge pull request #9 from JoshHeaps/tech-debt/chess-cleanup
Tech debt/chess cleanup
2025-10-03 11:27:23 -06:00
Josh Heaps 9b86bfafe0 Merge pull request #8 from JoshHeaps/tech-debt/chess-cleanup
Tech debt/chess cleanup
2025-10-02 10:19:41 -06:00
245 changed files with 51249 additions and 685 deletions
+4 -2
View File
@@ -3,9 +3,11 @@
"allow": [
"Bash(dotnet test:*)",
"Bash(dotnet build)",
"Bash(dir:*)"
"Bash(dir:*)",
"Bash(dotnet add:*)",
"Bash(dotnet build:*)"
],
"deny": [],
"ask": []
}
}
}
+97
View File
@@ -0,0 +1,97 @@
# .gitea/workflows/deploy.yml
name: Build and Deploy
on:
push:
branches: [ "master" ]
workflow_dispatch: {}
concurrency:
group: deploy-${{ gitea.ref }}
cancel-in-progress: true
jobs:
build-deploy:
# Gitea's runner images are lean, so the toolchain is installed below rather
# than assumed. The glibc that libchess_engine.so links against comes from
# this image, not from the runner host — keep it matched to the prod server.
runs-on: ubuntu-latest
steps:
- name: Install toolchain
run: |
apt-get update
apt-get install -y --no-install-recommends \
cmake build-essential rsync openssh-client
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Restore
run: dotnet restore JoshHeaps.Net/JoshHeaps.Net.csproj
- name: Build native chess engine (libchess_engine.so)
run: |
cmake -S native/chess_engine -B native/chess_engine/build -DCMAKE_BUILD_TYPE=Release
cmake --build native/chess_engine/build
cp native/chess_engine/build/libchess_engine.so JoshHeaps.Net/Resources/
# Publishing the project rather than the solution keeps chess_engine.vcxproj
# (Windows-only MSBuild C++ targets) and the UiTests assemblies out of it.
- name: Publish
run: dotnet publish JoshHeaps.Net/JoshHeaps.Net.csproj -c Release -o ./publish
- name: Verify payload
run: |
set -euo pipefail
test -f publish/Resources/libchess_engine.so \
|| { echo "libchess_engine.so missing from publish output"; exit 1; }
test -x publish/Resources/stockfish-ubuntu-x86-64-sse41-popcnt \
|| { echo "stockfish is not executable"; exit 1; }
- name: Prepare SSH
env:
SSH_KEY: ${{ secrets.SSH_KEY }}
SSH_HOST: ${{ secrets.SSH_HOST }}
SSH_PORT: ${{ secrets.SSH_PORT }}
run: |
set -euo pipefail
PORT="${SSH_PORT:-22}"
install -m 700 -d ~/.ssh
printf '%s\n' "$SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -p "$PORT" "$SSH_HOST" >> ~/.ssh/known_hosts 2>/dev/null
# --exclude chess-data: never let --delete remove the learned-engine training
# data, which lives in the deploy dir unless ChessEngine__WeightsPath is set.
- name: Rsync to server
env:
SSH_HOST: ${{ secrets.SSH_HOST }}
SSH_PORT: ${{ secrets.SSH_PORT }}
SSH_USER: ${{ secrets.SSH_USER }}
TARGET_DIR: ${{ secrets.TARGET_DIR }}
run: |
set -euo pipefail
PORT="${SSH_PORT:-22}"
rsync -az --delete --exclude 'chess-data' \
-e "ssh -p $PORT -i ~/.ssh/deploy_key -o StrictHostKeyChecking=yes" \
publish/ "$SSH_USER@$SSH_HOST:$TARGET_DIR/"
- name: Reload and restart service
env:
SSH_HOST: ${{ secrets.SSH_HOST }}
SSH_PORT: ${{ secrets.SSH_PORT }}
SSH_USER: ${{ secrets.SSH_USER }}
SERVICE_NAME: ${{ secrets.SERVICE_NAME }}
run: |
set -euo pipefail
PORT="${SSH_PORT:-22}"
ssh -p "$PORT" -i ~/.ssh/deploy_key "$SSH_USER@$SSH_HOST" \
"sudo systemctl daemon-reload \
&& sudo systemctl restart '$SERVICE_NAME' \
&& systemctl --no-pager status '$SERVICE_NAME' --lines=0"
+21 -4
View File
@@ -14,7 +14,9 @@ concurrency:
jobs:
build:
runs-on: ubuntu-latest
# Pinned (not ubuntu-latest) to match the prod server's Ubuntu/glibc so the
# compiled libchess_engine.so loads there. Keep this == the server's release.
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -24,9 +26,16 @@ jobs:
with:
dotnet-version: '8.0.x' # adjust if needed
- name: Restore
run: dotnet restore
run: dotnet restore JoshHeaps.Net/JoshHeaps.Net.csproj
- name: Build native chess engine (libchess_engine.so)
run: |
cmake -S native/chess_engine -B native/chess_engine/build -DCMAKE_BUILD_TYPE=Release
cmake --build native/chess_engine/build
cp native/chess_engine/build/libchess_engine.so JoshHeaps.Net/Resources/
- name: Publish
run: dotnet publish -c Release -o ./publish
run: dotnet publish JoshHeaps.Net/JoshHeaps.Net.csproj -c Release -o ./publish
- name: Upload artifact
uses: actions/upload-artifact@v4
@@ -45,6 +54,12 @@ jobs:
name: site-publish
path: publish
# GitHub artifacts don't preserve the Unix executable bit, so Stockfish (the only file
# the app spawns as a subprocess) arrives non-executable. Restore 755 here; rsync -a then
# carries it to the server, where the service user can run it regardless of file owner.
- name: Restore Stockfish executable bit
run: chmod 755 publish/Resources/stockfish-ubuntu-x86-64-sse41-popcnt
- name: Prepare SSH
run: |
install -m 700 -d ~/.ssh
@@ -54,7 +69,9 @@ jobs:
- name: Rsync to server
run: |
rsync -az --delete -e "ssh -p ${{ secrets.SSH_PORT || 22 }} -i ~/.ssh/id_rsa" \
# --exclude chess-data: never let --delete remove the learned-engine training
# data, which lives in the deploy dir unless ChessEngine__WeightsPath is set.
rsync -az --delete --exclude 'chess-data' -e "ssh -p ${{ secrets.SSH_PORT || 22 }} -i ~/.ssh/id_rsa" \
publish/ ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:${{ secrets.TARGET_DIR }}/
- name: Reload and restart service
+2 -2
View File
@@ -19,6 +19,6 @@ jobs:
with:
dotnet-version: 8.0.x
- name: Restore dependencies
run: dotnet restore
run: dotnet restore JoshHeaps.Net.slnf
- name: Build
run: dotnet build --no-restore
run: dotnet build JoshHeaps.Net.slnf --no-restore
+26
View File
@@ -0,0 +1,26 @@
# Unit tests for the Texas Hold'em dealer's money logic.
#
# The betting engine, the side-pot calculator and the table state are plain script-tag
# modules with no build step, so they run directly under Node's built-in test runner —
# no package.json, no install step, nothing to keep up to date.
name: Hold'em tests
on:
pull_request:
branches: [ "master" ]
push:
branches: [ "master" ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Run Hold'em unit tests
run: node --test tests/holdem/*.test.js
+2 -2
View File
@@ -19,10 +19,10 @@ jobs:
dotnet-version: 8.0.x
- name: Restore dependencies
run: dotnet restore
run: dotnet restore JoshHeaps.Net.slnf
- name: Build solution
run: dotnet build --no-restore
run: dotnet build JoshHeaps.Net.slnf --no-restore
- name: Install Playwright browsers
run: |
+9 -1
View File
@@ -3,6 +3,9 @@
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# Learned chess engine weights (runtime training output, not source)
chess-data/
# User-specific files
*.rsuser
*.suo
@@ -360,4 +363,9 @@ MigrationBackup/
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
FodyWeavers.xsd
# Native chess engine CMake build output (the compiled .dll/.so are committed under Resources/)
native/**/build/
# Playwright MCP scratch output
.playwright-mcp/
+13
View File
@@ -11,6 +11,19 @@ public class ApiTests : PageTest
private IAPIRequestContext? _apiContext;
private TestConfiguration Config => TestConfiguration.Instance;
[OneTimeSetUp]
public async Task OneTimeSetUp()
{
if (!Config.Playwright.Headless)
{
Environment.SetEnvironmentVariable("HEADED", "1");
}
if (Config.Playwright.SlowMotion > 0)
{
Environment.SetEnvironmentVariable("PWSLOWMO", Config.Playwright.SlowMotion.ToString());
}
}
[SetUp]
public async Task Setup()
{
+36 -2
View File
@@ -1,26 +1,60 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.9.34728.123
# Visual Studio Version 18
VisualStudioVersion = 18.6.11822.322 stable
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JoshHeaps.Net", "JoshHeaps.Net\JoshHeaps.Net.csproj", "{9F0182CC-470F-4D1A-99F5-348D7921751E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JoshHeaps.Net.UiTests", "JoshHeaps.Net.UiTests\JoshHeaps.Net.UiTests.csproj", "{360264F4-8292-4EB3-B67D-98376C13438B}"
EndProject
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "chess_engine", "native\chess_engine\chess_engine\chess_engine.vcxproj", "{2579BBBC-1830-4342-BC10-0A4182DC84C7}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|x64.ActiveCfg = Debug|Any CPU
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|x64.Build.0 = Debug|Any CPU
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|x86.ActiveCfg = Debug|Any CPU
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|x86.Build.0 = Debug|Any CPU
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|Any CPU.Build.0 = Release|Any CPU
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|x64.ActiveCfg = Release|Any CPU
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|x64.Build.0 = Release|Any CPU
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|x86.ActiveCfg = Release|Any CPU
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|x86.Build.0 = Release|Any CPU
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|x64.ActiveCfg = Debug|Any CPU
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|x64.Build.0 = Debug|Any CPU
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|x86.ActiveCfg = Debug|Any CPU
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|x86.Build.0 = Debug|Any CPU
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|Any CPU.Build.0 = Release|Any CPU
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|x64.ActiveCfg = Release|Any CPU
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|x64.Build.0 = Release|Any CPU
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|x86.ActiveCfg = Release|Any CPU
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|x86.Build.0 = Release|Any CPU
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|Any CPU.ActiveCfg = Debug|x64
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|Any CPU.Build.0 = Debug|x64
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|x64.ActiveCfg = Debug|x64
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|x64.Build.0 = Debug|x64
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|x86.ActiveCfg = Debug|Win32
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|x86.Build.0 = Debug|Win32
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|Any CPU.ActiveCfg = Release|x64
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|Any CPU.Build.0 = Release|x64
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|x64.ActiveCfg = Release|x64
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|x64.Build.0 = Release|x64
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|x86.ActiveCfg = Release|Win32
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|x86.Build.0 = Release|Win32
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
+9
View File
@@ -0,0 +1,9 @@
{
"solution": {
"path": "JoshHeaps.Net.sln",
"projects": [
"JoshHeaps.Net\\JoshHeaps.Net.csproj",
"JoshHeaps.Net.UiTests\\JoshHeaps.Net.UiTests.csproj"
]
}
}
@@ -0,0 +1,24 @@
---
title: My First Post
date: 2026-03-06
summary: Welcome to my blog! Here I'll share thoughts on software development, projects I'm working on, and things I find interesting.
tags: [dev, personal]
---
# Welcome to My Blog
I've been meaning to start writing about the things I build and learn, and I'm finally getting around to it.
## What to Expect
I plan to write about:
- **Projects** I'm working on, like this website and the other things on my portfolio
- **Software development** tips and patterns I find useful
- **Problem solving** approaches that have helped me grow as a developer
## Why a Blog?
Building things is great, but explaining *how* and *why* you built them is just as valuable. Writing forces you to organize your thoughts, and hopefully someone else finds it useful along the way.
Stay tuned for more posts!
@@ -0,0 +1,20 @@
using JoshHeaps.Net.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace JoshHeaps.Net.Controllers;
[ApiController]
[Route("api/[controller]")]
public class BlogController(IBlogService blogService, IConfiguration configuration) : ControllerBase
{
[HttpPost("invalidate")]
public IActionResult InvalidateCache([FromHeader(Name = "X-Invalidate-Key")] string? key)
{
var expectedKey = configuration["BlogApi:InvalidateKey"];
if (string.IsNullOrEmpty(expectedKey) || key != expectedKey)
return Unauthorized();
blogService.ClearCache();
return Ok();
}
}
+194 -97
View File
@@ -1,9 +1,9 @@
using JoshHeaps.Net.Hubs;
using JoshHeaps.Net.Hubs;
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Implementations;
using JoshHeaps.Net.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using System.Collections.Concurrent;
namespace JoshHeaps.Net.Controllers;
@@ -11,18 +11,17 @@ namespace JoshHeaps.Net.Controllers;
[Route("api/[controller]")]
public class ChessController(
IChessService chessService,
IHubContext<ChessHub> chessHub,
IBackgroundTaskQueue queue) : ControllerBase
IBackgroundTaskQueue queue,
IChessEngineFactory engineFactory,
IComputerMoveOrchestrator orchestrator,
ILearnedWeightsStore weightsStore,
IGameStore gameStore,
ISelfPlayCoordinator selfPlay,
AutoTrainingSettings autoTraining,
IHubContext<ChessHub> chessHub) : ControllerBase
{
/// <summary>
/// Store of ongoing games.
/// </summary>
private static readonly ConcurrentDictionary<Guid, GameState> _games = [];
private static readonly ConcurrentDictionary<Guid, Task> _gameRemovalTasks = [];
private static readonly ConcurrentDictionary<Guid, CancellationTokenSource> _gameRemovalCancellationTokens = [];
private static readonly TimeSpan _computerGameTimeout = TimeSpan.FromHours(1);
private static readonly TimeSpan _multiplayerGameTimeout = TimeSpan.FromSeconds(5);
private static readonly TimeSpan _multiplayerGameTimeout = TimeSpan.FromDays(1);
private static readonly TimeSpan _gameCleanupTimeout = TimeSpan.FromMinutes(1);
/// <summary>
@@ -30,38 +29,45 @@ public class ChessController(
/// </summary>
[HttpGet("new")]
[HttpGet("new/{difficulty}")]
public ActionResult CreateGame(int difficulty = 20)
public ActionResult CreateGame(int difficulty = 20, string color = "random")
{
var gameState = chessService.CreateNewGame();
_games[gameState.GameId] = gameState;
gameStore.Add(gameState);
gameState.IsVsComputer = true;
gameState.WhiteJoined = true;
gameState.BlackJoined = true;
Guid playerId = Guid.NewGuid();
Guid computerId = Guid.NewGuid();
var isWhite = Random.Shared.Next(2) == 0;
var isWhite = color.ToLowerInvariant() switch
{
"white" => true,
"black" => false,
_ => Random.Shared.Next(2) == 0,
};
gameState.Computer = new(difficulty);
var computer = engineFactory.Create(difficulty);
if (isWhite)
{
gameState.WhitePlayerId = playerId;
gameState.BlackPlayerId = computerId;
gameState.BlackComputer = computer;
}
else
{
gameState.WhitePlayerId = computerId;
gameState.BlackPlayerId = playerId;
gameState.WhiteComputer = computer;
queue.Queue(async () =>
{
// Give user's browser time to connect to signalR and such.
await Task.Delay(TimeSpan.FromSeconds(1));
await gameState.Computer.MakeMove(gameState, chessHub, chessService);
await orchestrator.PlayAsync(gameState);
});
}
ScheduleRemoveGame(gameState.GameId, _computerGameTimeout);
gameStore.ScheduleRemove(gameState.GameId, _computerGameTimeout);
return Ok(new
{
@@ -71,6 +77,55 @@ public class ChessController(
});
}
/// <summary>
/// Create a computer-vs-computer game and auto-play it move by move, broadcasting each
/// move so it can be watched on the spectator page. Each side's engine and skill can be
/// chosen independently; when the learned engine plays, the game also trains it.
/// </summary>
[HttpGet("watch/cpu")]
[HttpGet("watch/cpu/{difficulty}")]
public ActionResult CreateSelfPlayGame(
int difficulty = 4,
string whiteEngine = "custom",
string blackEngine = "custom",
int? whiteSkill = null,
int? blackSkill = null)
{
var config = new SelfPlayConfig(
ParseEngineKind(whiteEngine), whiteSkill ?? difficulty,
ParseEngineKind(blackEngine), blackSkill ?? difficulty);
var (gameId, _) = selfPlay.StartGame(config);
return Ok(new { GameId = gameId });
}
private static ChessEngineKind ParseEngineKind(string value) => value.ToLowerInvariant() switch
{
"stockfish" => ChessEngineKind.Stockfish,
"customlearned" or "learned" => ChessEngineKind.CustomLearned,
_ => ChessEngineKind.Custom
};
/// <summary>
/// Current number of background auto-training games (and the allowed maximum). Auto-training
/// itself runs only outside Development; this reflects the target the service is keeping.
/// </summary>
[HttpGet("autotrain")]
public ActionResult GetAutoTrain() =>
Ok(new { count = autoTraining.GameCount, max = AutoTrainingSettings.MaxGames });
/// <summary>
/// Set how many auto-training games run concurrently (clamped to 0..max; 0 pauses training).
/// Takes effect live — the background service tops up or drains toward the new count.
/// </summary>
[HttpPost("autotrain")]
public ActionResult SetAutoTrain([FromQuery] int count)
{
autoTraining.GameCount = count; // clamped inside the setter
return Ok(new { count = autoTraining.GameCount, max = AutoTrainingSettings.MaxGames });
}
/// <summary>
/// Joins the "pool" of chess players.
/// Test code expects to receive a GUID for the player
@@ -80,12 +135,12 @@ public class ChessController(
public ActionResult JoinGame()
{
Console.WriteLine("joining game");
GameState? gameState = _games.Values.FirstOrDefault(g => g.IsOpen);
GameState? gameState = gameStore.All.FirstOrDefault(g => g.IsOpen);
if (gameState == null)
{
gameState = chessService.CreateNewGame();
_games[gameState.GameId] = gameState;
gameStore.Add(gameState);
}
Guid playerId = Guid.NewGuid();
@@ -103,7 +158,7 @@ public class ChessController(
isWhite = false;
}
ScheduleRemoveGame(gameState.GameId, _multiplayerGameTimeout);
gameStore.ScheduleRemove(gameState.GameId, _multiplayerGameTimeout);
return Ok(new
{
@@ -113,51 +168,72 @@ public class ChessController(
});
}
/// <summary>
/// List in-progress games for spectators: both sides present and the game not yet decided.
/// </summary>
[HttpGet("active")]
public ActionResult GetActiveGames()
{
var activeGames = gameStore.All
// In-progress games, plus finished computer-vs-computer games still in their result window.
.Where(g => g.WhiteJoined && g.BlackJoined
&& ((!g.IsCheckmate && !g.IsStalemate && !g.IsForfeited && !g.IsThreefoldRepetition) || g.IsComputerVsComputer))
.Select(g => new
{
g.GameId,
g.IsVsComputer,
g.IsComputerVsComputer,
WhiteEngine = g.WhiteEngineKind.ToString(),
BlackEngine = g.BlackEngineKind.ToString(),
CurrentPlayer = g.CurrentPlayer.ToString(),
MoveCount = g.MoveHistory.Count,
g.IsCheck
})
.OrderByDescending(g => g.MoveCount);
return Ok(activeGames);
}
/// <summary>
/// The learned engine's piece-square bonus table, for the weights-visualization page:
/// one 64-entry array per piece type (Pawn..King), white-relative (A1=0 .. H8=63).
/// </summary>
[HttpGet("weights")]
public ActionResult GetLearnedWeights()
{
var names = new[] { "Pawn", "Knight", "Bishop", "Rook", "Queen", "King" };
var featureNames = new[] { "Mobility N", "Mobility B", "Mobility R", "Mobility Q", "Passed", "Isolated", "Doubled", "King safety" };
var snapshot = weightsStore.Snapshot();
return Ok(new
{
mg = snapshot.Mg.Select((squares, i) => new { name = names[i], squares }),
eg = snapshot.Eg.Select((squares, i) => new { name = names[i], squares }),
features = snapshot.Features.Select((value, i) => new { name = featureNames[i], value })
});
}
/// <summary>
/// Get the state of an existing game by ID.
/// </summary>
[HttpGet("{gameId}")]
public ActionResult GetGameState(Guid gameId)
{
if (!_games.TryGetValue(gameId, out var gameState))
if (!gameStore.TryGet(gameId, out var gameState))
return NotFound("Game not found");
var response = new
{
gameState.GameId,
CurrentPlayer = gameState.CurrentPlayer.ToString(),
gameState.IsCheck,
gameState.IsCheckmate,
gameState.IsStalemate,
EnPassantTarget = gameState.EnPassantTarget?.ToString() ?? null,
gameState.WhiteCanCastleKingside,
gameState.WhiteCanCastleQueenside,
gameState.BlackCanCastleKingside,
gameState.BlackCanCastleQueenside,
Pieces = gameState.Pieces
.Where(p => p.Position.Row >= 0)
.Select(p => new {
p.Id,
p.Type,
p.Color,
p.Position.Row,
p.Position.Col,
p.HasMoved
}),
gameState.MoveHistory
};
return Ok(response);
return Ok(gameState.ToDto());
}
/// <summary>
/// Make a move in the specified game.
/// Make a move in the specified game.
/// The test passes a JSON body with a MoveDto.
/// </summary>
[HttpPost("move")]
public ActionResult MakeMove([FromBody] MoveDto moveDto)
public async Task<ActionResult> MakeMove([FromBody] MoveDto moveDto)
{
if (!_games.TryGetValue(moveDto.GameId, out var gameState))
if (!gameStore.TryGet(moveDto.GameId, out var gameState))
return NotFound("Game not found");
// Check if player is authorized to move
@@ -165,7 +241,7 @@ public class ChessController(
var expectedPlayerId = isWhiteMove ? gameState.WhitePlayerId : gameState.BlackPlayerId;
if (moveDto.PlayerId != expectedPlayerId)
return Forbid("You are not the current player.");
return StatusCode(403, "You are not the current player.");
// Make sure player owns the piece
var piece = gameState.Pieces.FirstOrDefault(p => p.Id == moveDto.PieceId);
@@ -174,24 +250,80 @@ public class ChessController(
return NotFound("Chess piece Id does not exist");
if ((isWhiteMove && piece.Color != PieceColor.White) || (!isWhiteMove && piece?.Color != PieceColor.Black))
return Forbid("You cannot move this piece.");
return StatusCode(403, "You cannot move this piece.");
var result = chessService.MakeMove(gameState, moveDto);
if (!result.Success)
return BadRequest(result);
if (result.IsCheckmate || result.IsStalemate)
ScheduleRemoveGame(gameState.GameId, _gameCleanupTimeout);
var isGameOver = result.IsCheckmate || result.IsStalemate || result.IsThreefoldRepetition;
if (isGameOver)
gameStore.ScheduleRemove(gameState.GameId, _gameCleanupTimeout);
else if (gameState.IsVsComputer)
ScheduleRemoveGame(gameState.GameId, _computerGameTimeout);
gameStore.ScheduleRemove(gameState.GameId, _computerGameTimeout);
else
ScheduleRemoveGame(gameState.GameId, _multiplayerGameTimeout);
gameStore.ScheduleRemove(gameState.GameId, _multiplayerGameTimeout);
if (gameState.IsVsComputer && gameState.Computer is not null)
queue.Queue(() => gameState.Computer.MakeMove(gameState, chessHub, chessService));
var state = gameState.ToDto();
return Ok(result);
// Broadcast the move (with the full resulting state) to everyone watching this
// game. The mover also receives this echo but drops it via the version guard,
// since it already rendered the same state from this response.
await chessHub.Clients.Group(gameState.GameId.ToString())
.SendAsync("ReceiveMoveUpdate", gameState.GameId.ToString(), moveDto, result, state);
var sideToMoveEngine = gameState.CurrentPlayer == PieceColor.White
? gameState.WhiteComputer
: gameState.BlackComputer;
if (!isGameOver && gameState.IsVsComputer && sideToMoveEngine is not null)
queue.Queue(() => orchestrator.PlayAsync(gameState));
return Ok(new { result, state });
}
/// <summary>
/// Forfeit a game on behalf of the calling player, handing the win to the opponent.
/// Used when a player abandons a game (e.g. starts a new one mid-game).
/// </summary>
[HttpPost("forfeit")]
public async Task<ActionResult> Forfeit([FromBody] ForfeitDto forfeit)
{
if (!gameStore.TryGet(forfeit.GameId, out var gameState))
return NotFound("Game not found");
if (gameState.IsCheckmate || gameState.IsStalemate || gameState.IsForfeited)
return Ok();
var isWhitePlayer = gameState.WhitePlayerId == forfeit.PlayerId;
var isBlackPlayer = gameState.BlackPlayerId == forfeit.PlayerId;
if (!isWhitePlayer && !isBlackPlayer)
return StatusCode(403, "You are not a player in this game.");
gameState.IsForfeited = true;
gameState.Winner = isWhitePlayer ? PieceColor.Black : PieceColor.White;
await chessHub.Clients.Group(gameState.GameId.ToString())
.SendAsync("ReceiveGameOver", gameState.GameId.ToString(), gameState.Winner.ToString(), "forfeit");
gameStore.ScheduleRemove(gameState.GameId, _gameCleanupTimeout);
return Ok();
}
/// <summary>
/// Export a game as PGN (works while the game is still in memory after it ends).
/// </summary>
[HttpGet("{gameId}/pgn")]
public ActionResult GetPgn(Guid gameId)
{
if (!gameStore.TryGet(gameId, out var gameState))
return NotFound("Game not found");
return Content(gameState.ToPgn(), "application/x-chess-pgn");
}
/// <summary>
@@ -200,7 +332,7 @@ public class ChessController(
[HttpGet("{gameId}/legalMoves/{pieceId}")]
public ActionResult GetLegalMoves(Guid gameId, string pieceId)
{
if (!_games.TryGetValue(gameId, out var gameState))
if (!gameStore.TryGet(gameId, out var gameState))
return NotFound("Game not found");
var moves = chessService.GetLegalMovesForPiece(gameState, pieceId);
@@ -214,7 +346,7 @@ public class ChessController(
[HttpGet("{gameId}/legalMoves")]
public ActionResult GetAllLegalMoves(Guid gameId)
{
if (!_games.TryGetValue(gameId, out var gameState))
if (!gameStore.TryGet(gameId, out var gameState))
return NotFound("Game not found");
var allMoves = chessService.GetAllLegalMoves(gameState)
@@ -226,39 +358,4 @@ public class ChessController(
return Ok(allMoves);
}
private static void ScheduleRemoveGame(Guid id, TimeSpan delay)
{
if (_gameRemovalCancellationTokens.TryRemove(id, out var oldCts))
{
oldCts.Cancel();
oldCts.Dispose();
}
var cts = new CancellationTokenSource();
_gameRemovalCancellationTokens[id] = cts;
_gameRemovalTasks[id] = Task.Run(async () =>
{
try
{
await Task.Delay(delay, cts.Token);
if (_games.TryGetValue(id, out var game) && game.Computer is not null)
await game.Computer.DisposeAsync();
_games.Remove(id, out _);
}
catch (OperationCanceledException) { }
finally
{
if (_gameRemovalCancellationTokens.TryGetValue(id, out var currentCts) && currentCts == cts)
{
_gameRemovalCancellationTokens.TryRemove(id, out _);
}
cts.Dispose();
}
});
}
}
+1 -7
View File
@@ -1,5 +1,4 @@
using JoshHeaps.Net.Models;
using Microsoft.AspNetCore.SignalR;
using Microsoft.AspNetCore.SignalR;
namespace JoshHeaps.Net.Hubs;
@@ -11,11 +10,6 @@ public class ChessHub : Hub
await Groups.AddToGroupAsync(Context.ConnectionId, gameId);
}
public async Task MoveMade(string gameId, MoveDto moveDto, MoveResultDto moveResult)
{
await Clients.OthersInGroup(gameId).SendAsync("ReceiveMoveUpdate", gameId, moveDto, moveResult);
}
public async Task LeaveWebsocketGroup(string gameId)
{
Console.WriteLine($"❌ Leaving group {gameId}");
+14
View File
@@ -4,6 +4,8 @@
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>53ed685c-bdff-4306-8cc2-9fbe55c85713</UserSecretsId>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
@@ -27,4 +29,16 @@
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="8.0.7" />
</ItemGroup>
<!-- Builds the native engine and refreshes Resources\chess_engine.dll (via its post-build copy)
only inside Visual Studio, which has the C++ tools. The dotnet CLI can't build a .vcxproj,
so this is skipped by `dotnet build`/`dotnet publish` (incl. the Linux CI, which deploys the
libchess_engine.so instead). ReferenceOutputAssembly=false: build-ordering only, not a
managed reference to the native DLL. -->
<ItemGroup Condition="'$(BuildingInsideVisualStudio)' == 'true'">
<ProjectReference Include="..\native\chess_engine\chess_engine\chess_engine.vcxproj">
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
<Private>false</Private>
</ProjectReference>
</ItemGroup>
</Project>
+12
View File
@@ -0,0 +1,12 @@
namespace JoshHeaps.Net.Models;
public class BlogPost
{
public long Id { get; set; }
public string Slug { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public DateTime PublishedDate { get; set; }
public string Summary { get; set; } = string.Empty;
public List<string> Tags { get; set; } = [];
public string HtmlContent { get; set; } = string.Empty;
}
+7
View File
@@ -0,0 +1,7 @@
namespace JoshHeaps.Net.Models;
public class ForfeitDto
{
public Guid GameId { get; set; }
public Guid PlayerId { get; set; }
}
+33 -1
View File
@@ -1,4 +1,5 @@
using JoshHeaps.Net.Services.Implementations;
using JoshHeaps.Net.Services.Interfaces;
namespace JoshHeaps.Net.Models;
@@ -28,10 +29,25 @@ public class GameState
public bool IsCheck { get; set; }
public bool IsCheckmate { get; set; }
public bool IsStalemate { get; set; }
public bool IsThreefoldRepetition { get; set; }
public bool IsForfeited { get; set; } = false;
// The color that won, when the game ended by forfeit (null while the game is live).
public PieceColor? Winner { get; set; }
// Keep a history of moves if desired
public List<string> MoveHistory { get; set; }
// Position keys (FEN placement/side/castling/en-passant) for threefold-repetition detection.
public List<string> PositionHistory { get; set; }
// Moves in standard algebraic notation (SAN), in order, for PGN export.
public List<string> SanHistory { get; set; }
// Half-moves since the last capture or pawn move (the FEN 50-move clock). Also tells
// us how many trailing PositionHistory entries belong to the current repetition window.
public int HalfmoveClock { get; set; }
// A list of all pieces to quickly reference them (optional but convenient).
// Alternatively, you can iterate the Board array.
public List<ChessPiece> Pieces { get; set; }
@@ -42,8 +58,22 @@ public class GameState
public Guid BlackPlayerId { get; set; }
public bool IsVsComputer { get; set; } = false;
public bool IsComputerVsComputer { get; set; } = false;
public Stockfish? Computer { get; set; }
// The engine playing each side (null for a human). In a human-vs-computer game only the
// computer's side is set; the orchestrator picks the engine for whoever is to move.
public IChessEngine? WhiteComputer { get; set; }
public IChessEngine? BlackComputer { get; set; }
// Which engine implementation each side uses — lets game-over handling know which side(s)
// are the learning engine, and lets spectators see who is playing.
public ChessEngineKind WhiteEngineKind { get; set; }
public ChessEngineKind BlackEngineKind { get; set; }
// Native per-game training accumulator (nint.Zero when this game isn't training the
// learned engine). The engine records each played position into it and applies the
// result on game over.
public nint Trainer { get; set; }
// optional: convenience
public bool IsOpen => !WhiteJoined || !BlackJoined;
@@ -54,5 +84,7 @@ public class GameState
Board = new ChessPiece[8, 8];
Pieces = [];
MoveHistory = [];
PositionHistory = [];
SanHistory = [];
}
}
+61
View File
@@ -0,0 +1,61 @@
using System.Linq;
namespace JoshHeaps.Net.Models;
/// <summary>
/// A single piece as sent to the client. Captured pieces keep their original
/// type and color; their position is meaningless and omitted.
/// </summary>
public record ChessPieceDto(string Id, PieceType Type, PieceColor Color, int Row, int Col, bool HasMoved);
/// <summary>
/// The full board state pushed to clients. The same shape is returned by the
/// state endpoint, the move endpoint, and every SignalR move broadcast, so the
/// client always renders from one authoritative payload instead of re-fetching.
/// </summary>
public record GameStateDto(
Guid GameId,
string CurrentPlayer,
bool IsCheck,
bool IsCheckmate,
bool IsStalemate,
bool IsThreefoldRepetition,
string? EnPassantTarget,
bool WhiteCanCastleKingside,
bool WhiteCanCastleQueenside,
bool BlackCanCastleKingside,
bool BlackCanCastleQueenside,
IReadOnlyList<ChessPieceDto> Pieces,
IReadOnlyList<ChessPieceDto> CapturedPieces,
IReadOnlyList<string> MoveHistory,
// Moves in standard algebraic notation, for the move-list panel.
IReadOnlyList<string> SanHistory,
// Monotonic ply counter the client uses to drop stale or echoed updates.
int Version);
public static class GameStateMapper
{
public static GameStateDto ToDto(this GameState gameState) => new(
gameState.GameId,
gameState.CurrentPlayer.ToString(),
gameState.IsCheck,
gameState.IsCheckmate,
gameState.IsStalemate,
gameState.IsThreefoldRepetition,
gameState.EnPassantTarget?.ToString(),
gameState.WhiteCanCastleKingside,
gameState.WhiteCanCastleQueenside,
gameState.BlackCanCastleKingside,
gameState.BlackCanCastleQueenside,
gameState.Pieces
.Where(p => p.Position.Row >= 0)
.Select(p => new ChessPieceDto(p.Id, p.Type, p.Color, p.Position.Row, p.Position.Col, p.HasMoved))
.ToList(),
gameState.Pieces
.Where(p => p.Position.Row < 0)
.Select(p => new ChessPieceDto(p.Id, p.Type, p.Color, p.Position.Row, p.Position.Col, p.HasMoved))
.ToList(),
gameState.MoveHistory,
gameState.SanHistory,
gameState.MoveHistory.Count);
}
@@ -0,0 +1,9 @@
namespace JoshHeaps.Net.Models;
/// <summary>
/// A copy of the learned engine's weights for display: midgame and endgame piece-square
/// tables (one 64-entry array per piece, in canonical Pawn..King order, white-relative
/// A1=0..H8=63) plus the feature weights (mobility N/B/R/Q, passed, isolated, doubled,
/// king safety).
/// </summary>
public sealed record LearnedWeightsSnapshot(int[][] Mg, int[][] Eg, int[] Features);
+1
View File
@@ -7,4 +7,5 @@ public class MoveResultDto
public bool IsCheck { get; set; }
public bool IsCheckmate { get; set; }
public bool IsStalemate { get; set; }
public bool IsThreefoldRepetition { get; set; }
}
+49
View File
@@ -0,0 +1,49 @@
@page
@model JoshHeaps.Net.Pages.BlogModel
@{
ViewData["Title"] = "Blog - JoshHeaps.Net";
Layout = "_Layout";
}
<div class="blog-index">
<h1 class="blog-title">Blog</h1>
@if (Model.ActiveTag is not null)
{
<div class="blog-filter">
<span>Filtered by: <strong>@Model.ActiveTag</strong></span>
<a href="/blog">Clear filter</a>
</div>
}
@if (Model.Posts.Count == 0)
{
<p class="blog-empty">No posts yet. Check back soon!</p>
}
<div class="blog-list">
@foreach (var post in Model.Posts)
{
<article class="blog-card">
<a href="/blog/@post.Slug" class="blog-card-link">
<h2 class="blog-card-title">@post.Title</h2>
</a>
<time class="blog-card-date" datetime="@post.PublishedDate.ToString("yyyy-MM-dd")">
@post.PublishedDate.ToString("MMMM d, yyyy")
</time>
<p class="blog-card-summary">@post.Summary</p>
<div class="blog-card-tags">
@foreach (var tag in post.Tags)
{
<a href="/blog?tag=@tag" class="blog-tag">@tag</a>
}
</div>
</article>
}
</div>
</div>
@section Styles {
<link href="/css/variables.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
<link href="/css/blog/index.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
}
+26
View File
@@ -0,0 +1,26 @@
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace JoshHeaps.Net.Pages;
public class BlogModel : PageModel
{
private readonly IBlogService _blogService;
public List<BlogPost> Posts { get; set; } = [];
public string? ActiveTag { get; set; }
public BlogModel(IBlogService blogService)
{
_blogService = blogService;
}
public async Task OnGetAsync(string? tag)
{
ActiveTag = tag;
Posts = tag is not null
? await _blogService.GetPostsByTagAsync(tag)
: await _blogService.GetAllPostsAsync();
}
}
+38
View File
@@ -0,0 +1,38 @@
@page "/blog/{slug}"
@model JoshHeaps.Net.Pages.Blog.PostModel
@{
ViewData["Title"] = Model.Post!.Title + " - JoshHeaps.Net";
Layout = "_Layout";
}
<article class="blog-post">
<a href="/blog" class="blog-back">&larr; Back to blog</a>
<header class="blog-post-header">
<h1 class="blog-post-title">@Model.Post.Title</h1>
<time class="blog-post-date" datetime="@Model.Post.PublishedDate.ToString("yyyy-MM-dd")">
@Model.Post.PublishedDate.ToString("MMMM d, yyyy")
</time>
<div class="blog-post-tags">
@foreach (var tag in Model.Post.Tags)
{
<a href="/blog?tag=@tag" class="blog-tag">@tag</a>
}
</div>
</header>
<div class="post-content">
@Html.Raw(Model.Post.HtmlContent)
</div>
</article>
@section Styles {
<link href="/css/variables.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
<link href="/css/blog/post.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
<link href="/css/blog/prism-tomorrow.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
}
@section Scripts {
<script src="https://cdn.jsdelivr.net/npm/prismjs@1/components/prism-core.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/prismjs@1/plugins/autoloader/prism-autoloader.min.js"></script>
}
+28
View File
@@ -0,0 +1,28 @@
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace JoshHeaps.Net.Pages.Blog;
public class PostModel : PageModel
{
private readonly IBlogService _blogService;
public BlogPost? Post { get; set; }
public PostModel(IBlogService blogService)
{
_blogService = blogService;
}
public async Task<IActionResult> OnGetAsync(string slug)
{
Post = await _blogService.GetPostBySlugAsync(slug);
if (Post is null)
return NotFound();
return Page();
}
}
+58
View File
@@ -0,0 +1,58 @@
@page
@model JoshHeaps.Net.Pages.CasinoModel
@{
ViewData["Title"] = "Casino Games";
Layout = "_Layout";
}
<div class="casino-container">
<h1 class="casino-header">Casino Games</h1>
<p class="casino-subtitle">Tools for running table games without doing the math yourself</p>
<div class="games-grid">
<a href="/casino/texasholdem" class="game-card">
<div class="game-icon">🃏</div>
<div class="game-title">Texas Hold'em</div>
<div class="game-description">
A dealer's assistant: seat players around the table, track blinds, chips and
all-ins, then split main and side pots automatically.
</div>
</a>
<a href="/casino/texasholdem/quicksplit" class="game-card">
<div class="game-icon">➗</div>
<div class="game-title">Quick Split</div>
<div class="game-description">
Chop a pot in seconds on your phone — no table setup, just the amount,
how many players are splitting it, and the smallest chip in play.
</div>
</a>
<div class="game-card disabled">
<div class="game-icon">🎲</div>
<div class="game-title">Craps</div>
<div class="game-description">Payout calculator for the table.</div>
<div class="coming-soon">Coming soon...</div>
</div>
<div class="game-card disabled">
<div class="game-icon">🎡</div>
<div class="game-title">Roulette</div>
<div class="game-description">Bet tracking and payout reference.</div>
<div class="coming-soon">Coming soon...</div>
</div>
<div class="game-card disabled">
<div class="game-icon">🂡</div>
<div class="game-title">Blackjack</div>
<div class="game-description">Multi-hand dealer tracker.</div>
<div class="coming-soon">Coming soon...</div>
</div>
</div>
<a href="/" class="home-button">← Back to Home</a>
</div>
@section Styles {
<link href="/css/casino/page.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
}
+11
View File
@@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace JoshHeaps.Net.Pages
{
public class CasinoModel : PageModel
{
public void OnGet()
{
}
}
}
@@ -0,0 +1,953 @@
@page
@model JoshHeaps.Net.Pages.Casino.TexasHoldemModel
@{
ViewData["Title"] = "Texas Hold'em Dealer";
Layout = "_Layout";
}
<div id="holdemApp">
<header id="holdemHeader">
@* Six equally-weighted buttons used to sit here in two rows, and on a 390px
phone the felt did not start until 225px down — a quarter of the screen spent
before the dealer saw the table. Only two of the six are per-hand controls.
The rest have gone where they belong rather than into a "more" menu, which
would have relocated the crowding rather than removed it: Reset Table down to
the sidebar with the save/load pair — in a panel called Save & reset, so the
danger is on the label rather than at the end of a scroll — Quick Split down
with Common rulings where the other "open something else" link already lives,
and the way back to the site up top where a back link goes. *@
@* The word is its own element so a phone can drop it and keep the arrow, and the
aria-label is what a screen reader gets either way. It contains the visible
text on the sizes that still show it, which is what WCAG 2.5.3 asks of a name
that says more than the label does. *@
<div id="holdemHeaderLeft">
<a href="/casino" class="holdemBackLink" aria-label="Back to Casino">←<span class="backLinkWord"> Casino</span></a>
<h1>Texas Hold'em Dealer</h1>
</div>
<div id="holdemHeaderActions">
<button id="misdealBtn" class="holdemBtn" title="Take this hand back — every chip returns to the player who put it in, and the same button deals again" hidden>Misdeal</button>
@* Both the label and the title are rewritten every render from whether a hand
is out — see Render.renderHeaderActions. What is here is what is true before
the first render: no hand, so pressing it only moves the button. *@
<button id="nextHandBtn" class="holdemBtn" title="Pass the button on again without dealing — for a hand nobody played">Move Button ⟳</button>
@* Not the accent any more. It is a shortcut to a sheet, available all night,
and it is never the next step — the bar always has that, and at showdown
the bar says so in as many words. See .actionAdvance in holdem.css. *@
<button id="resolvePotBtn" class="holdemBtn">Resolve Pot</button>
</div>
</header>
@* The seat sheet was the only one of the tool's seven overlays with nothing behind
it. On a phone it covers the felt from 250px down and the action bar with it, so
it is modal in everything but appearance — and what stayed lit above it was the
header, with Misdeal, Next Hand and Resolve Pot live under a thumb that was
reaching for a name field. NN/g and Material both say the same thing about this:
a modal sheet with an invisible scrim tells the user they can still touch what
they can still see, and they can't.
Not an `*Overlay` id, deliberately — Modal watches those and would take the
sidebar panel this becomes on a desktop for a dialog. It is driven entirely by
body.seatSheetOpen, and it is `display: none` above 900px where the panel is
just a panel. *@
<div id="seatSheetScrim"></div>
<div id="holdemLayout">
<section id="tableSection">
<div id="boardStrip" hidden></div>
<div id="pokerTableWrap">
<div id="pokerTable">
<div id="tableCentre">
<div id="boardRow" hidden aria-label="Community cards"></div>
<div id="potBadge">
<span id="potBadgeLabel">Pot</span>
<span id="potBadgeAmount">$0</span>
</div>
</div>
</div>
<div id="betsLayer" aria-hidden="true"></div>
<div id="seatsLayer"></div>
</div>
<p id="tableHint" class="tableHint"></p>
<p class="tableHintActions">
<button type="button" id="openRulingsBtn" class="linkBtn">Common rulings</button>
<a href="/casino/texasholdem/quicksplit" class="linkBtn" title="Chop a pot without setting up a table">Quick Split</a>
</p>
</section>
<aside id="holdemSidebar">
<section id="seatEditorPanel" class="sidebarPanel seatEditorPanel">
<div class="seatSheetGrip" aria-hidden="true"></div>
<div class="seatEditorHeader">
<h2 id="seatEditorTitle">Select a seat</h2>
<button id="closeSeatEditorBtn" class="seatSheetClose" aria-label="Done editing this seat">Done</button>
</div>
<div id="seatEditorBody">
<p class="fieldHint">Click a seat at the table to edit their name, cards, chips and anything else.</p>
</div>
</section>
@* Not `open` in the markup any more. Whether this panel starts open is a
question about the table, so main.js answers it after the state has
loaded: open on a table nobody has set up yet, shut on one already
half-way through a night. *@
<details class="sidebarPanel" id="tableSetupPanel">
<summary><h2>Table Setup</h2> <span class="panelGlance" id="glanceTableSetup" hidden></span></summary>
@* The numbers first, and only the numbers.
This panel is the one thing that is open when the tool is first
opened, and it was 886px of a 667px phone — the two figures its own
summary glances at, the blinds, sat below 218px of prose explaining
three switches a dealer may never touch. The three have gone into
"While you deal" below, with the paragraphs that explain them, which
is where House take and Without live betting already were: the panel
had the vocabulary for this and was only using it for money. *@
@* Every field below takes digits and none of them is type="number".
That is deliberate, and it is the second half of a fix — the first
half is Render.setField, which stopped a re-render writing over the
text somebody was still typing.
type="number" was costing more than it paid here. It silently drops
characters it dislikes with no feedback to the person or to assistive
tech; it reports a half-typed "0." as "0", which is what let the
decimal point be written away; it does not expose selectionStart, so
nothing can put a caret back where it was; it draws a spin button
into a 54px-wide blind field, where five digits already need 45 of
the 52 available; and it is the control GOV.UK dropped from their
number patterns after user testing, for the same reasons plus Dragon
and NVDA.
inputmode is what actually summons the keypad on a phone, and it is
the better keypad: "decimal" carries the separator for the reader's
locale, which is the key a dealer needs to type 0.25 and the one
type="number" leaves them hunting for on iOS.
min/max/step go with it. Nothing relied on them: the seat count is
clamped to 2..24 in main.js before it is used, and the money fields
are read through a finite-number check that a spinner never provided
anyway. *@
<div class="panelBody">
<label class="fieldRow">
<span>Seats at table</span>
<input type="text" id="seatCountInput" inputmode="numeric" autocomplete="off" value="10" />
</label>
<div class="blindsRow">
<label class="fieldRow">
<span>Small blind</span>
<input type="text" id="smallBlindInput" inputmode="decimal" autocomplete="off" />
</label>
<label class="fieldRow">
<span>Big blind</span>
<input type="text" id="bigBlindInput" inputmode="decimal" autocomplete="off" />
</label>
</div>
<label class="fieldRow">
<span>Starting stack ($)</span>
<input type="text" id="defaultStackInput" inputmode="decimal" autocomplete="off" placeholder="e.g. 200" />
</label>
<p class="fieldHint">Blinds post themselves each hand. Seats without their own stack start here.</p>
<label class="fieldRow">
<span>Blinds move</span>
<select id="directionSelect">
<option value="clockwise" selected>Clockwise</option>
<option value="counterclockwise">Counterclockwise</option>
</select>
</label>
@* All three change what the tool does rather than what the table is,
and all three are set once and then left alone. The glance says so
when one of them is not on its default, because a switch you cannot
see is a switch you cannot remember turning on. *@
<details class="subPanel">
<summary>While you deal <span class="panelGlance subPanelGlance" id="glanceWhileYouDeal" hidden></span></summary>
<div class="subPanelBody">
<label class="fieldRow">
<span>Dedicated dealer (not a seat)</span>
<input type="checkbox" id="dedicatedDealerToggle" />
</label>
<p class="fieldHint">Turn this on if a person outside the game deals — no seat shows the dealer button, but blinds still rotate automatically each hand.</p>
<label class="fieldRow">
<span>Ask me for the cards as they come out</span>
<input type="checkbox" id="trackBoardToggle" />
</label>
@* The label used to lead with "Track the board", which is the feature's name
rather than what pressing it does to your evening. What it does is put a
card sheet in front of you at four moments: each street, and the showdown. *@
<p class="fieldHint">Every time you deal a street, a sheet asks which cards came off the deck, and puts them on the felt. At the showdown it asks for each hand that turns over &mdash; then Resolve Pot reads the winner and the amount instead of asking you. Close the sheet at any point to go back to picking the winner yourself.</p>
<label class="fieldRow">
<span>Keep the screen on during a hand</span>
<input type="checkbox" id="keepScreenAwakeToggle" />
</label>
@* On by default, and the only setting here that is. A dealer has to
opt in to the tool doing more than they asked; they should not have
to opt in to it staying readable. *@
<p class="fieldHint">Stops the phone dimming and locking between the flop and the turn. It only holds while betting is open, so the screen sleeps as usual during breaks &mdash; and the tool says so the first time it takes hold.</p>
</div>
</details>
<details class="subPanel">
<summary>House take</summary>
<div class="subPanelBody">
<div class="blindsRow">
<label class="fieldRow">
<span>Rake (%)</span>
<input type="text" id="rakePercentInput" inputmode="decimal" autocomplete="off" placeholder="0" />
</label>
<label class="fieldRow">
<span>Cap ($)</span>
<input type="text" id="rakeCapInput" inputmode="decimal" autocomplete="off" placeholder="none" />
</label>
</div>
<label class="fieldRow">
<span>No flop, no drop</span>
<input type="checkbox" id="rakeNoFlopInput" />
</label>
<p class="fieldHint">
Taken from the pot when it's awarded, and shown before anything is pushed.
Rounded down to a chip the table has, so the players keep what the house
can't make. Leave the percentage at zero for a game that doesn't rake.
</p>
<p class="fieldHint" id="rakeTakenHint" hidden></p>
</div>
</details>
<details class="subPanel">
<summary>Without live betting</summary>
<div class="subPanelBody">
<label class="fieldRow">
<span>Base pot amount ($)</span>
<input type="text" id="basePotInput" inputmode="decimal" autocomplete="off" placeholder="e.g. 500" />
</label>
<p class="fieldHint">For tracking a hand by hand instead of tapping the action: everyone is assumed to have put in this much, unless their seat says otherwise.</p>
</div>
</details>
</div>
</details>
@* These four were one panel called "Session", which is a container word rather
than a name: it held the standings, a waiting list, the hand log and the
save/load/reset buttons — a topic, a task, a log and three utilities, which
is four kinds of thing under one label. It measured at 1206px and 103
elements, and "The table itself" was 1052px down inside it, so reaching
Reset table meant opening a panel called Session and scrolling past the
money and the log to get there.
Each panel is named for the thing a dealer came looking for, and the
subheads that used to do that job are gone, because the summary says it
now. They are in the order of a night: set the table up, watch the money,
fill the seats, look back at a hand — and, last and apart from the rest,
the two buttons that end it. *@
<details class="sidebarPanel">
<summary><h2>Standings</h2> <span class="panelGlance" id="glanceStandings" hidden></span></summary>
<div class="panelBody">
<p id="storageWarning" class="storageWarning" hidden></p>
<div class="standingsTableWrap">
<div id="standingsTable">
<div class="standingsRow standingsHeader">
<span>Player</span>
<span class="standingsInHead">In</span>
<span class="standingsWonHead">Won</span>
<span class="standingsNetHead">Net</span>
<span class="standingsChipsHead" title="What they are holding — the cash to count out if they leave now">Chips</span>
<span title="Those chips, less the cash they handed over">Up/down</span>
</div>
<div id="standingsList"></div>
</div>
</div>
<p id="standingsEmptyHint" class="fieldHint">Everyone who has bought in shows up here, with what they'd be owed if the night ended now.</p>
<div id="bankReconcile" class="bankReconcile" hidden>
<div class="bankReconcileRow">
<span>Cash taken</span>
<span id="bankCashIn"></span>
</div>
<div class="bankReconcileRow">
<span>Chips in play</span>
<span id="bankChipsInPlay"></span>
</div>
<div class="bankReconcileRow" id="bankDiscardedRow" hidden>
<span>Taken off the table</span>
<span id="bankDiscarded"></span>
</div>
<div class="bankReconcileRow" id="bankRakedRow" hidden>
<span>House take</span>
<span id="bankRaked"></span>
</div>
<div class="bankReconcileRow" id="bankPaidOutRow" hidden>
<span>Paid out</span>
<span id="bankPaidOut"></span>
</div>
<p id="bankReconcileVerdict" class="bankReconcileVerdict"></p>
<button type="button" id="bankRecordBuyInsBtn" class="holdemBtn holdemBtnSmall" hidden></button>
</div>
<div id="settleUp" hidden>
<h3 class="panelSubhead">Settle up</h3>
<ul id="settleUpList" class="settleUpList"></ul>
<p id="settleUpNote" class="fieldHint"></p>
</div>
</div>
</details>
<details class="sidebarPanel">
<summary><h2>Waiting list</h2> <span class="panelGlance" id="glanceWaiting" hidden></span></summary>
<div class="panelBody">
<form id="waitingListForm" class="waitingAdd">
<input type="text" id="waitingNameInput" maxlength="24" placeholder="Who's waiting?" autocomplete="off" />
<button type="submit" id="waitingAddBtn" class="holdemBtn holdemBtnSmall">Add</button>
</form>
<ol id="waitingList" class="waitingList"></ol>
<p id="waitingEmptyHint" class="fieldHint">Nobody waiting. Add the people standing behind the chairs, and the tool will offer them the next seat that opens.</p>
</div>
</details>
<details class="sidebarPanel">
<summary><h2>Hands</h2> <span class="panelGlance" id="glanceHands" hidden></span></summary>
<div class="panelBody">
<div class="handHistoryFilterRow">
<input id="handHistoryFilter" type="search" class="handHistoryFilter"
placeholder="Find a hand — name, amount or #" aria-label="Filter the hand log by player, amount or hand number" />
<span id="handHistoryCount" class="handHistoryCount" hidden></span>
</div>
<div id="handHistoryList"></div>
<p id="handHistoryNoMatch" class="fieldHint" hidden></p>
<p id="handHistoryEmptyHint" class="fieldHint">Resolved pots are logged here, in case a chip count is disputed later.</p>
<div class="handHistoryActions">
<button id="exportHistoryBtn" class="holdemBtn holdemBtnSmall">Export CSV</button>
<button id="clearHistoryBtn" class="holdemBtn holdemBtnSmall holdemBtnDanger">Clear history</button>
</div>
</div>
</details>
<details class="sidebarPanel">
<summary><h2>Chip Legend</h2> <span class="panelGlance" id="glanceChips" hidden></span></summary>
@* The panel was 793px on an iPhone and 420 of them were the colour form:
five rows of swatch, value box and a red delete block, then an add
button and a paragraph of instructions — all of it above "Stacks in
chips", which is the part a dealer opens this panel *during* a game to
read. Setting the colours is a thing you do once, on the first night,
and it was sitting on top of the thing you do at every colour-up.
So the order is the other way round now, and the form is behind a
disclosure, which is the shape Table Setup already uses for House take
and Without live betting. The legend line stays out in the open: it is
what the panel is named for, it is two lines rather than four hundred
pixels, and it answers the other question a dealer opens this for —
what is the green one worth. *@
<div class="panelBody">
<ul id="chipLegendStrip" class="chipLegendStrip"></ul>
<div id="colourUp" hidden>
<h3 class="panelSubhead">Stacks in chips</h3>
<dl id="colourUpList" class="colourUpList"></dl>
<p class="fieldHint">The fewest chips that make each stack — for colouring up, or counting one out.</p>
</div>
<details class="subPanel">
<summary>Colours and values</summary>
<div class="subPanelBody">
<div id="chipLegendList"></div>
<button id="addChipColorBtn" class="holdemBtn holdemBtnSmall">+ Add chip color</button>
<p class="fieldHint">Give each chip color a value, then count chips on a seat instead of typing a total.</p>
</div>
</details>
</div>
</details>
<details class="sidebarPanel" id="keyboardPanel">
<summary><h2>Keyboard</h2></summary>
<div class="panelBody">
<dl id="shortcutLegend" class="shortcutLegend"></dl>
<p class="fieldHint">Nothing fires while you're typing in a field, or while a sheet is open over the table.</p>
</div>
</details>
<details class="sidebarPanel">
<summary><h2>Table Notes</h2> <span class="panelGlance" id="glanceNotes" hidden></span></summary>
<div class="panelBody">
<textarea id="tableNotes" rows="4" aria-label="Table notes" placeholder="Anything else worth tracking — side bets, IOUs, running totals..."></textarea>
</div>
</details>
@* Last, and on its own, because everything in it is about the night as a whole
rather than the hand in front of you — and because two of the three buttons
are the ones you cannot take back.
Reset table came down from the header a while ago, where it sat in the same
weight as Resolve Pot: the most-pressed control in a session beside the one
that throws the night away. It used to be buried at the bottom of Session,
1052px down, which hid it but also meant nobody could find it on purpose.
Naming the panel for both jobs puts the danger on the label, where it is
read before the panel is opened rather than met at the end of a scroll. *@
<details class="sidebarPanel">
<summary><h2>Save &amp; reset</h2></summary>
<div class="panelBody">
<p class="fieldHint">
The night lives in this browser and nowhere else. Save it to a file to carry it
to another device, or to have something to come back to if this one dies.
</p>
@* That sentence has always been true, and always been missing its
second half: a browser is allowed to throw site data away when the
device runs short, and Safari does it on a timer. So the panel now
says which of the two situations this device is actually in, and
offers the one thing that changes it. See requestDurable() in
state.js for why the ask is a button rather than a page-load call. *@
<p class="fieldHint" id="storageDurabilityHint" hidden></p>
<button id="keepStorageBtn" class="holdemBtn holdemBtnSmall" hidden>Ask this browser to keep it</button>
<div class="handHistoryActions">
<button id="saveTableBtn" class="holdemBtn holdemBtnSmall">Save table file</button>
<button id="loadTableBtn" class="holdemBtn holdemBtnSmall">Load table file</button>
<input id="loadTableInput" type="file" accept="application/json,.json" hidden />
<button id="resetTableBtn" class="holdemBtn holdemBtnSmall holdemBtnDanger">Reset table</button>
</div>
</div>
</details>
</aside>
</div>
</div>
<template id="seatTemplate">
<div class="seat" draggable="true">
<div class="seatChairNumber"></div>
<button class="dealerChip" type="button" title="Make this seat the dealer button">D</button>
<span class="blindBadge sbBadge">SB</span>
<span class="blindBadge bbBadge">BB</span>
<span class="blindBadge strBadge" title="Straddled — the action comes back to this seat">STR</span>
<div class="seatPuckName"></div>
<div class="seatPuckCards"></div>
<div class="seatPuckTotal"></div>
<div class="seatPuckStack"></div>
</div>
</template>
@* Four things stacked flat used to be five: name, in/out, two hole-card slots, stack,
a five-box chip counter and a list of custom fields, every one of them at the same
weight, all of it open at once. Measured on a 390px phone the sheet came to 594px,
and the three controls a dealer touches on nearly every visit — the name, the two
status boxes, the stack — were 132px of it. The chip counter alone was 174px, the
tallest thing in the sheet, for a job most tables do once a night if at all; the two
card slots were 62px of dashed grey on every table that never asked to be shown a
card.
So the flat list is now three rows and three disclosures, and which of the three
stand open is a question about *this seat*, answered in renderSeatDisclosures: a
seat holding cards opens its cards, a seat with chips counted opens its count, a
seat with notes on it opens its notes. Anything with nothing in it stays a one-line
summary that says so. Nothing was removed and nothing moved to another screen. *@
<template id="seatEditorTemplate">
<div class="seatEditorForm">
<label class="fieldRow">
<span>Name</span>
<input type="text" class="seatNameInput" placeholder="Seat" maxlength="24" />
</label>
@* One question with three answers, not two boxes with four corners.
A seat is sitting out, in the hand, or folded — and "folded" only means
anything while the seat is in. So the two checkboxes here could be ticked
into a state that means nothing, and the code that stopped them doing it
`disabled` the Fold box whenever In hand was clear. Measured: that left the
label at the same opacity and the same colour with a `title` on it, so a
dealer pressing Fold on a seat that was sitting out got no movement and no
reason — the exact thing round 16 went through the tool to remove, hiding
from the rule that finds it because the title sat on the label rather than
on the control.
Three named states cannot contradict each other and none of them is ever
unavailable, so there is nothing left to dim. The radiogroup is the same
shape the card picker's suits use. *@
<div class="seatState" role="radiogroup" aria-label="This seat">
<button type="button" class="seatStateOption" data-seat-state="out" role="radio" aria-checked="false">Sitting out</button>
<button type="button" class="seatStateOption" data-seat-state="in" role="radio" aria-checked="false">In the hand</button>
<button type="button" class="seatStateOption" data-seat-state="folded" role="radio" aria-checked="false">Folded</button>
</div>
<label class="fieldRow stackRow">
<span>Stack</span>
<input type="text" class="stackInput" inputmode="decimal" autocomplete="off" placeholder="table default" />
</label>
<div class="seatBuyIns" hidden></div>
<details class="subPanel seatSubPanel cardsPanel">
<summary><span class="seatSubPanelName">Hole cards</span> <span class="seatGlance cardsGlance"></span></summary>
<div class="subPanelBody">
<div class="seatCards">
<button type="button" class="cardSlot card1Slot" aria-label="Choose first card"></button>
<button type="button" class="cardSlot card2Slot" aria-label="Choose second card"></button>
</div>
</div>
</details>
@* Named for the question it answers rather than for the boxes it contains. The
counter, the typed override and the total are one calculation — what this
seat has put in the pot — and the answer now sits on the summary, so a shut
panel still says it. *@
<details class="subPanel seatSubPanel contributionPanel">
<summary><span class="seatSubPanelName">What they've put in</span> <span class="seatGlance contributionGlance"></span></summary>
<div class="subPanelBody seatContribution">
<div class="chipCounts"></div>
<label class="fieldRow amountOverrideRow">
<span>$ amount</span>
<input type="text" class="amountOverrideInput" inputmode="decimal" autocomplete="off" placeholder="auto" />
</label>
<div class="seatTotal">Total: <span class="seatTotalAmount">$0</span></div>
</div>
</details>
<details class="subPanel seatSubPanel notesPanel">
<summary><span class="seatSubPanelName">Notes on this seat</span> <span class="seatGlance notesGlance"></span></summary>
<div class="subPanelBody">
<div class="customFields"></div>
<button class="holdemBtn holdemBtnSmall addFieldBtn">+ field</button>
</div>
</details>
</div>
</template>
<template id="chipLegendRowTemplate">
<div class="chipLegendRow">
<input type="color" class="chipColorInput" aria-label="Chip colour" />
<input type="text" class="chipValueInput" inputmode="decimal" autocomplete="off" placeholder="value" aria-label="Chip value" />
<button class="holdemBtn holdemBtnSmall holdemBtnDanger removeChipColorBtn">×</button>
</div>
</template>
@* The value used to be a `title` attribute, which is a tooltip — and a tooltip is
nothing at all on the device this is used on. Five coloured dots with five number
boxes and no way to tell which one was the $25 without opening the Chip Legend and
matching colours by eye. It is text now. *@
<template id="chipCountTemplate">
<label class="chipCountRow">
<span class="chipSwatch"></span>
<span class="chipCountValue"></span>
<input type="text" class="chipCountInput" inputmode="numeric" autocomplete="off" value="0" />
</label>
</template>
<template id="customFieldTemplate">
<div class="customFieldRow">
<input type="text" class="customFieldLabel" placeholder="Label" maxlength="20" />
<input type="text" class="customFieldValue" placeholder="Value" maxlength="40" />
<button class="holdemBtn holdemBtnSmall holdemBtnDanger removeFieldBtn">×</button>
</div>
</template>
<template id="standingsRowTemplate">
<div class="standingsRow">
<span class="standingsName"></span>
<span class="standingsIn"></span>
<span class="standingsWon"></span>
<span class="standingsNet"></span>
<span class="standingsChips"></span>
<span class="standingsCashOut"></span>
</div>
</template>
<template id="handHistoryRowTemplate">
<div class="handHistoryRow">
<div class="handHistoryRowHeader">
<span class="handHistoryHandNumber"></span>
<span class="handHistoryPot"></span>
<span class="handHistoryTime"></span>
</div>
<div class="handHistoryPayouts"></div>
</div>
</template>
<div id="addChipsOverlay" hidden>
<div id="addChipsSheet" role="dialog" aria-labelledby="addChipsTitle">
<div class="seatSheetGrip" aria-hidden="true"></div>
<div class="addChipsHeader">
<h2 id="addChipsTitle" tabindex="-1" data-modal-focus></h2>
<span id="addChipsSubtitle"></span>
</div>
<div id="addChipsQuick"></div>
<div class="addChipsEntry">
<span class="addChipsCurrency">$</span>
<input type="text" id="addChipsInput" inputmode="decimal" autocomplete="off" aria-label="Amount to add" />
</div>
<div class="addChipsActions">
<button id="addChipsConfirmBtn" class="holdemBtn holdemBtnAccent" type="button">Add to stack</button>
<button id="addChipsCancelBtn" data-modal-close class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
</div>
</div>
</div>
<div id="rulingsOverlay" hidden>
<div id="rulingsSheet" role="dialog" aria-labelledby="rulingsTitle">
@* The grip belongs inside the header, not above it, because the header is stuck
to the top of the scrollport — anything above a sticky thing is something the
list scrolls out through. The seat menu's header is built the same way, and
for the same reason. *@
<div class="rulingsHeader">
<div class="seatSheetGrip" aria-hidden="true"></div>
<h2 id="rulingsTitle" tabindex="-1" data-modal-focus>Common rulings</h2>
<button id="closeRulingsBtn" data-modal-close class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
</div>
<p class="rulingsIntro">
The conventions most rooms follow, for the calls that come up mid-hand.
Your house rules always win — and settle a dispute before the next hand,
not during it.
</p>
@* Twelve rulings, shut.
They used to be twelve headings with all their prose under them — 2,480px of
it on a phone, of which 1,754 was below the fold — and a row of twelve chips
across the top to jump between them. The chips were an index of the twelve
headings printed directly above the same twelve headings, which is the same
list twice: 172px of navigation before the first word, and the dealer who
used it landed in the middle of a scroll with eleven rulings they didn't ask
for still wrapped around them.
The headings are the index now. NN/g's rule for this shape — a list of
independent topics somebody opens one of — is an accordion, shut by default,
and half of these headings answer the question without being opened at all:
"A short all-in doesn't reopen the betting" is the ruling, not the topic.
Not exclusive (no `name` attribute): closing one because another opened moves
everything under the dealer's finger, which is the disorientation the
accordion is here to avoid. *@
@* Six and six, down one column and then the next. Two boxes rather than one
list so that landscape can lay them side by side without the columns
sharing rows: opening a ruling in one of them moves what is under it in
that column and nothing else. Everywhere but landscape the wrappers are
`display: contents` and the twelve are one column, exactly as before. *@
<div class="rulingsList">
<div class="rulingsColumn">
<details class="ruling">
<summary><h3>Redeal it — misdeal</h3></summary>
<ul>
<li>The first card goes to the wrong seat.</li>
<li>A seat entitled to a hand gets dealt out, or a seat that isn't entitled gets dealt in.</li>
<li>Two or more boxed cards turn up on the initial deal.</li>
<li>Either of the first two cards is exposed by dealer error.</li>
</ul>
</details>
<details class="ruling">
<summary><h3>A board card turned too early</h3></summary>
<p>
If the fourth board card gets turned before the betting round is finished,
that card is out of play for the round. Finish the betting, then burn and
turn what would have been the fifth card in the fourth card's place.
</p>
</details>
<details class="ruling">
<summary><h3>Acting out of turn</h3></summary>
<p>
A one-off is usually just backed up if no one has acted behind them.
Doing it repeatedly is what earns a penalty.
</p>
</details>
<details class="ruling">
<summary><h3>Calling the clock on a player</h3></summary>
<p>
Any player may ask for a clock once a decision is taking unreasonably
long; it is the dealer, not the table, who grants it. The player then
gets a set time and a countdown out loud — thirty seconds and ten is the
usual form.
</p>
<p>
If the count reaches zero with no action, the hand is <em>dead</em> if
they were facing a bet, and <em>checked</em> if they were not: nobody is
folded for slowness when checking was free. The action bar runs the
count once you tap <em>Call the clock</em>, and offers the ruling as one
button when it ends.
</p>
</details>
<details class="ruling">
<summary><h3>A straddle is a blind raise</h3></summary>
<p>
The player left of the big blind may put out twice it before the cards,
without looking. It is a blind bet, not a raise made in turn, so it does
three things at once: everyone else acts first, the minimum raise becomes
twice the straddle, and the straddler still has the option when the
action comes back — they may raise even if nobody raised into them.
</p>
<p>
Re-straddles carry on to the left, each doubling again, and stop before
the small blind. Tap <em>Straddle</em> on the deal prompt and the tool
posts it, puts the right player on the clock and prices the raise;
the seat wears an <em>STR</em> badge for the hand.
</p>
</details>
<details class="ruling">
<summary><h3>A short all-in doesn't reopen the betting</h3></summary>
<p>
Somebody shoving in less than a full raise — say $3 more when the last
raise was $8 — does not give the players behind them another go. Anyone
who has already acted owes the difference or folds; they don't get to
raise again because a short stack happened to land between them.
</p>
<p>
A player who hadn't acted yet keeps full rights and may still raise, and
the minimum raise stays measured off the last <em>full</em> raise, not
off the short one. The action bar follows this for you.
</p>
</details>
</div>
<div class="rulingsColumn">
<details class="ruling">
<summary><h3>Chips nobody called come back</h3></summary>
<p>
Bet more than anyone was able to call and the excess is returned to you
before the showdown — it was never contested, so it isn't part of the
pot. The pot breakdown shows it as "uncalled" rather than pretending it's
a side pot you could win.
</p>
</details>
<details class="ruling">
<summary><h3>One big chip, nothing said</h3></summary>
<p>
The most common argument at a home table. A player facing a bet who
pushes out a single oversized chip without saying anything has
<em>called</em> — not raised, however big the chip is. To raise with one
chip you have to say "raise" before it lands; say it without an amount
and the raise is the most that chip allows.
</p>
<p>
Facing no bet, the same silent chip is a bet of the whole chip. And a
clear verbal declaration in turn is binding either way — if words and
chips disagree, whichever came first is the action.
</p>
</details>
<details class="ruling">
<summary><h3>A raise has to be a full raise</h3></summary>
<p>
At least as much again as the last raise, or the big blind if there
hasn't been one. Anything between a call and that minimum isn't a raise
a player can make — it's a call, unless they're putting their last chips
in. The raise pad says so if you type one, and won't stop you: your
ruling beats the arithmetic.
</p>
</details>
<details class="ruling">
<summary><h3>Who turns their hand over first</h3></summary>
<p>
If there was a bet on the river, the last player to bet or raise shows
first. If the river was checked all the way round, it's the first live
player left of the button. Nobody has to be asked twice — a player may
always turn their hand over early if they want to.
</p>
<p>
When everyone is all-in before the river there was no final round to
lead, so it falls to whoever last put money in, on whichever street that
was. The action bar names the seat and the reason at every showdown, so
the call is already made before anyone asks.
</p>
</details>
<details class="ruling">
<summary><h3>The odd chip in a split pot</h3></summary>
<p>
When a chopped pot won't divide evenly, the odd chip goes to the first
player left of the button, not to whoever the dealer happened to tick
first. Pot payouts here order the winners that way, so the leftover lands
on the right seat every time.
</p>
</details>
<details class="ruling">
<summary><h3>Betting in one motion — the string bet</h3></summary>
<p>
A bet is whatever goes in on the first trip to the stack, unless the
amount was said out loud first. Reaching back for more chips without
having said "raise" is a string bet, and only the first motion stands.
Saying the number is always enough on its own: "raise to sixty" binds
whatever order the chips arrive in.
</p>
<p>
It's the rule home games invoke most and enforce least, and both are
fine — the point is that a player can't watch the table react and then
decide how much to bet. Say it, or push it all at once.
</p>
</details>
</div>
</div>
<p class="rulingsSource">
Follows the Tournament Directors Association rules, which most rooms and
home games take their lead from.
</p>
</div>
</div>
<div id="seatMenuOverlay" hidden>
<div id="seatMenuSheet" role="dialog" aria-labelledby="seatMenuTitle">
@* The grip was the only thing at the top of this sheet, and NN/g is explicit
that a grab handle is not a close affordance — a sheet wants a real one. It
earns its place twice over here: this sheet is the one that can outgrow the
screen, and with the header stuck to the top the way out never scrolls away.
Third job, the quiet one: Escape finds this close button rather than the one
at the foot, so Escape closes the sheet while Back steps back inside it.
It used to carry a fourth. Modal focused a dialog's first control and the
first control here was Fold, so putting a close button above the list was
also what moved the landing spot off the destructive choice. That was the
right half of the answer — Roselli is clear that a close button is a landing
spot for a sentence, not for a sheet long enough to scroll, where a stray
Space dismisses the thing the dealer just opened. The heading below names
the spot outright now, and it says which seat. *@
<div class="seatMenuHeader">
<div class="seatSheetGrip" aria-hidden="true"></div>
<div class="seatMenuHeading">
<h2 id="seatMenuTitle" tabindex="-1" data-modal-focus></h2>
<span id="seatMenuSubtitle"></span>
</div>
<button id="seatMenuCloseBtn" data-modal-close class="seatMenuClose" type="button" aria-label="Close this menu">×</button>
</div>
<div id="seatMenuActions"></div>
<button id="seatMenuCancelBtn" class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
</div>
</div>
<div id="cardPickerOverlay" hidden>
@* `data-why-note` is this sheet claiming the job of saying why a card can't be
picked. The toast that answers everywhere else is fixed to the bottom of the
window, and this sheet stands on top of it. *@
<div id="cardPickerSheet" role="dialog" aria-labelledby="cardPickerTitle" data-why-note="cardPickerNote">
<div class="seatSheetGrip" aria-hidden="true"></div>
<div class="cardPickerHeader">
@* Not the first control any more. Modal used to land on whatever came
first in here, and what came first in here was Clear: the sheet a
dealer opens to write a card down opened on the button that rubs one
out. The heading says which seat and which card, which is the thing
worth hearing before any of the fifty-two. *@
<h2 id="cardPickerTitle" tabindex="-1" data-modal-focus></h2>
<button id="cardPickerClearBtn" class="holdemBtn holdemBtnSmall" type="button">Clear</button>
<button id="cardPickerCloseBtn" data-modal-close class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
</div>
<div id="cardPickerSlots"></div>
@* Empty until a dealt card is pressed, and holding its line the whole time so
that filling it doesn't shove fifty-two cards down the screen under a thumb
that is already reaching for one. *@
<p id="cardPickerNote" class="pickerNote" role="status"></p>
@* The first of the two questions a card is. A radiogroup rather than a tablist:
the grid below is not a different panel of content, it is the same thirteen
controls filtered, and single-select filtering is what a radio group is for.
The suits are a fixed set written by paint(), not here, so that the one place
that knows what a suit looks like stays CardPicker.SUITS. *@
<div id="cardPickerSuits" role="radiogroup" aria-label="Suit"></div>
@* Which suit's thirteen these are, said once for a screen reader rather than
left to be inferred from the chosen radio two elements up. *@
<span id="cardPickerGridLabel" class="visuallyHidden"></span>
<div id="cardPickerGrid" role="group" aria-labelledby="cardPickerGridLabel"></div>
</div>
</div>
@* No aria-live here any more. This element's entire contents are rebuilt on every
action, so a live region on it announced the whole bar every time — measured, after
one tap: "PRE-FLOP Pot $15 9 players Bob · $250 behind 0:00 ↶ dealing the hand U
Fold Call $10 Raise". That is what a screen reader read out after every single fold.
A live region has to be a stable element whose *text* changes; the one below is. *@
<div id="actionBar" hidden></div>
@* Whose turn it is, in a sentence. Empty in the markup and only ever written to, so
assistive tech is already watching it when the first update lands — a live region
created at the moment it has something to say is one the screen reader misses. *@
<div id="actionAnnouncer" role="status" aria-live="polite" class="visuallyHidden"></div>
<div id="toastHost" aria-live="polite"></div>
<div id="confirmOverlay" hidden>
<div id="confirmModal" role="alertdialog" aria-labelledby="confirmTitle" aria-describedby="confirmMessage" tabindex="-1">
<h2 id="confirmTitle"></h2>
<p id="confirmMessage"></p>
@* The one sheet that lands on a control rather than its heading, and the one
where the APG asks for it by name: this is a question with two answers and
one of them cannot be taken back, so the dealer starts on the answer that
leaves them no worse off. It is short enough to be read at a glance, which is
the case where landing on a control is the right thing. Said out loud here
rather than left to depend on which button happens to be written first. *@
<div class="confirmActions">
<button id="confirmCancelBtn" data-modal-close data-modal-focus class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
<button id="confirmAcceptBtn" class="holdemBtn holdemBtnDanger" type="button">Confirm</button>
</div>
</div>
</div>
<div id="potWizardOverlay" hidden>
<div id="potWizardModal" role="dialog" aria-labelledby="potWizardTitle">
<div class="potWizardHeader">
<h2 id="potWizardTitle" tabindex="-1" data-modal-focus>Resolve the Pot</h2>
@* Named, and named for what leaving does rather than "Close". The glyph is
the whole of this control, so unnamed it announced as "button" — on the
one dialog in the tool that divides money, where the difference between
backing out and confirming is somebody's stack. *@
<button id="closePotWizardBtn" type="button" data-modal-close class="holdemBtn holdemBtnGhost"
aria-label="Close without paying the pot">✕</button>
</div>
<div id="potWizardBody"></div>
@* The buttons live outside the scrolling body on purpose. This is the one
dialog that divides money, and its Confirm sat below a scroll at every screen
size — 244px off the bottom on an iPhone, 583 on a phone held sideways. A
sticky footer cannot fix that from inside: the actions were the last child of
the scrolling content, so their containing block ended exactly where they did
and `bottom` had no room to lift them. Out here they simply don't scroll. *@
<div id="potWizardFooter"></div>
</div>
</div>
@section Styles {
<link href="/css/casino/holdem.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
@* Installable, and works with no signal — see the service worker registration at the
foot of main.js. A dealer running a night in somebody's back room is on the worst
network there is, and the tool being a *website* was the last thing that could
take it away from them mid-game. *@
<link rel="manifest" href="/casino/holdem.webmanifest" />
@* iOS reads none of the manifest's icons and only this. *@
<link rel="apple-touch-icon" href="/images/casino/holdem-apple-touch.png" />
@* The standard name first, then Apple's older one. Chrome has been printing
"<meta name="apple-mobile-web-app-capable"> is deprecated" into this page's
console on every load; the replacement is the unprefixed name, and Safari still
wants the prefixed one, so both are here. *@
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Hold'em" />
@* Paints the phone's chrome the same near-black as the felt's ground, so an
installed copy doesn't sit under a white status bar. *@
<meta name="theme-color" content="#141414" />
}
@* A note for whoever reads the ?v= below and assumes it works: it doesn't. ViewData
["cssVersion"] is assigned in _Layout's own code block, which runs after these
sections are handed their values, so every one of these renders as a bare "?v=" —
on this page and on Chess, Particles and Memory Lane too. It is inert rather than
broken: Program.cs sends `Cache-Control: no-store` on every static file, so nothing
is being browser-cached for a version string to bust in the first place.
Left alone deliberately. Fixing it is a one-line change to a file every page on the
site shares, and the service worker was the reason to care — that now uses stale-
while-revalidate, which needs no version to stay current. *@
@section Scripts {
<script src="~/js/casino/holdem/money.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/feedback.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/potCalculator.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/state.js?v=@ViewData["cssVersion"]"></script>
@* After feedback.js, because the first lock it takes announces itself with a Toast. *@
<script src="~/js/casino/holdem/wakeLock.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/settleUp.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/render.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/handRank.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/cardPicker.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/seatMenu.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/addChips.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/betting.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/actionBar.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/shortcuts.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/potWizard.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/handHistoryExport.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/modal.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/main.js?v=@ViewData["cssVersion"]"></script>
}
@@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace JoshHeaps.Net.Pages.Casino
{
public class TexasHoldemModel : PageModel
{
public void OnGet()
{
}
}
}
@@ -0,0 +1,95 @@
@page
@model JoshHeaps.Net.Pages.Casino.TexasHoldem.QuickSplitModel
@{
ViewData["Title"] = "Quick Split";
Layout = "_Layout";
}
<div id="splitApp">
<header id="splitHeader">
<h1>Quick Split</h1>
<p class="splitSubtitle">Chop a pot without setting up a table.</p>
</header>
<div id="modeToggle" class="chipRow" role="group" aria-label="How the money went in">
<button type="button" id="modeEvenBtn" class="choiceChip selected">Everyone matched</button>
<button type="button" id="modeUnevenBtn" class="choiceChip">Someone's all-in</button>
</div>
<div id="splitResult" aria-live="polite">
<div id="splitResultLabel">Each player gets</div>
<div id="splitResultAmount">$0.00</div>
<div id="splitOddNote" hidden></div>
<div id="splitDustNote" hidden></div>
</div>
<div id="unevenResult" hidden aria-live="polite">
<div id="unevenPots"></div>
<p id="unevenEmpty" class="unevenEmpty">Enter what each player put in.</p>
</div>
<section class="splitField unevenOnly" hidden>
<span class="splitLabel">What each player put in</span>
<div id="unevenRows"></div>
<button type="button" id="addPlayerBtn" class="splitBtn splitBtnGhost">+ Add player</button>
</section>
<section class="splitField evenOnly">
<label for="potInput" class="splitLabel">Pot</label>
<div class="potInputWrap">
<span class="potCurrency">$</span>
<input type="text" id="potInput" inputmode="decimal" autocomplete="off"
placeholder="0" aria-label="Pot amount" />
<button type="button" id="clearPotBtn" class="clearPotBtn" aria-label="Clear pot amount" hidden>✕</button>
</div>
</section>
<section class="splitField evenOnly">
<span class="splitLabel" id="waysLabel">Split between</span>
<div id="waysChoices" class="chipRow" role="group" aria-labelledby="waysLabel"></div>
</section>
<section class="splitField evenOnly">
<span class="splitLabel" id="chipLabel">Smallest chip in play</span>
<div id="chipChoices" class="chipRow" role="group" aria-labelledby="chipLabel"></div>
</section>
<div id="splitActions">
<button type="button" id="resetSplitBtn" class="splitBtn splitBtnGhost">Clear</button>
<a href="/casino/texasholdem" class="splitBtn splitBtnGhost">Full dealer table →</a>
</div>
<p class="splitFootnote evenOnly">
If somebody was all-in for less than the others, switch to
<strong>Someone's all-in</strong> above.
</p>
<p class="splitFootnote unevenOnly" hidden>
Pots are listed with who can win each. Award them by hand strength, best hand
first. Nothing here is saved — the <a href="/casino/texasholdem">full table</a>
keeps a log.
</p>
</div>
<template id="unevenRowTemplate">
<div class="unevenRow">
<span class="unevenRowLabel"></span>
<div class="unevenAmountWrap">
<span class="unevenCurrency">$</span>
<input type="text" class="unevenAmount" inputmode="decimal" autocomplete="off" placeholder="0" />
</div>
<button type="button" class="unevenFoldBtn" aria-pressed="false">Folded</button>
<button type="button" class="unevenRemoveBtn" aria-label="Remove player">✕</button>
</div>
</template>
@section Styles {
<link href="/css/casino/quicksplit.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
}
@section Scripts {
<script src="~/js/casino/holdem/money.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/splitCalculator.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/potCalculator.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/quickSplit.js?v=@ViewData["cssVersion"]"></script>
}
@@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace JoshHeaps.Net.Pages.Casino.TexasHoldem
{
public class QuickSplitModel : PageModel
{
public void OnGet()
{
}
}
}
+61 -17
View File
@@ -6,7 +6,14 @@
}
<div id="chessContainer">
<div id="boardContainer">
<section id="boardArea">
<div class="playerBar" id="topPlayerBar">
<span class="playerDot black"></span>
<span class="playerName">Opponent</span>
<div class="capturedTray" id="captured-top"></div>
<span class="advantage" id="advantage-top"></span>
</div>
<div id="chessBoard">
<!-- Placeholder squares -->
@for (int i = 0; i < 64; i++)
@@ -14,16 +21,41 @@
<div id="square-@i" class="chessSquare @( (i + i / 8) % 2 == 0 ? "light" : "dark" )"></div>
}
</div>
</div>
<div id="textContainer" class="sideContent">
<h1>Chess</h1>
<p>Click a button to start a game :)</p>
</div>
<div class="playerBar" id="bottomPlayerBar">
<span class="playerDot white"></span>
<span class="playerName">You</span>
<div class="capturedTray" id="captured-bottom"></div>
<span class="advantage" id="advantage-bottom"></span>
</div>
</section>
<div id="buttonContainer" class="sideContent">
<button id="startGameBtn" onclick="startNewGame()">Start New Game</button>
<button id="startCPUGame" onclick="startCPUGame()">Vs CPU</button>
<aside id="gamePanel">
<header class="panelHeader">
<span class="panelLogo">&#9822;</span>
<h1>Chess</h1>
<button id="menuClose" class="iconBtn" aria-label="Close menu" onclick="closeMenu()">&times;</button>
</header>
<div id="moveList">
<p class="movePlaceholder">Moves will appear here once a game begins.</p>
</div>
<div id="statusLine">Start a game to play.</div>
<div id="panelButtons">
<button id="startGameBtn" class="btn btn-primary" onclick="startNewGame()">New Game</button>
<button id="startCPUGame" class="btn btn-secondary" onclick="startCPUGame()">Play vs CPU</button>
<button id="copyPgnBtn" class="btn btn-ghost" onclick="copyPgn()" style="display: none">Copy PGN</button>
<a id="watchLink" href="/watch">Watch other games &rarr;</a>
</div>
</aside>
<div id="menuBackdrop" onclick="closeMenu()"></div>
<div id="mobileBar">
<span id="mobileStatus">Start a game to play.</span>
<button id="menuToggle" class="btn btn-primary" onclick="toggleMenu()">Menu</button>
</div>
</div>
@@ -57,19 +89,31 @@
</div>
</div>
<div id="colorModal" style="display: none;">
<p>Play as:</p>
<div id="colorButtonContainer">
<button onclick="selectColor('white')">White</button>
<button onclick="selectColor('black')">Black</button>
<button onclick="selectColor('random')">Random</button>
</div>
</div>
@section Scripts {
<script src="~/js/signalr/signalr.min.js"></script>
<script src="~/js/ChessScripts/GameState.js"></script>
<script src="~/js/ChessScripts/ChessUtils.js"></script>
<script src="~/js/ChessScripts/ChessAPI.js"></script>
<script src="~/js/ChessScripts/ChessSignalR.js"></script>
<script src="~/js/ChessScripts/ChessInteractions.js"></script>
<script src="~/js/ChessScripts/ChessBoard.js"></script>
<script src="~/js/ChessScripts/ChessModals.js"></script>
<script src="~/js/ChessScripts/chessMain.js"></script>
<script src="~/js/ChessScripts/GameState.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/ChessScripts/ChessUtils.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/ChessScripts/ChessAPI.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/ChessScripts/ChessSignalR.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/ChessScripts/ChessInteractions.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/ChessScripts/ChessBoard.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/ChessScripts/ChessModals.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/ChessScripts/chessMain.js?v=@ViewData["cssVersion"]"></script>
}
@section Styles {
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="~/css/chess/game.css?v=@ViewData["cssVersion"]" />
<link rel="stylesheet" href="~/css/chess/site.css?v=@ViewData["cssVersion"]" />
}
+89 -73
View File
@@ -1,101 +1,117 @@
@page
@page
@model JoshHeaps.Net.Pages.IndexModel
@{
ViewData["Title"] = "JoshHeaps.Net";
Layout = "_Layout";
}
<header id="Header">
<a class="headerOption" onclick="showClickedContents('projects')">./ Projects</a>
<a class="headerOption" onclick="showClickedContents('demos')">./ Demos</a>
<a class="headerOption" onclick="showClickedContents('contact')">./ Contact me</a>
</header>
<div id="WelcomeMessage"></div>
<div id="ProjectsBox">
<div id="ChessProject" class="projectContainer">
<div class="displayBox diagonal-section-left">
<a target="_blank" href="https://github.com/JoshHeaps/OnlineChess">
<img id="ChessImage" class="projectImage projectImageLeft" src="/images/Chess.jpg" title="Chessboard"/>
</a>
</div>
<div id="ChessText" class="projectTextRight">
<h2 id="ChessTitle" class="projectHeaderRight projectHeader">Chess</h2>
<p id="ChessDescription" class="projectDescriptionRight projectDescription">
As someone who loves chess, I wanted to create a chess game that I could play with my friends and family. This project is a work in progress, as currently it can only be played locally. It was built in C# on .NET 8.0, using winforms, because I like a challenge. I plan to add online multiplayer functionality in the future.
</p>
</div>
<nav id="Header">
<span class="site-name">josh heaps</span>
<div class="nav-links">
<a class="nav-link" onclick="showClickedContents('projects')">Projects</a>
<a class="nav-link" onclick="showClickedContents('demos')">Demos</a>
<a class="nav-link" onclick="showClickedContents('blog')">Blog</a>
<a class="nav-link" onclick="showClickedContents('contact')">Contact</a>
</div>
</nav>
<div id="CompilerProject" class="projectContainer">
<div id="CompilerText" class="projectTextLeft">
<h2 id="CompilerTitle" class="projectHeaderLeft projectHeader">Compiler</h2>
<p id="CompilerDescription" class="projectDescriptionLeft projectDescription">
I love solving problems, and figuring out how things work. What better way to do both than writing a compiler myself? This project is a compiler that I wrote in C# on .NET 8.0. It takes in a simple language that I created, outputs the corresponding common intermediate language (CIL), and executes it. I plan to add more features in the future.
</p>
</div>
<div class="displayBox diagonal-section-right">
<a target="_blank" href="https://github.com/JoshHeaps/CILCompiler">
<img id="CompilerImage" class="projectImage projectImageRight" src="/images/CompilerDemo.png" title="Compiler Input and Output"/>
<section id="Hero">
<div id="WelcomeMessage"></div>
</section>
<section id="ProjectsBox">
<h2 class="section-title">Projects</h2>
<div class="projects-grid">
<article class="project-card">
<a href="https://github.com/JoshHeaps/JoshHeaps.Net/blob/master/JoshHeaps.Net/Pages/Chess.cshtml" target="_blank" class="project-image-link">
<img class="projectImage" src="/images/Chess.jpg" alt="Chessboard" />
</a>
</div>
</div>
</div>
<div class="project-info">
<h3 class="project-title">Chess</h3>
<p class="project-description">As someone who loves chess, I wanted to create a chess game that I could play with my friends and family. It was made using .NET razor pages, a .NET web api backend, and a good amount of javascript on the frontend. Feel free to check it out in the demos below :)</p>
<a class="project-link" href="https://github.com/JoshHeaps/JoshHeaps.Net/blob/master/JoshHeaps.Net/Pages/Chess.cshtml" target="_blank">View on GitHub &rarr;</a>
</div>
</article>
<div id="DemosBox" class="displayBox">
<h2 id="DemoHeader">Demos</h2>
<article class="project-card">
<a href="https://github.com/JoshHeaps/CILCompiler" target="_blank" class="project-image-link">
<img class="projectImage" src="/images/CompilerDemo.png" alt="Compiler Input and Output" />
</a>
<div class="project-info">
<h3 class="project-title">Compiler</h3>
<p class="project-description">I love solving problems, and figuring out how things work. What better way to do both than writing a compiler myself? This project is a compiler that I wrote in C# on .NET 8.0. It takes in a simple language that I created, outputs the corresponding common intermediate language (CIL), and executes it. I plan to add more features in the future.</p>
<a class="project-link" href="https://github.com/JoshHeaps/CILCompiler" target="_blank">View on GitHub &rarr;</a>
</div>
</article>
<article class="project-card">
<a href="https://media.joshheaps.net" target="_blank" class="project-image-link">
<img class="projectImage" src="/images/CloudStorageDemo.png" alt="Cloud Storage Screenshot" />
</a>
<div class="project-info">
<h3 class="project-title">Image Cloud Storage</h3>
<p class="project-description">My wife and I LOVE to take pictures and videos of our kids, but we do not like paying for extra cloud storage. I made this for my family so we can upload pictures to my server, share them with each other, and now we don't need to worry about how much space we're using.</p>
<a class="project-link" href="https://media.joshheaps.net" target="_blank">View Live Demo &rarr;</a>
</div>
</article>
</div>
</section>
<section id="DemosBox">
<h2 class="section-title">Demos</h2>
<div id="buttonWrapper">
<button id="ChessDemo" class="demoButton" onclick="window.location.href='/chess'">
Play Chess
</button>
<button id="ParticleDemo" class="demoButton" onclick="window.location.href='/particles'">
Particle Simulator
</button>
<button id="MemoryLane" class="demoButton" onclick="window.location.href='/memorylane'">
Memory Lane
</button>
<button id="CloadStorageDemo" class="demoButton" onclick="window.location.href='https://media.joshheaps.net'">
Cloud Image Storage
</button>
<button id="MoreFiller" class="demoButton">
More coming soon...
</button>
<button class="demoButton" onclick="window.location.href='/chess'">Play Chess</button>
<button class="demoButton" onclick="window.location.href='/watch'">Watch Live Chess</button>
<button class="demoButton" onclick="window.location.href='/particles'">Particle Simulator</button>
<button class="demoButton" onclick="window.location.href='/memorylane'">Memory Lane</button>
<button class="demoButton" onclick="window.location.href='/casino'">Casino Games</button>
<button class="demoButton" onclick="window.location.href='https://media.joshheaps.net'">Cloud Image Storage</button>
<button id="MoreFiller" class="demoButton">More coming soon...</button>
</div>
</div>
</section>
<section id="BlogBox">
<h2 class="section-title">Latest Posts</h2>
<div id="blogPostsList">
@foreach (var post in Model.LatestPosts)
{
<div class="blog-teaser-item">
<a class="blog-teaser-title" href="/blog/@post.Slug">@post.Title</a>
<span class="blog-teaser-date">@post.PublishedDate.ToString("MMMM d, yyyy")</span>
<p class="blog-teaser-summary">@post.Summary</p>
</div>
}
</div>
<a class="blog-view-all" href="/blog">View all posts &rarr;</a>
</section>
<footer id="Contacts">
<svg viewBox="0 0 16 16" class="social-icon">
<a id="GithubLink" class="social-link" href="https://github.com/JoshHeaps" target="_blank">
<a class="social-link" href="https://github.com/JoshHeaps" target="_blank">
<svg viewBox="0 0 16 16" class="social-icon">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"></path>
<rect width="100%" height="100%" fill="transparent" />
</a>
</svg>
<svg viewBox="0 0 24 24" class="social-icon">
<a id="LinkedinLink" class="social-link" href="https://www.linkedin.com/in/josh-heaps/" target="_blank">
</svg>
</a>
<a class="social-link" href="https://www.linkedin.com/in/josh-heaps/" target="_blank">
<svg viewBox="0 0 24 24" class="social-icon">
<path d="M20.5 2h-17A1.5 1.5 0 002 3.5v17A1.5 1.5 0 003.5 22h17a1.5 1.5 0 001.5-1.5v-17A1.5 1.5 0 0020.5 2zM8 19H5v-9h3zM6.5 8.25A1.75 1.75 0 118.3 6.5a1.78 1.78 0 01-1.8 1.75zM19 19h-3v-4.74c0-1.42-.6-1.93-1.38-1.93A1.74 1.74 0 0013 14.19a.66.66 0 000 .14V19h-3v-9h2.9v1.3a3.11 3.11 0 012.7-1.4c1.55 0 3.36.86 3.36 3.66z"></path>
<rect width="100%" height="100%" fill="transparent" />
</a>
</svg>
<svg viewBox="0 -4 31 31" class="social-icon" preserveAspectRatio="none">
<a id="GmailLink" class="social-link" href="mailto:[email protected]">
</svg>
</a>
<a class="social-link" href="mailto:[email protected]">
<svg viewBox="0 -4 31 31" class="social-icon" preserveAspectRatio="none">
<path d="m0 3.23863636v2.72727273l3.12784091 3.02727273 3.69034091 2.08636368.68181818-4.59034095-.68181818-4.27329546-1.90909091-1.43181818c-2.02329546-1.51704546-4.90909091-.07329545-4.90909091 2.45454545"/>
<path d="m23.1818182 2.21590909-.6818182 4.32954546.6818182 4.53409095 3.3494318-1.65852277 3.46875-3.45511364v-2.72727273c0-2.5278409-2.8857955-3.97159091-4.9090909-2.45454545z"/>
<path d="m2.04545455 22.6704545h4.77272727v-11.590909l-6.81818182-5.11363641v14.65909091c0 1.1301136.91534091 2.0454545 2.04545455 2.0454545"/>
<path d="m23.1818182 22.6704545h4.7727273c1.1301136 0 2.0454545-.9153409 2.0454545-2.0454545v-14.65909091l-6.8181818 5.11363641z"/>
<path d="m15 8.35227273-8.18181818-6.13636364v8.86363641l8.18181818 6.1363636 8.1818182-6.1363636v-8.86363641z"/>
<rect width="100%" height="100%" fill="transparent" />
</a>
</svg>
</svg>
</a>
</footer>
@section Styles {
<link href="/css/variables.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
<link href="/css/home/site.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
<link href="/css/home/project.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
<link href="/css/home/imageLeftProject.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
<link href="/css/home/imageRightProject.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
<link href="/css/home/blog.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
<link href="/css/home/footer.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
}
@@ -103,4 +119,4 @@
<script src="/js/site.js"></script>
<script src="/js/utils.js"></script>
<script src="/js/opacityUpdateObserver.js"></script>
}
}
+8 -3
View File
@@ -1,12 +1,17 @@
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace JoshHeaps.Net.Pages
{
public class IndexModel() : PageModel
public class IndexModel(IBlogService blogService) : PageModel
{
public void OnGet()
{
public List<BlogPost> LatestPosts { get; set; } = [];
public async Task OnGetAsync()
{
var allPosts = await blogService.GetAllPostsAsync();
LatestPosts = allPosts.Take(3).ToList();
}
}
}
+61
View File
@@ -0,0 +1,61 @@
@page
@model JoshHeaps.Net.Pages.WatchModel
@{
Layout = "_Layout";
ViewData["Title"] = "Watch Chess";
}
<div id="watchHeader">
<h1>Live Chess</h1>
<p id="watchStatus">Loading games…</p>
<div id="watchControls">
<fieldset class="enginePicker">
<legend>White</legend>
<select id="whiteEngine">
<option value="custom">Custom (v1)</option>
<option value="customLearned" selected>Custom (Learned)</option>
<option value="stockfish">Stockfish</option>
</select>
<select id="whiteSkill">
@for (int i = 1; i <= 20; i++)
{
<option value="@i" @(i == 4 ? "selected" : "")>@i</option>
}
</select>
</fieldset>
<fieldset class="enginePicker">
<legend>Black</legend>
<select id="blackEngine">
<option value="custom" selected>Custom (v1)</option>
<option value="customLearned">Custom (Learned)</option>
<option value="stockfish">Stockfish</option>
</select>
<select id="blackSkill">
@for (int i = 1; i <= 20; i++)
{
<option value="@i" @(i == 4 ? "selected" : "")>@i</option>
}
</select>
</fieldset>
<button id="startCpuVsCpu" onclick="Spectate.startCpuGame()">Watch CPU vs CPU</button>
<fieldset class="enginePicker">
<legend>Auto-train games</legend>
<input id="autoTrainCount" type="number" min="0" max="16" step="1" />
<button id="applyAutoTrain" onclick="Spectate.setAutoTrainCount()">Apply</button>
</fieldset>
</div>
<a id="backToPlay" href="/chess">← Play a game</a>
<a id="viewWeights" href="/weights">View learned weights →</a>
</div>
<div id="gamesFeed"></div>
@section Scripts {
<script src="~/js/signalr/signalr.min.js"></script>
<script src="~/js/ChessScripts/Spectate.js"></script>
}
@section Styles {
<link rel="stylesheet" href="~/css/chess/game.css?v=@ViewData["cssVersion"]" />
<link rel="stylesheet" href="~/css/chess/spectate.css?v=@ViewData["cssVersion"]" />
}
+11
View File
@@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace JoshHeaps.Net.Pages
{
public class WatchModel : PageModel
{
public void OnGet()
{
}
}
}
+44
View File
@@ -0,0 +1,44 @@
@page
@model JoshHeaps.Net.Pages.WeightsModel
@{
Layout = "_Layout";
ViewData["Title"] = "Learned Weights";
}
<div id="weightsHeader">
<h1>Learned Piece-Square Weights</h1>
<p id="weightsStatus">Where the learned engine thinks each piece belongs.</p>
<div id="weightsControls">
<div class="heatLegend">
<span>low</span>
<span class="legendBar"></span>
<span>high</span>
</div>
<button id="refreshWeights" onclick="Weights.refresh()">Refresh</button>
</div>
<a id="backToWatch" href="/watch">← Watch / train</a>
</div>
<section class="weightsSection">
<h2>Feature weights</h2>
<p class="sectionHint">Learned value of each contextual feature (per normalized unit). Mobility is per piece type; passed pawns are endgame-weighted, king safety midgame-weighted.</p>
<div id="featureWeights" class="featurePanel"></div>
</section>
<section class="weightsSection">
<h2>Midgame tables</h2>
<div id="weightsGridMg" class="weightsGrid"></div>
</section>
<section class="weightsSection">
<h2>Endgame tables</h2>
<div id="weightsGridEg" class="weightsGrid"></div>
</section>
@section Scripts {
<script src="~/js/ChessScripts/Weights.js"></script>
}
@section Styles {
<link rel="stylesheet" href="~/css/chess/weights.css?v=@ViewData["cssVersion"]" />
}
+11
View File
@@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace JoshHeaps.Net.Pages
{
public class WeightsModel : PageModel
{
public void OnGet()
{
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
@{
Layout = null;
ViewData["cssVersion"] = "1.0.2"; // <--- change this once to bust cache
ViewData["cssVersion"] = "1.0.8"; // <--- change this once to bust cache
}
<!DOCTYPE html>
+64
View File
@@ -1,6 +1,7 @@
using JoshHeaps.Net.Hubs;
using JoshHeaps.Net.Services.Implementations;
using JoshHeaps.Net.Services.Interfaces;
using Microsoft.AspNetCore.StaticFiles;
var builder = WebApplication.CreateBuilder(args);
@@ -12,12 +13,34 @@ builder.Services.AddControllers();
builder.Services.AddSignalR();
builder.Services.AddHttpClient("BlogApi", client =>
{
var baseUrl = configuration["BlogApi:BaseUrl"] ?? "https://media.joshheaps.net";
client.BaseAddress = new Uri(baseUrl);
client.Timeout = TimeSpan.FromSeconds(10);
});
builder.Services.AddSingleton<IBlogService, BlogService>();
builder.Services.AddSingleton<IChessService, ChessService>();
builder.Services.AddSingleton<IBackgroundTaskQueue, BackgroundTaskQueue>();
builder.Services.Configure<ChessEngineOptions>(configuration.GetSection(ChessEngineOptions.SectionName));
builder.Services.AddSingleton<ILearnedWeightsStore, LearnedWeightsStore>();
builder.Services.AddSingleton<IChessEngineFactory, ChessEngineFactory>();
builder.Services.AddSingleton<IComputerMoveOrchestrator, ComputerMoveOrchestrator>();
builder.Services.AddSingleton<IGameStore, GameStore>();
builder.Services.AddSingleton<ISelfPlayCoordinator, SelfPlayCoordinator>();
builder.Services.AddSingleton<AutoTrainingSettings>();
if (!builder.Environment.IsDevelopment())
{
builder.Services.AddHostedService<AutoIpUpdateService>();
// Continuously train the learned engine against Stockfish in the background. Toggle off
// via ChessEngine:AutoTrain (env ChessEngine__AutoTrain=false) without a redeploy.
if (configuration.GetValue($"{ChessEngineOptions.SectionName}:{nameof(ChessEngineOptions.AutoTrain)}", true))
builder.Services.AddHostedService<AutoTrainingService>();
}
var app = builder.Build();
// Configure the HTTP request pipeline.
@@ -30,8 +53,49 @@ if (!app.Environment.IsDevelopment())
app.UseHttpsRedirection();
// One spelling of the casino URLs, because the Hold'em tool's offline support depends
// on it and nothing warns you when it breaks.
//
// Razor Pages routing is case-insensitive, so /Casino/TexasHoldem and
// /casino/texasholdem both serve the dealer tool and look identical. A service worker's
// scope is not: the spec matches it as a plain string prefix, so a worker scoped to
// "/casino/" does not control a page at "/Casino/TexasHoldem". Measured, that meant the
// capital-C spelling registered the worker, activated it, logged nothing — and then left
// the cache completely empty, because the page was never controlled and the precache
// handshake it waited on could not resolve. A dealer who bookmarked that URL had a tool
// that looked loaded and had nothing stored for the night the signal went.
//
// So one spelling wins and the other is redirected to it, permanently, before anything
// else in the pipeline gets a look.
//
// Deliberately scoped to /casino rather than applied site-wide. Every file under
// wwwroot/casino and wwwroot/images/casino is already lowercase, so lowercasing a path
// here can never miss a real file on a case-sensitive filesystem — a guarantee that does
// not hold for the rest of the site, and one that /chessHub in particular would break.
app.Use(async (context, next) =>
{
var path = context.Request.Path.Value;
if (path is not null
&& path.StartsWith("/casino", StringComparison.OrdinalIgnoreCase)
&& path.Any(char.IsUpper))
{
context.Response.Redirect(path.ToLowerInvariant() + context.Request.QueryString, permanent: true);
return;
}
await next();
});
// .webmanifest isn't in the default MIME map, and an unknown extension isn't served
// with the wrong type — it isn't served at all. Without this the Hold'em tool's
// manifest 404s, and a 404 manifest is exactly the silent failure that stops a browser
// ever offering "Add to Home Screen": nothing is logged and no prompt appears.
var contentTypes = new FileExtensionContentTypeProvider();
contentTypes.Mappings[".webmanifest"] = "application/manifest+json";
app.UseStaticFiles(new StaticFileOptions
{
ContentTypeProvider = contentTypes,
OnPrepareResponse = ctx =>
{
ctx.Context.Response.Headers.Append("Cache-Control", "no-cache, no-store, must-revalidate");
@@ -11,6 +11,7 @@
"profiles": {
"http": {
"commandName": "Project",
"nativeDebugging": true,
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5200",
@@ -20,6 +21,7 @@
},
"https": {
"commandName": "Project",
"nativeDebugging": true,
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7118;http://localhost:5200",
Binary file not shown.
@@ -8,31 +8,23 @@ public class AutoIpUpdateService(
ILogger<AutoIpUpdateService> log)
: BackgroundService
{
private static readonly TimeSpan CheckInterval = TimeSpan.FromMinutes(5);
private static readonly HttpClient httpClient = new();
public static bool IsEnabled { get; private set; } = false;
private static readonly TimeSpan _checkInterval = TimeSpan.FromMinutes(1);
private static readonly HttpClient _httpClient = new();
protected override async Task ExecuteAsync(CancellationToken stop)
{
IsEnabled = true;
var timer = new PeriodicTimer(CheckInterval);
AAAARecord dnsRecord = await GetDnsRecordAsync();
string lastKnownIp = dnsRecord.Content;
var timer = new PeriodicTimer(_checkInterval);
while (await timer.WaitForNextTickAsync(stop))
{
try
{
string currentIp = await GetPublicIpAsync() ?? "";
if (lastKnownIp != currentIp)
{
await UpdateDnsIpAsync(config, dnsRecord, currentIp);
lastKnownIp = currentIp;
}
await UpdateIpAddressIfChanged();
}
catch (OperationCanceledException) { /* shutting down */ }
catch (OperationCanceledException) { break; }
catch (Exception ex)
{
log.LogError(ex, "Error while attempting ip update");
@@ -40,11 +32,33 @@ public class AutoIpUpdateService(
}
}
private async Task UpdateIpAddressIfChanged()
{
var dnsRecords = await GetDnsRecordAsync();
string dnsRecordIp = dnsRecords[0].Content;
if (!dnsRecords.Records.All(x => x.Content == dnsRecords[0].Content))
dnsRecordIp = string.Empty;
string publicIp = await GetPublicIpAsync() ?? string.Empty;
if (string.IsNullOrWhiteSpace(publicIp) || dnsRecordIp == publicIp)
return;
foreach (var dnsRecord in dnsRecords.Records)
{
if (dnsRecord.Content == publicIp)
continue;
await UpdateDnsIpAsync(config, dnsRecord, publicIp);
}
}
private static async Task<string> GetPublicIpAsync()
{
try
{
return await httpClient.GetStringAsync(@"https://api.ipify.org/");
return await _httpClient.GetStringAsync(@"https://api.ipify.org/");
}
catch
{
@@ -55,7 +69,7 @@ public class AutoIpUpdateService(
}
}
private async Task<AAAARecord> GetDnsRecordAsync()
private async Task<RecordList> GetDnsRecordAsync()
{
try
{
@@ -64,8 +78,8 @@ public class AutoIpUpdateService(
cfClient.DefaultRequestHeaders.Add("X-Auth-Key", config["cfKey"]);
var result = await cfClient.GetAsync(@$"https://api.cloudflare.com/client/v4/zones/{config["zoneId"]}/dns_records");
Console.WriteLine(await result.Content.ReadAsStringAsync());
var records = System.Text.Json.JsonSerializer.Deserialize<RecordList>(await result.Content.ReadAsStringAsync());
return records!.Result[0];
var records = JsonSerializer.Deserialize<RecordList>(await result.Content.ReadAsStringAsync());
return records!;
}
catch
{
@@ -76,22 +90,23 @@ public class AutoIpUpdateService(
}
}
private static async Task UpdateDnsIpAsync(IConfiguration config, AAAARecord record, string ip)
private static async Task UpdateDnsIpAsync(IConfiguration config, DnsRecord record, string ip)
{
HttpClient cfClient = new();
cfClient.DefaultRequestHeaders.Add("X-Auth-Email", config["cfEmail"]);
cfClient.DefaultRequestHeaders.Add("X-Auth-Key", config["cfKey"]);
object content = new
object updateRequestBody = new
{
comment = "Update as needed",
name = record.Name,
ttl = record.Ttl,
type = record.Type,
comment = record.Comment,
content = ip,
name = "@",
proxied = true,
ttl = 3600,
type = "AAAA"
proxied = record.Proxied,
};
var result = await cfClient.PutAsJsonAsync(@$"https://api.cloudflare.com/client/v4/zones/{config["zoneId"]}/dns_records{record.Id}", content);
var result = await cfClient.PatchAsJsonAsync(@$"https://api.cloudflare.com/client/v4/zones/{config["zoneId"]}/dns_records/{record.Id}", updateRequestBody);
if (!result.IsSuccessStatusCode)
{
@@ -102,11 +117,23 @@ public class AutoIpUpdateService(
}
}
record AAAARecord(
record DnsRecord(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("ttl")] int Ttl,
[property: JsonPropertyName("type")] string Type,
[property: JsonPropertyName("comment")] string Comment,
[property: JsonPropertyName("content")] string Content,
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("proxied")] bool Proxied,
[property: JsonPropertyName("id")] string Id);
record RecordList([property: JsonPropertyName("result")] List<AAAARecord> Result);
record RecordList([property: JsonPropertyName("result")] List<DnsRecord> Records)
{
public DnsRecord this[int index]
{
get
{
return Records[index];
}
}
}
}
@@ -0,0 +1,73 @@
using JoshHeaps.Net.Services.Interfaces;
namespace JoshHeaps.Net.Services.Implementations;
/// <summary>
/// Continuously trains the learned engine in the background by keeping a configurable number of
/// self-play games running — the learned engine (skill 6) against Stockfish (skill 20), alternating
/// which color Stockfish takes so the model trains on both. The target count is read live from
/// <see cref="AutoTrainingSettings"/> (adjustable from the website): when a game finishes another
/// starts to refill the pool, raising the count starts more, and lowering it lets the surplus drain
/// as games finish (0 pauses training). Registered only outside Development and gated by the
/// ChessEngine:AutoTrain config flag.
/// </summary>
public sealed class AutoTrainingService(
ISelfPlayCoordinator coordinator,
AutoTrainingSettings settings,
ILogger<AutoTrainingService> logger) : BackgroundService
{
private const int LearnedSkill = 6;
private const int StockfishSkill = 20;
private static readonly TimeSpan _restartBackoff = TimeSpan.FromSeconds(5);
private static readonly TimeSpan _pollInterval = TimeSpan.FromSeconds(2);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var running = new List<Task>();
int started = 0;
while (!stoppingToken.IsCancellationRequested)
{
running.RemoveAll(t => t.IsCompleted);
int desired = settings.GameCount;
bool startFailed = false;
while (running.Count < desired && !stoppingToken.IsCancellationRequested)
{
// Alternate Stockfish's color so the learned engine trains as both white and black.
var config = started++ % 2 == 0
? new SelfPlayConfig(ChessEngineKind.CustomLearned, LearnedSkill, ChessEngineKind.Stockfish, StockfishSkill)
: new SelfPlayConfig(ChessEngineKind.Stockfish, StockfishSkill, ChessEngineKind.CustomLearned, LearnedSkill);
try
{
var (_, completion) = coordinator.StartGame(config, stoppingToken);
running.Add(completion);
}
catch (Exception ex)
{
// Most likely an engine failing to start (e.g. Stockfish). Back off so a
// persistent failure doesn't spin a tight loop, then try again.
logger.LogError(ex, "Failed to start an auto-training game; retrying after backoff.");
startFailed = true;
break;
}
}
try
{
if (startFailed)
await Task.Delay(_restartBackoff, stoppingToken);
else if (running.Count > 0)
// Wake when any game finishes (to refill) or after a short poll (to pick up a
// count increase promptly).
await Task.WhenAny(Task.WhenAny(running), Task.Delay(_pollInterval, stoppingToken));
else
// Pool is empty (count is 0) — just poll for the count to change.
await Task.Delay(_pollInterval, stoppingToken);
}
catch (OperationCanceledException) { break; }
}
}
}
@@ -0,0 +1,31 @@
using Microsoft.Extensions.Options;
namespace JoshHeaps.Net.Services.Implementations;
/// <summary>
/// Runtime-adjustable auto-training settings. Singleton so the value set from the website (via the
/// chess controller) is seen live by the background <see cref="AutoTrainingService"/>. Seeded from
/// <see cref="ChessEngineOptions.AutoTrainGameCount"/> and clamped to a sane range.
/// </summary>
public sealed class AutoTrainingSettings
{
/// <summary>Upper bound on concurrent auto-training games (each spawns a Stockfish + a learned engine).</summary>
public const int MaxGames = 16;
private int _gameCount;
public AutoTrainingSettings(IOptions<ChessEngineOptions> options)
=> _gameCount = Clamp(options.Value.AutoTrainGameCount);
/// <summary>
/// Number of auto-training games to keep running concurrently. 0 pauses auto-training.
/// Reads/writes are atomic; the background service reads this every cycle.
/// </summary>
public int GameCount
{
get => Volatile.Read(ref _gameCount);
set => Volatile.Write(ref _gameCount, Clamp(value));
}
private static int Clamp(int n) => Math.Clamp(n, 0, MaxGames);
}
@@ -0,0 +1,108 @@
using System.Collections.Concurrent;
using System.Text.Json;
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces;
namespace JoshHeaps.Net.Services.Implementations;
public class BlogService : IBlogService
{
private readonly HttpClient _httpClient;
private readonly ILogger<BlogService> _logger;
private readonly TimeSpan _cacheTtl = TimeSpan.FromMinutes(5);
private readonly TimeSpan _staleGrace = TimeSpan.FromHours(1);
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new();
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
public BlogService(IHttpClientFactory httpClientFactory, ILogger<BlogService> logger)
{
_httpClient = httpClientFactory.CreateClient("BlogApi");
_logger = logger;
}
public async Task<List<BlogPost>> GetAllPostsAsync()
{
return await GetCachedAsync("all_posts",
() => FetchAsync<List<BlogPost>>("api/blog/posts")) ?? [];
}
public async Task<BlogPost?> GetPostBySlugAsync(string slug)
{
return await GetCachedAsync($"post_{slug}",
() => FetchAsync<BlogPost>($"api/blog/posts/{Uri.EscapeDataString(slug)}"));
}
public async Task<List<BlogPost>> GetPostsByTagAsync(string tag)
{
return await GetCachedAsync($"tag_{tag}",
() => FetchAsync<List<BlogPost>>($"api/blog/posts/tags/{Uri.EscapeDataString(tag)}")) ?? [];
}
private async Task<T?> FetchAsync<T>(string path) where T : class
{
try
{
var response = await _httpClient.GetAsync(path);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("Blog API returned {StatusCode} for {Path}", response.StatusCode, path);
return null;
}
var json = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<T>(json, JsonOptions);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to fetch from blog API: {Path}", path);
return null;
}
}
private async Task<T?> GetCachedAsync<T>(string key, Func<Task<T?>> factory) where T : class
{
_cache.TryGetValue(key, out var entry);
if (entry is not null && entry.ExpiresAt > DateTime.UtcNow)
return (T?)entry.Value;
var result = await factory();
if (result is not null)
{
_cache[key] = new CacheEntry(result, DateTime.UtcNow.Add(_cacheTtl));
return result;
}
return entry is null ? null : ServeStale<T>(key, entry);
}
// Stale entries are dropped once the grace window closes so that a dead API fails the same
// way for every key. Serving them indefinitely let a cached post list render next to 404s on
// the posts themselves, which reads as a site bug rather than an outage.
private T? ServeStale<T>(string key, CacheEntry entry) where T : class
{
if (entry.ExpiresAt.Add(_staleGrace) > DateTime.UtcNow)
{
_logger.LogWarning("Blog API unreachable, serving stale cache for key {Key}", key);
return (T?)entry.Value;
}
_cache.TryRemove(key, out _);
_logger.LogError("Blog API unreachable for over {StaleGrace}, dropping stale cache for key {Key}", _staleGrace, key);
return null;
}
public void ClearCache()
{
_cache.Clear();
_logger.LogInformation("Blog cache cleared");
}
private record CacheEntry(object Value, DateTime ExpiresAt);
}
@@ -0,0 +1,64 @@
using JoshHeaps.Net.Services.Interfaces;
using Microsoft.Extensions.Options;
namespace JoshHeaps.Net.Services.Implementations;
/// <summary>The available chess engine implementations.</summary>
public enum ChessEngineKind
{
Stockfish,
Custom,
/// <summary>The custom engine with the reinforcement-learned piece-square evaluation.</summary>
CustomLearned
}
/// <summary>Configuration selecting which <see cref="IChessEngine"/> to use.</summary>
public sealed class ChessEngineOptions
{
public const string SectionName = "ChessEngine";
public ChessEngineKind Engine { get; set; } = ChessEngineKind.Stockfish;
/// <summary>
/// Absolute path to the learned-weights file. Leave null to default to
/// <c>{ContentRoot}/chess-data/learned-weights.txt</c> (fine for local dev). In
/// production set this to a stable, service-writable location OUTSIDE the deploy
/// directory (e.g. <c>/var/lib/joshheaps/chess-data/learned-weights.txt</c>) so the
/// trained weights survive deploys and avoid deploy-user vs service-user permission
/// clashes. Override via the <c>ChessEngine__WeightsPath</c> environment variable.
/// </summary>
public string? WeightsPath { get; set; }
/// <summary>
/// When true (and outside Development), a background service continuously plays the learned
/// engine against Stockfish to train it. Set to false to stop auto-training without a
/// redeploy. Override via the <c>ChessEngine__AutoTrain</c> environment variable.
/// </summary>
public bool AutoTrain { get; set; } = true;
/// <summary>
/// How many auto-training games run concurrently (when <see cref="AutoTrain"/> is on). This is
/// the starting value; it can be changed at runtime from the website. Override the default via
/// the <c>ChessEngine__AutoTrainGameCount</c> environment variable.
/// </summary>
public int AutoTrainGameCount { get; set; } = 2;
}
/// <summary>Creates the configured <see cref="IChessEngine"/> per game.</summary>
public sealed class ChessEngineFactory(
IOptions<ChessEngineOptions> options,
ILearnedWeightsStore weightsStore) : IChessEngineFactory
{
private readonly ChessEngineKind _kind = options.Value.Engine;
public IChessEngine Create(int skill) => Create(skill, _kind);
public IChessEngine Create(int skill, ChessEngineKind kind) => kind switch
{
ChessEngineKind.Custom => new CustomChessEngine(skill),
ChessEngineKind.CustomLearned => new CustomChessEngine(skill, EngineVariant.Learned, weightsStore.WeightsFilePath),
ChessEngineKind.Stockfish => new Stockfish(skill),
_ => throw new InvalidOperationException($"Unknown chess engine '{kind}'.")
};
}
@@ -0,0 +1,153 @@
using JoshHeaps.Net.Models;
using System.Text;
namespace JoshHeaps.Net.Services.Implementations;
public static class ChessEngineHelpers
{
public static MoveDto ToMoveDto(
this string uci,
GameState gameState,
Guid playerId)
{
int fCol = uci[0] - 'a', fRow = 7 - (uci[1] - '1');
int tCol = uci[2] - 'a', tRow = 7 - (uci[3] - '1');
var piece = gameState.Board[fRow, fCol]
?? throw new Exception("No piece at source square");
PieceType? promo = uci.Length == 5 ? uci[4] switch
{
'q' => PieceType.Queen,
'r' => PieceType.Rook,
'b' => PieceType.Bishop,
'n' => PieceType.Knight,
_ => null
} : null;
return new MoveDto
{
GameId = gameState.GameId,
PlayerId = playerId,
PieceId = piece.Id,
TargetRow = tRow,
TargetCol = tCol,
PromotionChoice = promo,
SourceCol = fCol,
SourceRow = fRow,
};
}
/// <summary>
/// Convert a 2-D board array (rank 8 = row 0, file a = col 0) to a FEN string.
/// Only piece placement + active colour + castling are computed; the rest use
/// safe defaults (-, 0, 1). That is all the engine needs.
/// </summary>
public static string ToFen(this GameState gs)
{
var sb = new StringBuilder(64);
/* 1) piece placement */
for (int row = 0; row < 8; row++)
{
int empty = 0;
for (int col = 0; col < 8; col++)
{
var p = gs.Board[row, col];
if (p is null)
{
empty++;
}
else
{
if (empty > 0) { sb.Append(empty); empty = 0; }
sb.Append(ToFenChar(p)); // ← unchanged helper
}
}
if (empty > 0) sb.Append(empty);
if (row < 7) sb.Append('/');
}
/* 2) active colour */
sb.Append(gs.CurrentPlayer == PieceColor.White ? " w " : " b ");
/* 3) castling rights (from GameState flags) */
sb.Append(GetCastlingFlags(gs));
/* 4) en-passant target square */
sb.Append(' ');
sb.Append(gs.EnPassantTarget.HasValue
? Alg(gs.EnPassantTarget.Value)
: "-");
/* 5-6) half-move clock + full-move number */
int fullMoves = gs.MoveHistory.Count / 2 + 1;
sb.Append(' ').Append(gs.HalfmoveClock).Append(' ').Append(fullMoves);
return sb.ToString();
}
/// <summary>
/// The prior positions since the last irreversible move (capture/pawn move), as
/// completed FEN strings, oldest first and excluding the current position. This is
/// the repetition window the engine needs to detect threefold/50-move draws that a
/// single FEN can't express.
/// </summary>
public static IReadOnlyList<string> RepetitionHistory(this GameState gs)
{
int clock = gs.HalfmoveClock;
int count = gs.PositionHistory.Count;
int start = count - 1 - clock; // PositionHistory ends with the current position
if (clock <= 0 || start < 0)
return Array.Empty<string>();
var fens = new List<string>(clock);
for (int i = start; i < count - 1; i++)
fens.Add(gs.PositionHistory[i] + " 0 1"); // complete the 4-field key into a parseable FEN
return fens;
}
/* ---------- helpers ---------- */
private static string GetCastlingFlags(GameState gs)
{
var flags = new StringBuilder(4);
if (gs.WhiteCanCastleKingside) flags.Append('K');
if (gs.WhiteCanCastleQueenside) flags.Append('Q');
if (gs.BlackCanCastleKingside) flags.Append('k');
if (gs.BlackCanCastleQueenside) flags.Append('q');
return flags.Length == 0 ? "-" : flags.ToString();
}
private static string Alg(Position p)
{
char file = (char)('a' + p.Col);
int rank = 8 - p.Row;
return $"{file}{rank}";
}
private static char ToFenChar(ChessPiece p) => p switch
{
{ Type: PieceType.Pawn, Color: PieceColor.White } => 'P',
{ Type: PieceType.Pawn, Color: PieceColor.Black } => 'p',
{ Type: PieceType.Knight, Color: PieceColor.White } => 'N',
{ Type: PieceType.Knight, Color: PieceColor.Black } => 'n',
{ Type: PieceType.Bishop, Color: PieceColor.White } => 'B',
{ Type: PieceType.Bishop, Color: PieceColor.Black } => 'b',
{ Type: PieceType.Rook, Color: PieceColor.White } => 'R',
{ Type: PieceType.Rook, Color: PieceColor.Black } => 'r',
{ Type: PieceType.Queen, Color: PieceColor.White } => 'Q',
{ Type: PieceType.Queen, Color: PieceColor.Black } => 'q',
{ Type: PieceType.King, Color: PieceColor.White } => 'K',
{ Type: PieceType.King, Color: PieceColor.Black } => 'k',
_ => throw new ArgumentOutOfRangeException(nameof(p))
};
}
@@ -22,12 +22,14 @@ public class ChessService : IChessService
gameState.Pieces.Clear();
gameState.MoveHistory.Clear();
gameState.SanHistory.Clear();
gameState.WhiteCanCastleKingside = true;
gameState.WhiteCanCastleQueenside = true;
gameState.BlackCanCastleKingside = true;
gameState.BlackCanCastleQueenside = true;
gameState.EnPassantTarget = null;
gameState.HalfmoveClock = 0;
SetupBlackPieces(gameState);
SetupWhitePieces(gameState);
@@ -35,6 +37,9 @@ public class ChessService : IChessService
gameState.CurrentPlayer = PieceColor.White;
UpdateCheckStatus(gameState);
gameState.PositionHistory.Clear();
gameState.PositionHistory.Add(PositionKey(gameState));
}
private static void SetupBlackPieces(GameState gs)
@@ -110,7 +115,6 @@ public class ChessService : IChessService
var piece = gameState.Pieces.FirstOrDefault(p => p.Id == pieceId);
if (piece == null) return [];
if (piece.Color != gameState.CurrentPlayer) return [];
var candidateMoves = GenerateCandidateMoves(gameState, piece);
var legalMoves = new List<Position>();
@@ -138,12 +142,23 @@ public class ChessService : IChessService
if (!legalMoves.Any(m => m.Row == moveDto.TargetRow && m.Col == moveDto.TargetCol))
return new MoveResultDto { Success = false, Message = "Illegal move." };
// SAN is built before the move (needs the pre-move board for captures/disambiguation);
// the check/mate suffix is appended after UpdateCheckStatus.
var sanBase = BuildSan(gameState, piece, targetPos, moveDto);
PerformMove(gameState, piece, targetPos, moveDto);
UpdateCheckStatus(gameState);
var notation = $"{piece.Id}:{piece.Position}->{targetPos}";
gameState.MoveHistory.Add(notation);
gameState.SanHistory.Add(sanBase + (gameState.IsCheckmate ? "#" : gameState.IsCheck ? "+" : ""));
var positionKey = PositionKey(gameState);
gameState.PositionHistory.Add(positionKey);
if (gameState.PositionHistory.Count(k => k == positionKey) >= 3)
gameState.IsThreefoldRepetition = true;
return new MoveResultDto
{
@@ -151,14 +166,95 @@ public class ChessService : IChessService
Message = "Move successful.",
IsCheck = gameState.IsCheck,
IsCheckmate = gameState.IsCheckmate,
IsStalemate = gameState.IsStalemate
IsStalemate = gameState.IsStalemate,
IsThreefoldRepetition = gameState.IsThreefoldRepetition
};
}
/// <summary>
/// Build the standard-algebraic notation for a move from the position BEFORE it is
/// applied (the check/mate suffix is added by the caller afterward).
/// </summary>
private string BuildSan(GameState gs, ChessPiece piece, Position target, MoveDto moveDto)
{
var from = piece.Position;
if (piece.Type == PieceType.King && Math.Abs(target.Col - from.Col) == 2)
return target.Col > from.Col ? "O-O" : "O-O-O";
bool targetOccupied = gs.Board[target.Row, target.Col] != null;
bool isEnPassant = piece.Type == PieceType.Pawn && from.Col != target.Col && !targetOccupied;
bool isCapture = targetOccupied || isEnPassant;
string dest = SquareName(target);
if (piece.Type == PieceType.Pawn)
{
var san = isCapture ? $"{FileChar(from.Col)}x{dest}" : dest;
bool promotes = (piece.Color == PieceColor.White && target.Row == 0)
|| (piece.Color == PieceColor.Black && target.Row == 7);
if (promotes)
san += "=" + PieceLetter(moveDto.PromotionChoice ?? PieceType.Queen);
return san;
}
return $"{PieceLetter(piece.Type)}{Disambiguation(gs, piece, target)}{(isCapture ? "x" : "")}{dest}";
}
/// <summary>
/// SAN disambiguation: when another piece of the same type and color can also reach the
/// target, qualify the origin by file, else rank, else both.
/// </summary>
private string Disambiguation(GameState gs, ChessPiece piece, Position target)
{
var rivals = gs.Pieces
.Where(p => p.Id != piece.Id && p.Type == piece.Type && p.Color == piece.Color && p.Position.Row >= 0)
.Where(p => GetLegalMovesForPiece(gs, p.Id).Any(m => m.Row == target.Row && m.Col == target.Col))
.ToList();
if (rivals.Count == 0)
return "";
if (rivals.All(p => p.Position.Col != piece.Position.Col))
return FileChar(piece.Position.Col).ToString();
if (rivals.All(p => p.Position.Row != piece.Position.Row))
return (8 - piece.Position.Row).ToString();
return $"{FileChar(piece.Position.Col)}{8 - piece.Position.Row}";
}
private static char FileChar(int col) => (char)('a' + col);
private static string SquareName(Position p) => $"{FileChar(p.Col)}{8 - p.Row}";
private static string PieceLetter(PieceType type) => type switch
{
PieceType.Knight => "N",
PieceType.Bishop => "B",
PieceType.Rook => "R",
PieceType.Queen => "Q",
PieceType.King => "K",
_ => ""
};
/// <summary>
/// The repetition signature of a position: the first four FEN fields — piece placement,
/// side to move, castling rights, and en-passant target. Move counters are excluded.
/// </summary>
private static string PositionKey(GameState gs)
{
var fields = gs.ToFen().Split(' ');
return string.Join(' ', fields.Take(4));
}
private static void PerformMove(GameState gs, ChessPiece piece, Position targetPos, MoveDto moveDto)
{
var oldPos = piece.Position;
var captured = gs.Board[targetPos.Row, targetPos.Col];
var isPawnMove = piece.Type == PieceType.Pawn; // captured before promotion can change Type
HandleEnPassantIfNeeded(gs, piece, targetPos, ref captured);
@@ -180,6 +276,9 @@ public class ChessService : IChessService
UpdateCastlingRights(gs, piece, oldPos);
// Reset the 50-move clock on captures and pawn moves (irreversible); else advance it.
gs.HalfmoveClock = (isPawnMove || captured != null) ? 0 : gs.HalfmoveClock + 1;
gs.CurrentPlayer = gs.CurrentPlayer == PieceColor.White
? PieceColor.Black
: PieceColor.White;
@@ -529,7 +628,14 @@ public class ChessService : IChessService
var ep = gs.EnPassantTarget.Value;
if (ep.Row == forward1 && Math.Abs(ep.Col - startCol) == 1)
moves.Add(ep);
{
// Verify there's an enemy pawn to capture
int enemyPawnRow = piece.Color == PieceColor.White ? ep.Row + 1 : ep.Row - 1;
var enemyPawn = gs.Board[enemyPawnRow, ep.Col];
if (enemyPawn != null && enemyPawn.Type == PieceType.Pawn && enemyPawn.Color != piece.Color)
moves.Add(ep);
}
}
return moves;
@@ -542,11 +648,7 @@ public class ChessService : IChessService
=> GenerateSlidingMoves(gs, piece, [(1, 1), (1, -1), (-1, 1), (-1, -1)]);
private static List<Position> GenerateQueenMoves(GameState gs, ChessPiece piece)
=> GenerateSlidingMoves(gs, piece,
[
(1, 0), (-1, 0), (0, 1), (0, -1),
(1, 1), (1, -1), (-1, 1), (-1, -1)
]);
=> [..GenerateRookMoves(gs, piece), ..GenerateBishopMoves(gs, piece)];
private static List<Position> GenerateSlidingMoves(GameState gs, ChessPiece piece, (int dr, int dc)[] directions)
{
@@ -0,0 +1,70 @@
using JoshHeaps.Net.Hubs;
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces;
using Microsoft.AspNetCore.SignalR;
namespace JoshHeaps.Net.Services.Implementations;
/// <summary>
/// Engine-agnostic glue for computer moves: get a move from the engine, apply it
/// through the rules service, and broadcast it to the game's clients.
/// </summary>
public sealed class ComputerMoveOrchestrator(
IHubContext<ChessHub> chessHub,
IChessService chessService,
ILearnedWeightsStore weightsStore) : IComputerMoveOrchestrator
{
public async Task<(MoveDto move, MoveResultDto result)> PlayAsync(GameState state)
{
var engine = (state.CurrentPlayer == PieceColor.White ? state.WhiteComputer : state.BlackComputer)
?? throw new InvalidOperationException($"No engine is set for {state.CurrentPlayer} in game {state.GameId}.");
var uci = await engine.GetBestMoveAsync(state.ToFen(), state.RepetitionHistory());
var move = uci.ToMoveDto(state, CurrentPlayerId(state));
return await ApplyAndBroadcastAsync(state, move);
}
public async Task PlayRandomMoveAsync(GameState state)
{
var options = chessService.GetAllLegalMoves(state);
// No legal moves means the game is already over; let the caller's loop detect it.
if (options.Count == 0)
return;
var (piece, moves) = options[Random.Shared.Next(options.Count)];
var target = moves[Random.Shared.Next(moves.Count)];
var move = new MoveDto
{
GameId = state.GameId,
PlayerId = CurrentPlayerId(state),
PieceId = piece.Id,
SourceRow = piece.Position.Row,
SourceCol = piece.Position.Col,
TargetRow = target.Row,
TargetCol = target.Col,
PromotionChoice = null // a pawn cannot reach the last rank within the opening plies
};
await ApplyAndBroadcastAsync(state, move);
}
private async Task<(MoveDto move, MoveResultDto result)> ApplyAndBroadcastAsync(GameState state, MoveDto move)
{
var result = chessService.MakeMove(state, move);
// Record the played position for training (no-op for non-training games).
if (state.Trainer != nint.Zero)
weightsStore.Record(state.Trainer, state.ToFen());
await chessHub.Clients.Group(state.GameId.ToString())
.SendAsync("ReceiveMoveUpdate", state.GameId.ToString(), move, result, state.ToDto());
return (move, result);
}
private static Guid CurrentPlayerId(GameState state) =>
state.CurrentPlayer == PieceColor.White ? state.WhitePlayerId : state.BlackPlayerId;
}
@@ -0,0 +1,162 @@
using JoshHeaps.Net.Services.Interfaces;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace JoshHeaps.Net.Services.Implementations;
/// <summary>Which evaluation the native engine uses.</summary>
public enum EngineVariant
{
/// <summary>The hand-crafted evaluation.</summary>
Classic,
/// <summary>Material plus a learned per-square bonus table loaded from a weights file.</summary>
Learned
}
/// <summary>
/// Middleman wrapper over the native custom chess engine (chess_engine.dll / libchess_engine.so).
/// Shares <see cref="IChessEngine"/> with <see cref="Stockfish"/> so the two are swappable.
/// The boundary contract is FEN string in, UCI move string out — identical to Stockfish.
/// </summary>
public sealed partial class CustomChessEngine : IChessEngine
{
private readonly EngineSafeHandle _handle;
public int Skill { get; }
public CustomChessEngine(int skill = 20, EngineVariant variant = EngineVariant.Classic, string? weightsPath = null)
{
Skill = skill;
// weights= must come last: the native side reads the path as the rest of the
// string, which lets it contain ';' and spaces.
var options = variant == EngineVariant.Learned
? $"skill={skill};variant=learned;weights={weightsPath}"
: $"skill={skill}";
var handle = NativeMethods.engine_create(options);
if (handle == IntPtr.Zero)
throw new InvalidOperationException("Native chess engine failed to initialize (engine_create returned null).");
_handle = new EngineSafeHandle(handle);
}
public Task<string> GetBestMoveAsync(string fen, IReadOnlyList<string> historyFens) =>
Task.Run(() => GetBestMove(fen, string.Join('\n', historyFens)));
private unsafe string GetBestMove(string fen, string history)
{
const int bufferLength = 16; // longest UCI move is 5 chars ("e7e8q") + NUL
byte* buffer = stackalloc byte[bufferLength];
var added = false;
try
{
_handle.DangerousAddRef(ref added);
var code = NativeMethods.engine_best_move(_handle.DangerousGetHandle(), fen, history, buffer, bufferLength);
if (code != 0)
throw new InvalidOperationException($"Native chess engine failed to produce a move for FEN '{fen}' (engine_best_move returned {code}).");
return Marshal.PtrToStringUTF8((IntPtr)buffer)
?? throw new InvalidOperationException("Native chess engine returned an empty move.");
}
finally
{
if (added)
_handle.DangerousRelease();
}
}
public ValueTask DisposeAsync()
{
_handle.Dispose();
return ValueTask.CompletedTask;
}
/// <summary>Guarantees the native handle is released exactly once via engine_destroy.</summary>
private sealed class EngineSafeHandle : SafeHandle
{
public EngineSafeHandle(IntPtr handle) : base(IntPtr.Zero, ownsHandle: true) => SetHandle(handle);
public override bool IsInvalid => handle == IntPtr.Zero;
protected override bool ReleaseHandle()
{
NativeMethods.engine_destroy(handle);
return true;
}
}
/// <summary>
/// P/Invoke surface for chess_engine.(dll|so). The resolver maps the logical name
/// "chess_engine" to the platform binary in the Resources folder (mirrors Stockfish).
/// Internal so <see cref="LearnedWeightsStore"/> can share the single import resolver.
/// </summary>
internal static partial class NativeMethods
{
private const string LibName = "chess_engine";
static NativeMethods() =>
NativeLibrary.SetDllImportResolver(typeof(NativeMethods).Assembly, Resolve);
private static IntPtr Resolve(string libraryName, Assembly assembly, DllImportSearchPath? searchPath)
{
if (libraryName != LibName)
return IntPtr.Zero;
var fileName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? "chess_engine.dll"
: "libchess_engine.so";
var path = Path.Combine(AppContext.BaseDirectory, "Resources", fileName);
if (NativeLibrary.TryLoad(path, out var handle))
return handle;
return NativeLibrary.Load(libraryName, assembly, searchPath);
}
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static partial IntPtr engine_create(string? options);
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static unsafe partial int engine_best_move(IntPtr engine, string fen, string history, byte* outBuffer, int outLength);
[LibraryImport(LibName)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static partial void engine_destroy(IntPtr engine);
// ---- Learned-weights / training ABI (see chess_engine.h) ----
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static partial void learned_load(string path);
[LibraryImport(LibName)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static unsafe partial int weights_snapshot(int* outBuf, int outLen);
[LibraryImport(LibName)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static partial IntPtr trainer_create();
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static partial void trainer_record(IntPtr trainer, string fen);
[LibraryImport(LibName)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static partial void trainer_apply(IntPtr trainer, int winner, double weight);
[LibraryImport(LibName)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static partial void trainer_destroy(IntPtr trainer);
}
}
@@ -0,0 +1,69 @@
using System.Collections.Concurrent;
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces;
namespace JoshHeaps.Net.Services.Implementations;
/// <summary>
/// In-memory game registry with a delayed-removal lifecycle. Singleton: the game state is
/// process-wide, not per-request, so it lives in a service rather than static controller fields.
/// </summary>
public sealed class GameStore(ILearnedWeightsStore weightsStore) : IGameStore
{
private readonly ConcurrentDictionary<Guid, GameState> _games = [];
private readonly ConcurrentDictionary<Guid, Task> _removalTasks = [];
private readonly ConcurrentDictionary<Guid, CancellationTokenSource> _removalCts = [];
public void Add(GameState game) => _games[game.GameId] = game;
public bool TryGet(Guid id, out GameState game) => _games.TryGetValue(id, out game!);
public bool Contains(Guid id) => _games.ContainsKey(id);
public IReadOnlyCollection<GameState> All => [.. _games.Values];
public void ScheduleRemove(Guid id, TimeSpan delay)
{
if (_removalCts.TryRemove(id, out var oldCts))
{
oldCts.Cancel();
oldCts.Dispose();
}
var cts = new CancellationTokenSource();
_removalCts[id] = cts;
_removalTasks[id] = Task.Run(async () =>
{
try
{
await Task.Delay(delay, cts.Token);
if (_games.TryGetValue(id, out var game))
{
if (game.WhiteComputer is not null)
await game.WhiteComputer.DisposeAsync();
if (game.BlackComputer is not null)
await game.BlackComputer.DisposeAsync();
// Free the trainer if the game never reached ApplyLearning (e.g. timed out).
if (game.Trainer != nint.Zero)
{
weightsStore.DestroyTrainer(game.Trainer);
game.Trainer = nint.Zero;
}
}
_games.Remove(id, out _);
}
catch (OperationCanceledException) { }
finally
{
if (_removalCts.TryGetValue(id, out var currentCts) && currentCts == cts)
_removalCts.TryRemove(id, out _);
cts.Dispose();
}
});
}
}
@@ -0,0 +1,84 @@
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces;
using Microsoft.Extensions.Options;
namespace JoshHeaps.Net.Services.Implementations;
/// <summary>
/// Managed facade over the native learned-weights model (see <see cref="ILearnedWeightsStore"/>).
/// On construction it points the native engine at the weights file; everything else delegates
/// to the shared native ABI in <see cref="CustomChessEngine"/>.
/// </summary>
public sealed class LearnedWeightsStore : ILearnedWeightsStore
{
private const int Pieces = 6; // Pawn..King
private const int Squares = 64;
private const int Features = 8; // mobility N/B/R/Q, passed, isolated, doubled, king safety
public string WeightsFilePath { get; }
public LearnedWeightsStore(IHostEnvironment env, IOptions<ChessEngineOptions> options, ILogger<LearnedWeightsStore> logger)
{
// Prefer the configured path (production points this outside the deploy dir); fall
// back to the content root for local dev.
var configured = options.Value.WeightsPath;
WeightsFilePath = string.IsNullOrWhiteSpace(configured)
? Path.Combine(env.ContentRootPath, "chess-data", "learned-weights.txt")
: configured;
// A missing/unwritable/misconfigured path must not take down the whole app — the
// learned engine just plays from a neutral table and can't persist training.
try
{
Directory.CreateDirectory(Path.GetDirectoryName(WeightsFilePath)!);
CustomChessEngine.NativeMethods.learned_load(WeightsFilePath);
}
catch (Exception ex)
{
logger.LogError(ex,
"Could not initialize the learned-weights store at {Path}. The learned engine will " +
"play from a neutral table and training will not persist. In production set " +
"ChessEngine:WeightsPath (env ChessEngine__WeightsPath) to a service-writable directory.",
WeightsFilePath);
}
}
public nint CreateTrainer() => CustomChessEngine.NativeMethods.trainer_create();
public void Record(nint trainer, string fen) =>
CustomChessEngine.NativeMethods.trainer_record(trainer, fen);
public void ApplyResult(nint trainer, PieceColor winner, double weight) =>
CustomChessEngine.NativeMethods.trainer_apply(trainer, winner == PieceColor.White ? 0 : 1, weight);
public void DestroyTrainer(nint trainer) =>
CustomChessEngine.NativeMethods.trainer_destroy(trainer);
public LearnedWeightsSnapshot Snapshot()
{
const int total = Pieces * Squares * 2 + Features;
var buffer = new int[total];
unsafe
{
fixed (int* p = buffer)
CustomChessEngine.NativeMethods.weights_snapshot(p, total);
}
var mg = new int[Pieces][];
var eg = new int[Pieces][];
for (int piece = 0; piece < Pieces; piece++)
{
mg[piece] = new int[Squares];
eg[piece] = new int[Squares];
Array.Copy(buffer, piece * Squares, mg[piece], 0, Squares);
Array.Copy(buffer, Pieces * Squares + piece * Squares, eg[piece], 0, Squares);
}
var features = new int[Features];
Array.Copy(buffer, Pieces * Squares * 2, features, 0, Features);
return new LearnedWeightsSnapshot(mg, eg, features);
}
}
@@ -0,0 +1,50 @@
using JoshHeaps.Net.Models;
using System.Text;
namespace JoshHeaps.Net.Services.Implementations;
public static class PgnExporter
{
/// <summary>
/// Render a game as PGN: the standard seven-tag header plus the SAN movetext and result.
/// </summary>
public static string ToPgn(this GameState gs)
{
var result = ResultTag(gs);
var name = gs.IsComputerVsComputer ? "Computer" : null;
var sb = new StringBuilder();
sb.AppendLine("[Event \"JoshHeaps.Net Chess\"]");
sb.AppendLine("[Site \"joshheaps.net\"]");
sb.AppendLine($"[Date \"{DateTime.Now:yyyy.MM.dd}\"]");
sb.AppendLine($"[White \"{name ?? "White"}\"]");
sb.AppendLine($"[Black \"{name ?? "Black"}\"]");
sb.AppendLine($"[Result \"{result}\"]");
sb.AppendLine();
for (int i = 0; i < gs.SanHistory.Count; i++)
{
if (i % 2 == 0)
sb.Append(i / 2 + 1).Append(". ");
sb.Append(gs.SanHistory[i]).Append(' ');
}
sb.Append(result);
return sb.ToString();
}
private static string ResultTag(GameState gs)
{
if (gs.IsCheckmate)
return gs.CurrentPlayer == PieceColor.White ? "0-1" : "1-0";
if (gs.IsStalemate || gs.IsThreefoldRepetition)
return "1/2-1/2";
if (gs.IsForfeited)
return gs.Winner == PieceColor.White ? "1-0" : "0-1";
return "*";
}
}
@@ -0,0 +1,257 @@
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces;
namespace JoshHeaps.Net.Services.Implementations;
/// <summary>
/// Runs CPU-vs-CPU games: builds the game and engines, plays a randomized opening (for training
/// variety), drives the move loop to completion, then trains the learned engine from the result.
/// </summary>
public sealed class SelfPlayCoordinator(
IChessService chessService,
IChessEngineFactory engineFactory,
IComputerMoveOrchestrator orchestrator,
ILearnedWeightsStore weightsStore,
IGameStore gameStore) : ISelfPlayCoordinator
{
private static readonly TimeSpan _computerGameTimeout = TimeSpan.FromHours(1);
private static readonly TimeSpan _selfPlayMoveDelay = TimeSpan.FromSeconds(1);
private static readonly TimeSpan _selfPlayResultTimeout = TimeSpan.FromSeconds(30);
// Abandon a game that goes this long without a move being played — i.e. an engine (usually
// Stockfish) that crashed or froze. The game is killed with no result recorded; if it was an
// auto-training game the trainer schedules a replacement once this one's task completes.
private static readonly TimeSpan _idleTimeout = TimeSpan.FromSeconds(60);
// Plies of random legal moves at the start of a training game, so self-play and
// engine-vs-engine games explore different lines instead of replaying one game.
private const int _openingRandomPlies = 4;
public (Guid GameId, Task Completion) StartGame(SelfPlayConfig config, CancellationToken cancellationToken = default)
{
var whiteComputer = engineFactory.Create(config.WhiteSkill, config.WhiteKind);
IChessEngine blackComputer;
try
{
blackComputer = engineFactory.Create(config.BlackSkill, config.BlackKind);
}
catch
{
// Don't leak the first engine if the second fails to start (e.g. Stockfish process).
whiteComputer.DisposeAsync().AsTask().GetAwaiter().GetResult();
throw;
}
var gameState = chessService.CreateNewGame();
gameState.IsVsComputer = true;
gameState.IsComputerVsComputer = true;
gameState.WhiteJoined = true;
gameState.BlackJoined = true;
gameState.WhitePlayerId = Guid.NewGuid();
gameState.BlackPlayerId = Guid.NewGuid();
gameState.WhiteEngineKind = config.WhiteKind;
gameState.BlackEngineKind = config.BlackKind;
gameState.WhiteComputer = whiteComputer;
gameState.BlackComputer = blackComputer;
// When the learned engine is playing, attach a trainer so the outcome can train it.
if (config.WhiteKind == ChessEngineKind.CustomLearned || config.BlackKind == ChessEngineKind.CustomLearned)
gameState.Trainer = weightsStore.CreateTrainer();
gameStore.Add(gameState);
gameStore.ScheduleRemove(gameState.GameId, _computerGameTimeout);
var completion = Task.Run(() => RunAsync(gameState, cancellationToken));
return (gameState.GameId, completion);
}
/// <summary>
/// Drives the game to completion, then trains from it. Never throws — a failure (or a frozen
/// engine) just ends the game and the trainer is always freed, so callers can await or ignore.
/// </summary>
private async Task RunAsync(GameState gameState, CancellationToken cancellationToken)
{
bool aborted = false;
try
{
// Give spectators a moment to join the SignalR group before the first move.
await Task.Delay(_selfPlayMoveDelay, cancellationToken);
// Training games open with random moves so they don't replay the same line.
if (gameState.Trainer != nint.Zero)
for (int i = 0; i < _openingRandomPlies && IsLive(gameState, cancellationToken); i++)
{
await orchestrator.PlayRandomMoveAsync(gameState);
await Task.Delay(_selfPlayMoveDelay, cancellationToken);
}
var lastMoveCount = gameState.MoveHistory.Count;
var lastProgress = DateTime.UtcNow;
while (IsLive(gameState, cancellationToken))
{
await PlayMoveWithTimeoutAsync(gameState, cancellationToken);
// Watchdog: abandon the game if it stops making moves (a crashed or frozen engine
// can leave PlayAsync returning without progressing). Reset the clock on a real
// move; otherwise bail once nothing has happened for the idle timeout.
if (gameState.MoveHistory.Count != lastMoveCount)
{
lastMoveCount = gameState.MoveHistory.Count;
lastProgress = DateTime.UtcNow;
}
else if (DateTime.UtcNow - lastProgress > _idleTimeout)
throw new TimeoutException("no move played within the idle timeout");
await Task.Delay(_selfPlayMoveDelay, cancellationToken);
}
}
catch (OperationCanceledException) { aborted = true; /* service shutting down */ }
catch (TimeoutException)
{
aborted = true;
Console.WriteLine($"Self-play game {gameState.GameId} abandoned: no move for over " +
$"{_idleTimeout.TotalSeconds:n0}s (likely a crashed or frozen engine).");
}
catch (Exception ex)
{
aborted = true;
Console.WriteLine($"Self-play game {gameState.GameId} stopped: {ex.Message}");
}
// A clean finish trains the learned engine; an abandoned game (cancelled, crashed, or
// idle past the timeout) records nothing and just frees its trainer.
if (aborted)
DiscardTraining(gameState);
else
ApplyLearning(gameState);
// A clean finish lingers briefly so spectators see the result; an abandoned game is torn
// down immediately so its engines (and any crashed/frozen Stockfish process) are released.
if (gameStore.Contains(gameState.GameId))
gameStore.ScheduleRemove(gameState.GameId, aborted ? TimeSpan.Zero : _selfPlayResultTimeout);
}
/// <summary>
/// Plays one engine move, abandoning it if it exceeds <see cref="_idleTimeout"/> (throwing
/// <see cref="TimeoutException"/>) so a single hung move can't block the loop forever. The
/// abandoned move's eventual fault — it errors once the game's engines are disposed — is
/// observed so it isn't an unobserved task exception.
/// </summary>
private async Task PlayMoveWithTimeoutAsync(GameState gameState, CancellationToken cancellationToken)
{
var play = orchestrator.PlayAsync(gameState);
try
{
await play.WaitAsync(_idleTimeout, cancellationToken);
}
catch (TimeoutException)
{
_ = play.ContinueWith(static t => { _ = t.Exception; }, TaskScheduler.Default);
throw;
}
}
private bool IsLive(GameState gameState, CancellationToken cancellationToken) =>
!cancellationToken.IsCancellationRequested
&& gameStore.Contains(gameState.GameId)
&& !IsGameOver(gameState);
private static bool IsGameOver(GameState gameState) =>
gameState.IsCheckmate || gameState.IsStalemate || gameState.IsThreefoldRepetition || gameState.IsForfeited;
/// <summary>
/// Feeds a finished training game's result into the learned weights, then frees the trainer.
/// A checkmate is a full-strength result; a material-imbalance draw is a half-strength win
/// for the lower-material side (holding a draw while down material is a success; only drawing
/// while up is a failure). A balanced draw, forfeit, or unfinished game teaches nothing.
/// </summary>
private void ApplyLearning(GameState gameState)
{
if (gameState.Trainer == nint.Zero)
return;
if (TryDetermineOutcome(gameState, out var winner, out var weight))
weightsStore.ApplyResult(gameState.Trainer, winner, weight);
weightsStore.DestroyTrainer(gameState.Trainer);
gameState.Trainer = nint.Zero;
}
/// <summary>
/// Frees an abandoned game's trainer without recording any result — a cancelled, crashed, or
/// idle-timed-out game teaches the model nothing.
/// </summary>
private void DiscardTraining(GameState gameState)
{
if (gameState.Trainer == nint.Zero)
return;
weightsStore.DestroyTrainer(gameState.Trainer);
gameState.Trainer = nint.Zero;
}
/// <summary>
/// Determines the trainable outcome of a finished game: the winning color and the reward
/// weight. Returns false when the game teaches nothing (balanced draw, forfeit, unfinished).
/// </summary>
private static bool TryDetermineOutcome(GameState gameState, out PieceColor winner, out double weight)
{
winner = PieceColor.White;
weight = 1.0;
if (gameState.IsCheckmate)
{
// The side to move is the mated one, so the winner is the other color.
winner = gameState.CurrentPlayer == PieceColor.White ? PieceColor.Black : PieceColor.White;
return true;
}
if (gameState.IsStalemate || gameState.IsThreefoldRepetition)
{
var (white, black) = MaterialCounts(gameState);
if (white == black)
return false; // a balanced draw carries no signal
winner = white < black ? PieceColor.White : PieceColor.Black;
weight = 0.5;
return true;
}
return false; // forfeit / unfinished
}
/// <summary>Total non-king material per side (P=1, N=B=3, R=5, Q=9), for draw adjudication.</summary>
private static (int white, int black) MaterialCounts(GameState gameState)
{
int white = 0, black = 0;
for (int row = 0; row < 8; row++)
for (int col = 0; col < 8; col++)
{
var piece = gameState.Board[row, col];
if (piece is null)
continue;
int value = piece.Type switch
{
PieceType.Pawn => 1,
PieceType.Knight => 3,
PieceType.Bishop => 3,
PieceType.Rook => 5,
PieceType.Queen => 9,
_ => 0
};
if (piece.Color == PieceColor.White)
white += value;
else
black += value;
}
return (white, black);
}
}
@@ -1,22 +1,19 @@
using JoshHeaps.Net.Hubs;
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces;
using Microsoft.AspNetCore.SignalR;
using System.Diagnostics;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Channels;
namespace JoshHeaps.Net.Services.Implementations;
public sealed class Stockfish : IAsyncDisposable
public sealed class Stockfish : IChessEngine
{
private readonly Process _p;
private readonly StreamWriter _stdin;
private readonly Channel<string> _stdout = Channel.CreateUnbounded<string>();
private readonly int _skill;
public int Skill => _skill;
public Stockfish(int skill = 20, int hash = 256)
{
_skill = skill;
@@ -28,13 +25,13 @@ public sealed class Stockfish : IAsyncDisposable
: "stockfish-ubuntu-x86-64-sse41-popcnt"; // default: Linux
string exePath = Path.Combine(baseDir, "Resources", fileName);
if (!File.Exists(exePath))
{
throw new FileNotFoundException($"Stockfish executable not found at {exePath}. " +
"Ensure the file is present in the Resources folder of your project.");
}
Console.Write(exePath);
_p = new Process
@@ -77,8 +74,9 @@ public sealed class Stockfish : IAsyncDisposable
WaitFor("readyok").GetAwaiter().GetResult();
}
public async Task<string> GetBestMoveAsync(string fen)
public async Task<string> GetBestMoveAsync(string fen, IReadOnlyList<string> historyFens)
{
// historyFens is unused: Stockfish tracks repetition from the position it's given.
Send($"position fen {fen}");
Send($"go depth {_skill}");
string? best = null;
@@ -102,152 +100,26 @@ public sealed class Stockfish : IAsyncDisposable
if (line == token) break;
}
// If Stockfish doesn't exit on "quit" (crashed, zombie, ignores stdin close), don't hang
// here forever — that would block GameStore's removal task and leave the finished game
// stuck in the store, cluttering the watch page indefinitely.
private static readonly TimeSpan _exitTimeout = TimeSpan.FromSeconds(5);
public async ValueTask DisposeAsync()
{
Send("quit");
_stdin.Close();
await _p.WaitForExitAsync();
_p.Dispose();
}
public async Task MakeMove(GameState state, IHubContext<ChessHub> chessHub, IChessService chessService)
{
var move = await GetBestMoveAsync(state.ToFen());
var moveDto = move.ToMoveDto(
state,
state.CurrentPlayer == PieceColor.White
? state.WhitePlayerId
: state.BlackPlayerId);
var result = chessService.MakeMove(state, moveDto);
await chessHub.Clients.Group(state.GameId.ToString()).SendAsync("ReceiveMoveUpdate", state.GameId.ToString(), moveDto, result);
}
}
public static class StockfishHelpers
{
public static MoveDto ToMoveDto(
this string uci,
GameState gameState,
Guid playerId)
{
int fCol = uci[0] - 'a', fRow = 7 - (uci[1] - '1');
int tCol = uci[2] - 'a', tRow = 7 - (uci[3] - '1');
var piece = gameState.Board[fRow, fCol]
?? throw new Exception("No piece at source square");
PieceType? promo = uci.Length == 5 ? uci[4] switch
try
{
'q' => PieceType.Queen,
'r' => PieceType.Rook,
'b' => PieceType.Bishop,
'n' => PieceType.Knight,
_ => null
} : null;
return new MoveDto
await _p.WaitForExitAsync().WaitAsync(_exitTimeout);
}
catch (TimeoutException)
{
GameId = gameState.GameId,
PlayerId = playerId,
PieceId = piece.Id,
TargetRow = tRow,
TargetCol = tCol,
PromotionChoice = promo,
SourceCol = fCol,
SourceRow = fRow,
};
}
/// <summary>
/// Convert a 2-D board array (rank 8 = row 0, file a = col 0) to a FEN string.
/// Only piece placement + active colour + castling are computed; the rest use
/// safe defaults (-, 0, 1). That is all Stockfish needs.
/// </summary>
public static string ToFen(this GameState gs)
{
var sb = new StringBuilder(64);
/* 1) piece placement */
for (int row = 0; row < 8; row++)
{
int empty = 0;
for (int col = 0; col < 8; col++)
{
var p = gs.Board[row, col];
if (p is null)
{
empty++;
}
else
{
if (empty > 0) { sb.Append(empty); empty = 0; }
sb.Append(ToFenChar(p)); // ← unchanged helper
}
}
if (empty > 0) sb.Append(empty);
if (row < 7) sb.Append('/');
if (!_p.HasExited)
_p.Kill(entireProcessTree: true);
}
/* 2) active colour */
sb.Append(gs.CurrentPlayer == PieceColor.White ? " w " : " b ");
/* 3) castling rights (from GameState flags) */
sb.Append(GetCastlingFlags(gs));
/* 4) en-passant target square */
sb.Append(' ');
sb.Append(gs.EnPassantTarget.HasValue
? Alg(gs.EnPassantTarget.Value)
: "-");
/* 5-6) half-move clock + full-move number */
int fullMoves = gs.MoveHistory.Count / 2 + 1;
sb.Append(" 0 ").Append(fullMoves);
return sb.ToString();
_p.Dispose();
}
/* ---------- helpers ---------- */
private static string GetCastlingFlags(GameState gs)
{
var flags = new StringBuilder(4);
if (gs.WhiteCanCastleKingside) flags.Append('K');
if (gs.WhiteCanCastleQueenside) flags.Append('Q');
if (gs.BlackCanCastleKingside) flags.Append('k');
if (gs.BlackCanCastleQueenside) flags.Append('q');
return flags.Length == 0 ? "-" : flags.ToString();
}
private static string Alg(Position p)
{
char file = (char)('a' + p.Col);
int rank = 8 - p.Row;
return $"{file}{rank}";
}
private static char ToFenChar(ChessPiece p) => p switch
{
{ Type: PieceType.Pawn, Color: PieceColor.White } => 'P',
{ Type: PieceType.Pawn, Color: PieceColor.Black } => 'p',
{ Type: PieceType.Knight, Color: PieceColor.White } => 'N',
{ Type: PieceType.Knight, Color: PieceColor.Black } => 'n',
{ Type: PieceType.Bishop, Color: PieceColor.White } => 'B',
{ Type: PieceType.Bishop, Color: PieceColor.Black } => 'b',
{ Type: PieceType.Rook, Color: PieceColor.White } => 'R',
{ Type: PieceType.Rook, Color: PieceColor.Black } => 'r',
{ Type: PieceType.Queen, Color: PieceColor.White } => 'Q',
{ Type: PieceType.Queen, Color: PieceColor.Black } => 'q',
{ Type: PieceType.King, Color: PieceColor.White } => 'K',
{ Type: PieceType.King, Color: PieceColor.Black } => 'k',
_ => throw new ArgumentOutOfRangeException(nameof(p))
};
}
}
@@ -0,0 +1,11 @@
using JoshHeaps.Net.Models;
namespace JoshHeaps.Net.Services.Interfaces;
public interface IBlogService
{
Task<List<BlogPost>> GetAllPostsAsync();
Task<BlogPost?> GetPostBySlugAsync(string slug);
Task<List<BlogPost>> GetPostsByTagAsync(string tag);
void ClearCache();
}
@@ -0,0 +1,24 @@
namespace JoshHeaps.Net.Services.Interfaces;
/// <summary>
/// An AI move-selection engine (e.g. Stockfish or the custom native engine).
/// Selects a move for a position; it does not enforce rules or broadcast updates.
/// Implementations own unmanaged resources, hence <see cref="IAsyncDisposable"/>.
/// </summary>
public interface IChessEngine : IAsyncDisposable
{
/// <summary>The engine's playing strength / search depth.</summary>
int Skill { get; }
/// <summary>
/// Returns the engine's chosen move in UCI long-algebraic form (e.g. "e2e4", "e7e8q").
/// </summary>
/// <param name="fen">The current position as a FEN string.</param>
/// <param name="historyFens">
/// Prior positions since the last irreversible move, oldest first, excluding the
/// current one — lets the engine detect threefold/50-move draws the FEN can't carry.
/// May be empty.
/// </param>
/// <returns>The selected move as a UCI string.</returns>
Task<string> GetBestMoveAsync(string fen, IReadOnlyList<string> historyFens);
}
@@ -0,0 +1,27 @@
using JoshHeaps.Net.Services.Implementations;
namespace JoshHeaps.Net.Services.Interfaces;
/// <summary>
/// Creates <see cref="IChessEngine"/> instances, choosing the implementation from configuration.
/// </summary>
public interface IChessEngineFactory
{
/// <summary>
/// Creates a new engine instance for a single game using the configured default engine.
/// The caller owns and disposes it.
/// </summary>
/// <param name="skill">The desired playing strength / search depth.</param>
/// <returns>A new, owned <see cref="IChessEngine"/>.</returns>
IChessEngine Create(int skill);
/// <summary>
/// Creates a new engine instance for a single game using an explicitly chosen engine
/// (e.g. for picking a different engine per side in a CPU-vs-CPU game). The caller owns
/// and disposes it.
/// </summary>
/// <param name="skill">The desired playing strength / search depth.</param>
/// <param name="kind">The engine implementation to create.</param>
/// <returns>A new, owned <see cref="IChessEngine"/>.</returns>
IChessEngine Create(int skill, ChessEngineKind kind);
}
@@ -0,0 +1,26 @@
using JoshHeaps.Net.Models;
namespace JoshHeaps.Net.Services.Interfaces;
/// <summary>
/// Drives a computer move: asks the side-to-move's engine for a move, applies it through
/// the rules service, and broadcasts the result to the game's clients.
/// </summary>
public interface IComputerMoveOrchestrator
{
/// <summary>
/// Has the side-to-move's engine pick a move for the current position, applies it, and
/// broadcasts it. The engine is taken from the game's per-side computer assignments.
/// </summary>
/// <param name="state">The game to play a move in.</param>
/// <returns>The applied move and its result.</returns>
Task<(MoveDto move, MoveResultDto result)> PlayAsync(GameState state);
/// <summary>
/// Plays a uniformly-random legal move for the side to move (no engine), applying and
/// broadcasting it. Used to randomize the opening of training games so self-play and
/// engine-vs-engine games don't replay the same line every time.
/// </summary>
/// <param name="state">The game to play a random move in.</param>
Task PlayRandomMoveAsync(GameState state);
}
@@ -0,0 +1,29 @@
using JoshHeaps.Net.Models;
namespace JoshHeaps.Net.Services.Interfaces;
/// <summary>
/// Process-wide registry of in-memory games and their cleanup lifecycle. Shared by the HTTP
/// controller (human and single-computer games) and the self-play coordinator (CPU-vs-CPU and
/// auto-training games), so every game is reachable from one place for lookup and spectating.
/// </summary>
public interface IGameStore
{
/// <summary>Add (or replace) a game in the registry.</summary>
void Add(GameState game);
/// <summary>Look a game up by id.</summary>
bool TryGet(Guid id, out GameState game);
/// <summary>Whether a game with this id is still in the registry.</summary>
bool Contains(Guid id);
/// <summary>Snapshot of all games currently in the registry.</summary>
IReadOnlyCollection<GameState> All { get; }
/// <summary>
/// Schedule removal of a game after <paramref name="delay"/>, cancelling any prior schedule
/// for it. On removal the game's engines are disposed and any training accumulator freed.
/// </summary>
void ScheduleRemove(Guid id, TimeSpan delay);
}
@@ -0,0 +1,33 @@
using JoshHeaps.Net.Models;
namespace JoshHeaps.Net.Services.Interfaces;
/// <summary>
/// Thin managed facade over the native learned-weights model. The weights, all feature
/// computation, per-game accumulation, the update rule, and persistence live in the native
/// engine; this just points it at the weights file, hands out per-game trainers, and reads
/// the table back for visualization.
/// </summary>
public interface ILearnedWeightsStore
{
/// <summary>Absolute path to the weights file the native engine loads and saves.</summary>
string WeightsFilePath { get; }
/// <summary>A copy of the current weights (midgame/endgame tables + feature weights).</summary>
LearnedWeightsSnapshot Snapshot();
/// <summary>Creates a per-game training accumulator. The caller owns it (see <see cref="DestroyTrainer"/>).</summary>
nint CreateTrainer();
/// <summary>Records one played position (post-move FEN) into a trainer.</summary>
void Record(nint trainer, string fen);
/// <summary>
/// Applies a finished game's outcome to the global weights (and saves): rewards the
/// winner's squares/features, punishes the loser's, scaled by <paramref name="weight"/>.
/// </summary>
void ApplyResult(nint trainer, PieceColor winner, double weight);
/// <summary>Frees a trainer. Safe to call with <see cref="nint.Zero"/>.</summary>
void DestroyTrainer(nint trainer);
}
@@ -0,0 +1,23 @@
using JoshHeaps.Net.Services.Implementations;
namespace JoshHeaps.Net.Services.Interfaces;
/// <summary>Per-side engine and strength for a CPU-vs-CPU game.</summary>
public sealed record SelfPlayConfig(
ChessEngineKind WhiteKind, int WhiteSkill,
ChessEngineKind BlackKind, int BlackSkill);
/// <summary>
/// Creates and runs CPU-vs-CPU games to completion: randomized opening, move loop, and (when
/// the learned engine plays) feeding the result back into the learned weights. Used by the
/// spectator "watch" endpoint and by the auto-trainer.
/// </summary>
public interface ISelfPlayCoordinator
{
/// <summary>
/// Create, register, and start running a self-play game. Returns immediately with the new
/// game's id and a task that completes when the game finishes (or is cancelled). Callers
/// that only need the id can ignore the task; the auto-trainer awaits it to start the next.
/// </summary>
(Guid GameId, Task Completion) StartGame(SelfPlayConfig config, CancellationToken cancellationToken = default);
}
@@ -5,5 +5,8 @@
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ChessEngine": {
"Engine": "Custom"
}
}
+8 -1
View File
@@ -5,5 +5,12 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"BlogApi": {
"BaseUrl": "https://media.joshheaps.net",
"InvalidateKey": "CHANGE_ME"
},
"ChessEngine": {
"Engine": "Custom"
}
}
@@ -0,0 +1,32 @@
{
"name": "Texas Hold'em Dealer",
"short_name": "Hold'em",
"description": "Seat your players, track the action, split the pot. Works with no signal.",
"start_url": "/casino/texasholdem",
"scope": "/casino/",
"display": "standalone",
"orientation": "any",
"background_color": "#141414",
"theme_color": "#141414",
"categories": ["utilities"],
"icons": [
{
"src": "/images/casino/holdem-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/images/casino/holdem-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/images/casino/holdem-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+125
View File
@@ -0,0 +1,125 @@
// Keeps the dealer tool working when the network doesn't.
//
// A home game is the worst network in the world: somebody's back room, one bar of
// signal, a router two floors away. The tool already keeps the whole night in
// localStorage, so the *table* survives anything — but the page didn't. Reload with no
// signal, or open it fresh when the wifi has dropped, and a dealer holding a phone in
// one hand and a deck in the other got a browser error page with nine players waiting.
//
// This is the one thing standing between "a website about poker" and "a tool you can
// rely on at a table", so it is worth the moving part.
//
// Scope is /casino/ rather than /casino/texasholdem/, because a scope has to be a
// prefix of the page it controls and "/casino/texasholdem" does not start with
// "/casino/texasholdem/". Anything in that scope this worker has not been told about
// goes straight to the network, so the other casino pages behave exactly as they did.
// Bumped whenever the shape of what is cached changes. The asset *version* rides in
// the URLs themselves (?v=1.0.8 from _Layout), so a normal release makes new keys
// without touching this.
const CACHE = "holdem-shell-v1";
// What this worker will keep. Anything else in scope — a form post, an API call, a
// page it was never told about — goes straight to the network and behaves exactly as
// it did before the worker existed.
const CACHEABLE = new Set(["script", "style", "image", "font", "manifest"]);
// The page hands over the exact list of URLs it was built from — see registerWorker()
// in main.js. Deriving it from the document instead of keeping a list here is the
// whole trick: a hand-written manifest of files drifts from the markup the first time
// somebody adds a script tag, and the failure mode is a tool that is offline-capable
// right up until the release that quietly broke it.
async function precache(urls) {
const cache = await caches.open(CACHE);
// Individually rather than cache.addAll, which rejects the whole batch if any one
// URL 404s — one stale entry in the list would leave the dealer with nothing.
await Promise.all(urls.map(async url => {
try {
const res = await fetch(url, { cache: "reload" });
if (res.ok) await cache.put(url, res);
} catch {
// Offline while installing. The runtime handler below picks these up on
// the next online visit.
}
}));
}
// No install handler, and deliberately no skipWaiting(). A new worker taking over a
// live page can hand it assets from a different release than the ones it started with,
// and this page loads seventeen separate scripts that expect to agree with each other.
// Waiting costs a dealer nothing — the update lands the next time they open the tool —
// and it cannot leave them with a half-swapped app in the middle of a hand.
self.addEventListener("activate", event => {
event.waitUntil((async () => {
const keys = await caches.keys();
await Promise.all(keys
.filter(key => key.startsWith("holdem-shell-") && key !== CACHE)
.map(key => caches.delete(key)));
await self.clients.claim();
})());
});
self.addEventListener("message", event => {
if (event.data && event.data.type === "precache" && Array.isArray(event.data.urls)) {
event.waitUntil(precache(event.data.urls));
}
});
// Only GETs, and only same-origin. A cross-origin request has opaque responses that
// can't be inspected, and caching one would mean serving a failure forever.
function handles(request) {
if (request.method !== "GET") return false;
const url = new URL(request.url);
return url.origin === self.location.origin;
}
self.addEventListener("fetch", event => {
const { request } = event;
if (!handles(request)) return;
// The page itself: network first, so an online dealer always gets the current
// build, and the cached copy is there the moment they aren't.
if (request.mode === "navigate") {
event.respondWith((async () => {
try {
const fresh = await fetch(request);
if (fresh.ok) (await caches.open(CACHE)).put(request, fresh.clone());
return fresh;
} catch {
const cached = await caches.match(request, { ignoreSearch: true });
if (cached) return cached;
throw new Error("offline and this page was never cached");
}
})());
return;
}
// Assets: stale while revalidate. The cached copy answers immediately — which is
// the whole point, a dealer opening this between hands should not wait for a
// network — and a fresh copy is fetched in the background for next time.
//
// Cache-first would be the obvious choice, on the reasoning that these URLs carry
// ?v= from the layout and so a hit is always the right file. They don't: the
// version renders empty (see the note in TexasHoldem.cshtml), so every release
// reuses the same URLs. Cache-first against unversioned URLs is how a tool gets
// frozen on a build from three months ago with no way for anyone to tell. This
// strategy needs no version to be correct: worst case a dealer is one launch
// behind, which for a tool that runs entirely in the browser is nothing.
if (!CACHEABLE.has(request.destination)) return;
event.respondWith((async () => {
const cache = await caches.open(CACHE);
const cached = await cache.match(request);
const fresh = fetch(request).then(res => {
if (res.ok) cache.put(request, res.clone());
return res;
}).catch(() => null);
if (cached) return cached;
const res = await fresh;
if (res) return res;
throw new Error("offline and not cached: " + request.url);
})());
});
+126
View File
@@ -0,0 +1,126 @@
*, *::before, *::after {
box-sizing: border-box;
}
body {
margin: 0;
background-color: var(--color-bg);
color: var(--color-text);
overflow-x: hidden;
font-family: var(--font-body);
line-height: 1.6;
}
.blog-index {
max-width: 800px;
margin: 0 auto;
padding: 4rem clamp(1.5rem, 5vw, 3rem);
}
.blog-title {
color: var(--color-heading);
font-size: clamp(1.75rem, 3vw, 2.25rem);
font-weight: 700;
letter-spacing: -0.02em;
margin: 0 0 2.5rem;
}
.blog-filter {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
color: var(--color-text-muted);
font-size: 0.875rem;
}
.blog-filter strong {
color: var(--color-heading-secondary);
}
.blog-filter a {
color: var(--color-accent);
text-decoration: none;
transition: color 0.2s;
}
.blog-filter a:hover {
color: var(--color-accent-hover);
}
.blog-empty {
color: var(--color-text-subtle);
font-style: italic;
font-size: 1rem;
}
.blog-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.blog-card {
padding: 1.5rem 1.75rem;
border-left: 2px solid var(--color-accent-border);
background: var(--color-surface);
border-radius: 0 8px 8px 0;
transition: background 0.2s;
}
.blog-card:hover {
background: var(--color-surface-hover);
}
.blog-card-link {
text-decoration: none;
}
.blog-card-title {
color: var(--color-heading-secondary);
font-size: 1.25rem;
font-weight: 600;
margin: 0 0 0.3rem;
letter-spacing: -0.01em;
transition: color 0.2s;
}
.blog-card-link:hover .blog-card-title {
color: var(--color-accent);
}
.blog-card-date {
color: var(--color-text-subtle);
font-size: 0.75rem;
display: block;
margin-bottom: 0.5rem;
}
.blog-card-summary {
color: var(--color-text-muted);
margin: 0;
line-height: 1.6;
font-size: 0.875rem;
}
.blog-card-tags {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
margin-top: 0.75rem;
}
.blog-tag {
background: var(--color-surface-raised);
color: var(--color-text-muted);
padding: 0.2rem 0.6rem;
border-radius: 4px;
font-size: 0.7rem;
text-decoration: none;
transition: background 0.2s, color 0.2s;
}
.blog-tag:hover {
background: var(--color-surface-raised-hover);
color: var(--color-heading-secondary);
}
+184
View File
@@ -0,0 +1,184 @@
*, *::before, *::after {
box-sizing: border-box;
}
body {
margin: 0;
background-color: var(--color-bg);
color: var(--color-text);
overflow-x: hidden;
font-family: var(--font-body);
line-height: 1.6;
}
.blog-post {
max-width: 800px;
margin: 0 auto;
padding: 4rem clamp(1.5rem, 5vw, 3rem);
}
.blog-back {
color: var(--color-text-subtle);
text-decoration: none;
font-size: 0.85rem;
transition: color 0.2s;
}
.blog-back:hover {
color: var(--color-heading-secondary);
}
.blog-post-header {
margin: 2.5rem 0 2.5rem;
}
.blog-post-title {
color: var(--color-heading);
font-size: clamp(1.75rem, 3vw, 2.25rem);
font-weight: 700;
letter-spacing: -0.02em;
margin: 0 0 0.5rem;
line-height: 1.25;
}
.blog-post-date {
color: var(--color-text-subtle);
font-size: 0.8rem;
display: block;
}
.blog-post-tags {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
margin-top: 0.75rem;
}
.blog-tag {
background: var(--color-surface-raised);
color: var(--color-text-muted);
padding: 0.2rem 0.6rem;
border-radius: 4px;
font-size: 0.7rem;
text-decoration: none;
transition: background 0.2s, color 0.2s;
}
.blog-tag:hover {
background: var(--color-surface-raised-hover);
color: var(--color-heading-secondary);
}
/* ── Post Content ── */
.post-content {
color: var(--color-text);
line-height: 1.8;
font-size: 1rem;
}
.post-content h1,
.post-content h2,
.post-content h3,
.post-content h4 {
color: var(--color-heading-secondary);
margin-top: 2rem;
margin-bottom: 0.75rem;
letter-spacing: -0.01em;
}
.post-content h1 { font-size: 1.6rem; }
.post-content h2 { font-size: 1.35rem; }
.post-content h3 { font-size: 1.15rem; }
.post-content p {
margin: 0 0 1.25rem;
}
.post-content a {
color: var(--color-accent);
text-decoration: none;
transition: color 0.2s;
}
.post-content a:hover {
color: var(--color-accent-hover);
}
.post-content ul,
.post-content ol {
margin: 0 0 1.25rem 1.5rem;
}
.post-content li {
margin-bottom: 0.4rem;
}
.post-content code {
background: var(--color-surface-raised);
color: var(--color-heading-secondary);
padding: 0.15rem 0.4rem;
border-radius: 4px;
font-size: 0.85em;
font-family: var(--font-mono);
}
.post-content pre {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 1.25rem 1.5rem;
overflow-x: auto;
margin: 0 0 1.25rem;
scrollbar-width: thin;
scrollbar-color: var(--color-border) transparent;
}
.post-content pre::-webkit-scrollbar {
height: 6px;
}
.post-content pre::-webkit-scrollbar-track {
background: transparent;
}
.post-content pre::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 3px;
}
.post-content pre[class*="language-"] {
background: var(--color-surface);
}
.post-content pre code {
background: none;
color: var(--prism-text);
padding: 0;
font-size: 0.85rem;
}
.post-content blockquote {
border-left: 2px solid var(--color-accent-border);
margin: 0 0 1.25rem;
padding: 0.75rem 1.25rem;
color: var(--color-text-muted);
background: var(--color-surface);
border-radius: 0 8px 8px 0;
}
.post-content img {
max-width: 100%;
border-radius: 8px;
margin: 1.25rem 0;
}
.post-content strong {
color: var(--color-heading-secondary);
}
.post-content hr {
border: none;
border-top: 1px solid var(--color-border);
margin: 2rem 0;
}
@@ -0,0 +1,154 @@
/* Prism.js Tomorrow Night theme (customized) */
/* Original: https://github.com/PrismJS/prism/blob/master/themes/prism-tomorrow.css */
/* ============================================
Theme colors — edit these to restyle
============================================ */
:root {
--prism-background: #0a0a0a;
--prism-text: #909090;
--prism-comment: #555;
--prism-punctuation: #707070;
--prism-tag: #09ff00;
--prism-function: #fff;
--prism-boolean: #1ab815;
--prism-number: #1ab815;
--prism-property: #c8c8c8;
--prism-class: #c8c8c8;
--prism-keyword: #09ff00;
--prism-string: #43c760;
--prism-variable: #43c760;
--prism-operator: #999;
--prism-inserted: #09ff00;
}
code[class*="language-"],
pre[class*="language-"] {
color: var(--prism-text);
background: none;
font-family: var(--font-mono);
font-size: 1em;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
tab-size: 4;
hyphens: none;
}
pre[class*="language-"] {
padding: 1em;
margin: 0.5em 0;
overflow: auto;
}
:not(pre) > code[class*="language-"],
pre[class*="language-"] {
background: var(--prism-background);
}
:not(pre) > code[class*="language-"] {
padding: 0.1em;
border-radius: 0.3em;
white-space: normal;
}
/* Comments */
.token.comment,
.token.block-comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: var(--prism-comment);
}
/* Punctuation */
.token.punctuation {
color: var(--prism-punctuation);
}
/* Tags, attributes, deleted */
.token.tag,
.token.attr-name,
.token.namespace,
.token.deleted {
color: var(--prism-tag);
}
/* Function names */
.token.function-name,
.token.function {
color: var(--prism-function);
}
/* Booleans */
.token.boolean {
color: var(--prism-boolean);
}
/* Numbers */
.token.number {
color: var(--prism-number);
}
/* Properties */
.token.property {
color: var(--prism-property);
}
/* Classes, constants, symbols */
.token.class-name,
.token.constant,
.token.symbol {
color: var(--prism-class);
}
/* Keywords, selectors, builtins */
.token.selector,
.token.important,
.token.atrule,
.token.keyword,
.token.builtin {
color: var(--prism-keyword);
}
/* Strings, chars, attr values, regex */
.token.string,
.token.char,
.token.attr-value,
.token.regex {
color: var(--prism-string);
}
/* Variables */
.token.variable {
color: var(--prism-variable);
}
/* Operators, entities, URLs */
.token.operator,
.token.entity,
.token.url {
color: var(--prism-operator);
}
/* Inserted */
.token.inserted {
color: var(--prism-inserted);
}
/* Formatting */
.token.bold,
.token.important {
font-weight: 700;
}
.token.italic {
font-style: italic;
}
.token.entity {
cursor: help;
}
File diff suppressed because it is too large Load Diff
+126
View File
@@ -0,0 +1,126 @@
body {
background: #1a1a1a;
min-height: 100vh;
margin: 0;
padding: 0;
}
.casino-container {
max-width: 1000px;
margin: 0 auto;
padding: 4rem 2rem;
text-align: center;
}
.casino-header {
color: #f5f5f5;
font-size: 2.5rem;
margin-bottom: 0.5rem;
font-weight: 300;
letter-spacing: 0.1em;
}
.casino-subtitle {
color: #a0a0a0;
font-size: 1rem;
margin-bottom: 4rem;
font-weight: 300;
}
.games-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
margin: 3rem auto;
max-width: 900px;
padding: 0 1rem;
}
.game-card {
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 4px;
padding: 2rem 1.5rem;
transition: all 0.3s ease;
cursor: pointer;
text-decoration: none;
color: inherit;
display: flex;
flex-direction: column;
align-items: center;
}
.game-card:hover {
background: #2f2f2f;
border-color: #4a4a4a;
transform: translateY(-2px);
}
.game-card.disabled {
opacity: 0.4;
cursor: not-allowed;
}
.game-card.disabled:hover {
background: #2a2a2a;
border-color: #3a3a3a;
transform: none;
}
.game-icon {
font-size: 2.5rem;
margin-bottom: 1rem;
opacity: 0.9;
}
.game-title {
font-size: 1.2rem;
font-weight: 400;
color: #f5f5f5;
margin-bottom: 0.75rem;
letter-spacing: 0.05em;
}
.game-description {
font-size: 0.9rem;
color: #999;
line-height: 1.6;
font-weight: 300;
}
.coming-soon {
color: #666;
font-style: italic;
margin-top: 0.5rem;
font-size: 0.85rem;
}
.home-button {
margin-top: 4rem;
padding: 0.75rem 1.5rem;
font-size: 0.9rem;
background: transparent;
color: #f5f5f5;
border: 1px solid #3a3a3a;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s ease;
text-decoration: none;
display: inline-block;
letter-spacing: 0.05em;
}
.home-button:hover {
background: #2a2a2a;
border-color: #4a4a4a;
}
@media (max-width: 768px) {
.casino-header {
font-size: 2rem;
}
.games-grid {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,656 @@
/* Quick Split is phone-first: sized for one thumb at the table, then allowed to
breathe on a laptop. Palette matches the dealer table so the two read as one tool. */
/* And matches it in the half a stylesheet does not paint, for the same reason: this
page is #0a0a0a and had never told the browser so, which leaves the scrollbars, the
caret, the selection and the autofill wash drawn for a light page. It has no
checkboxes to go white, but a dealer who opens it from the felt should not find a
light scrollbar down the side of the darker of the two pages. */
:root {
color-scheme: dark;
}
body {
background: radial-gradient(ellipse at 50% -10%, #1c1c1c 0%, #101010 55%, #0a0a0a 100%);
min-height: 100vh;
margin: 0;
color: #e6e6e6;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
/* Same touch handling as the dealer table — see the note there. This page is the one
a dealer opens mid-hand to chop a pot, so the ~300ms the browser spends deciding
whether a tap was a double-tap-to-zoom is the last thing it needs. */
button,
[role="button"],
input,
select,
textarea,
a {
touch-action: manipulation;
}
button,
[role="button"],
input,
select,
textarea,
a {
-webkit-tap-highlight-color: transparent;
}
/* Not on the fields — a dealer typing a pot amount still needs to select it. */
button,
[role="button"] {
-webkit-user-select: none;
user-select: none;
}
.clearPotBtn:active:not(:disabled),
.choiceChip:active:not(:disabled),
.stepBtn:active:not(:disabled),
.splitBtn:active:not(:disabled),
.splitBtnGhost:active:not(:disabled),
.unevenFoldBtn:active:not(:disabled),
.unevenRemoveBtn:active:not(:disabled) {
transform: translateY(1px);
filter: brightness(1.25);
transition-duration: 0.04s;
}
@media (prefers-reduced-motion: reduce) {
.clearPotBtn:active:not(:disabled),
.choiceChip:active:not(:disabled),
.stepBtn:active:not(:disabled),
.splitBtn:active:not(:disabled),
.splitBtnGhost:active:not(:disabled),
.unevenFoldBtn:active:not(:disabled),
.unevenRemoveBtn:active:not(:disabled) {
transform: none;
}
}
#splitApp {
max-width: 480px;
margin: 0 auto;
padding: 1.25rem 1rem 3rem;
}
/* ---------- Header ---------- */
#splitHeader h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 300;
letter-spacing: 0.04em;
color: #f5f5f5;
position: relative;
display: inline-block;
padding-bottom: 0.35rem;
}
#splitHeader h1::after {
content: "";
position: absolute;
left: 0;
bottom: 0;
width: 36px;
height: 3px;
border-radius: 2px;
background: linear-gradient(90deg, #d8b46a, #a9782f);
}
.splitSubtitle {
margin: 0.35rem 0 1.25rem;
color: #8f8f8f;
font-size: 0.85rem;
}
/* ---------- Mode toggle ---------- */
#modeToggle {
margin-bottom: 1.1rem;
}
/* ---------- Uneven amounts ---------- */
#unevenRows {
display: flex;
flex-direction: column;
gap: 0.45rem;
margin-bottom: 0.6rem;
}
.unevenRow {
display: flex;
align-items: center;
gap: 0.45rem;
}
.unevenRowLabel {
flex: none;
width: 2rem;
color: #8f8f8f;
font-size: 0.8rem;
font-weight: 600;
}
.unevenAmountWrap {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 0.3rem;
background: #141414;
border: 1px solid #3a3a3a;
border-radius: 10px;
padding: 0 0.6rem;
}
.unevenAmountWrap:focus-within {
border-color: #d8b46a;
}
.unevenCurrency {
/* Was #777 — 4.11:1 on this background, under the 4.5:1 small text needs. It sits
against every amount a dealer types, so it is read constantly. */
color: #8f8f8f;
}
/* 37px tall, on the field a dealer types every amount into. The touch rule has been
44px everywhere else since the round that set it; this page never got measured for
it, because the audit only ran the target check when asked and nothing asked. */
.unevenAmount {
flex: 1;
min-width: 0;
min-height: 46px;
background: none;
border: none;
outline: none;
color: #f2f2f2;
font-family: inherit;
font-size: 16px;
font-variant-numeric: tabular-nums;
padding: 0.6rem 0;
}
.unevenFoldBtn {
flex: none;
min-height: 44px;
padding: 0 0.6rem;
border-radius: 9px;
border: 1px solid #3a3a3a;
background: #232323;
color: #8f8f8f;
font-family: inherit;
font-size: 0.72rem;
cursor: pointer;
}
.unevenFoldBtn.selected {
background: linear-gradient(180deg, #3d1f1f, #2c1515);
border-color: #5b2626;
color: #ffd6d6;
}
/* 30px across, and it deletes a player's entry — the one control on the row where a
near miss costs something. The amount field beside it gives up the 16px. */
.unevenRemoveBtn {
flex: none;
width: 46px;
height: 46px;
border: none;
background: none;
/* Was #6a6a6a — 3.66:1, the worst on the page, on a control that deletes a
player's entry. A destructive action should not be the faintest thing in view. */
color: #9a9a9a;
font-size: 0.85rem;
cursor: pointer;
}
.unevenRemoveBtn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
#unevenResult {
background: linear-gradient(160deg, #1f2a21, #161c17);
border: 1px solid #2c3a2f;
border-radius: 14px;
padding: 0.9rem 1rem;
margin-bottom: 1.5rem;
}
#unevenResult[hidden] {
display: none;
}
.unevenPot {
display: flex;
align-items: baseline;
/* Wraps so a chop opens underneath its own pot rather than squeezing in beside the
eligible-players list and pushing it off the row. */
flex-wrap: wrap;
gap: 0.6rem;
padding: 0.3rem 0;
font-size: 0.85rem;
}
.unevenChopToggle {
flex: none;
order: 3;
/* 44px on touch: it moves money, like everything else that does. */
min-height: 32px;
padding: 0.2rem 0.6rem;
border: 1px solid rgba(216, 180, 106, 0.45);
border-radius: 999px;
background: rgba(216, 180, 106, 0.1);
color: #e3c887;
font: inherit;
font-size: 0.74rem;
cursor: pointer;
}
.unevenChopToggle:hover {
background: rgba(216, 180, 106, 0.18);
}
.unevenChopToggle:focus-visible {
outline: 2px solid #d8b46a;
outline-offset: 2px;
}
.unevenChopDetail {
order: 4;
flex: 0 0 100%;
/* Or the padding is added on top of the 100% basis and the panel sits wider than
the row it belongs to — measured 328px inside a 309px row on a phone. */
box-sizing: border-box;
margin: 0.15rem 0 0.2rem;
padding: 0.45rem 0.6rem;
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
}
.unevenChopWays {
display: flex;
align-items: center;
gap: 0.45rem;
margin-bottom: 0.3rem;
}
.unevenChopWaysLabel {
color: #cfcfcf;
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
}
.unevenChopEach {
margin: 0;
color: #f2f2f2;
font-weight: 600;
}
.unevenChopNote {
margin: 0.2rem 0 0;
color: #9a9a9a;
font-size: 0.76rem;
}
.unevenChopDust {
color: #e0b169;
}
@media (max-width: 700px), (pointer: coarse) {
.unevenChopToggle {
min-height: 44px;
padding: 0.4rem 0.9rem;
}
}
.unevenPot + .unevenPot {
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.unevenPotLabel {
flex: none;
color: #d8b46a;
font-weight: 600;
}
.unevenPotAmount {
flex: none;
color: #f2f2f2;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.unevenPotWho {
flex: 1;
min-width: 0;
text-align: right;
color: #9a9a9a;
font-size: 0.78rem;
}
.unevenPotReturn .unevenPotLabel,
.unevenPotReturn .unevenPotAmount {
color: #9a9a9a;
font-weight: 500;
}
.unevenPotTotal {
margin-top: 0.15rem;
border-top: 1px solid rgba(255, 255, 255, 0.14) !important;
}
.unevenPotTotal .unevenPotLabel {
color: #8f9d92;
font-weight: 500;
}
.unevenEmpty {
margin: 0;
color: #8f9d92;
font-size: 0.82rem;
line-height: 1.45;
}
/* ---------- The answer ---------- */
#splitResult {
background: linear-gradient(160deg, #1f2a21, #161c17);
border: 1px solid #2c3a2f;
border-radius: 14px;
padding: 1.4rem 1.1rem;
text-align: center;
margin-bottom: 1.5rem;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
#splitResult.hasAmount {
border-color: #3c6b46;
box-shadow: 0 0 0 1px rgba(78, 201, 122, 0.08), 0 6px 20px rgba(0, 0, 0, 0.35);
}
#splitResultLabel {
color: #8f9d92;
font-size: 0.78rem;
letter-spacing: 0.06em;
text-transform: uppercase;
}
#splitResultAmount {
margin-top: 0.35rem;
font-size: clamp(2.6rem, 14vw, 3.4rem);
font-weight: 600;
line-height: 1.05;
color: #f2f2f2;
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
}
#splitResult.hasAmount #splitResultAmount {
color: #7ee2a0;
}
#splitOddNote {
margin-top: 0.7rem;
color: #d8b46a;
font-size: 0.85rem;
line-height: 1.45;
}
#splitDustNote {
margin-top: 0.5rem;
color: #e0a1a1;
font-size: 0.8rem;
line-height: 1.4;
}
/* ---------- Inputs ---------- */
.splitField {
display: block;
margin-bottom: 1.35rem;
}
/* A `display` declaration outranks the browser's own [hidden] rule, so anything
given one has to opt back out explicitly or toggling `hidden` does nothing. */
[hidden] {
display: none !important;
}
.splitLabel {
display: block;
margin-bottom: 0.5rem;
color: #9a9a9a;
font-size: 0.75rem;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.potInputWrap {
display: flex;
align-items: center;
gap: 0.4rem;
background: #141414;
border: 1px solid #3a3a3a;
border-radius: 12px;
padding: 0.2rem 0.75rem;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.potInputWrap:focus-within {
border-color: #d8b46a;
box-shadow: 0 0 0 3px rgba(216, 180, 106, 0.12);
}
.potCurrency {
color: #777;
font-size: 1.6rem;
font-weight: 300;
}
#potInput {
flex: 1;
min-width: 0;
background: none;
border: none;
outline: none;
color: #f2f2f2;
font-size: 2rem;
font-weight: 500;
font-variant-numeric: tabular-nums;
padding: 0.55rem 0;
font-family: inherit;
}
#potInput::placeholder {
color: #4a4a4a;
}
.clearPotBtn {
flex: none;
width: 34px;
height: 34px;
border-radius: 50%;
border: none;
background: #262626;
color: #999;
font-size: 0.85rem;
cursor: pointer;
}
@media (hover: hover) {
.clearPotBtn:hover {
background: #333;
color: #ddd;
}
}
/* ---------- Tap targets ---------- */
.chipRow {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.choiceChip {
flex: 1 1 auto;
min-width: 58px;
min-height: 52px;
padding: 0.5rem 0.7rem;
background: linear-gradient(180deg, #2e2e2e, #232323);
border: 1px solid #3a3a3a;
border-radius: 11px;
color: #ddd;
font-size: 1rem;
font-family: inherit;
cursor: pointer;
transition: all 0.15s ease;
}
@media (hover: hover) {
.choiceChip:hover {
border-color: #4a4a4a;
background: linear-gradient(180deg, #383838, #2a2a2a);
}
}
.choiceChip.selected {
background: linear-gradient(180deg, #3a2f18, #241d0f);
border-color: #d8b46a;
color: #f0d9a8;
font-weight: 600;
}
/* As wide as the three things in it, and no wider.
This sits in the same wrapping flex row as the chips, where it has never once fitted
on the first line of a phone. `flex: 1 1 auto` then handed it the whole of the second
line: measured on an iPhone, a 358px bar with 252px of nothing in the middle and the
− and + pushed 304px apart — the two ends of a control a thumb is meant to work with
one hand. 136px now, its two ends 121px apart. */
.waysStepper {
display: flex;
align-items: center;
gap: 0.35rem;
flex: 0 0 auto;
background: #191919;
border: 1px solid #333;
border-radius: 11px;
padding: 0.2rem 0.4rem;
}
/* 44 both ways, not 40x44. It had been 40px wide since the steppers were written, and
the sweep never said so because the only two steppers on the site are on this screen
and this screen was only ever audited in its all-in mode, where neither is on display
until a side pot has more than two contenders. The first sweep that opened the even
mode reported `hittable 39x43` on both ends, on every touch viewport. */
.stepBtn {
width: 44px;
height: 44px;
border: none;
border-radius: 8px;
background: #262626;
color: #ddd;
font-size: 1.25rem;
line-height: 1;
font-family: inherit;
cursor: pointer;
}
/* The floor and the ceiling look like the floor and the ceiling. */
.stepBtn:disabled {
opacity: 0.32;
cursor: default;
}
@media (hover: hover) {
.stepBtn:not(:disabled):hover {
background: #333;
}
}
/* The count itself, now that no chip beside it is also holding one — so it is painted
the way a selected chip is painted, because that is what it is standing in for. */
.waysReadout {
min-width: 2.5ch;
text-align: center;
color: #f0d9a8;
font-size: 1rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
/* ---------- Footer actions ---------- */
#splitActions {
display: flex;
gap: 0.6rem;
margin-top: 1.75rem;
}
.splitBtn {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 48px;
padding: 0.6rem 0.9rem;
border-radius: 10px;
border: 1px solid #3a3a3a;
background: linear-gradient(180deg, #2e2e2e, #232323);
color: #ddd;
font-size: 0.9rem;
font-family: inherit;
text-decoration: none;
cursor: pointer;
transition: all 0.15s ease;
}
@media (hover: hover) {
.splitBtn:hover {
border-color: #4a4a4a;
background: linear-gradient(180deg, #383838, #2a2a2a);
}
}
.splitBtnGhost {
background: transparent;
}
.splitFootnote {
margin-top: 1.5rem;
/* Was #6f6f6f — 3.94:1. It carries the instruction for what to do with the pot
list, which is not a decorative line. */
color: #8d8d8d;
font-size: 0.78rem;
line-height: 1.5;
text-align: center;
}
/* This footnote names a control the dealer is being told to reach for, so the name
itself has to be readable — it was 3.39:1 at its worst, inheriting the muted grey
the surrounding sentence is deliberately set in. Emphasised text carrying the
instruction gets its own colour rather than the aside's. */
.splitFootnote strong {
color: #a8a8a8;
font-weight: 600;
}
.splitFootnote a {
color: #a9782f;
}
/* Roomier once there's a real screen to work with. */
@media (min-width: 700px) {
#splitApp {
padding-top: 2.5rem;
}
#splitHeader h1 {
font-size: 1.8rem;
}
}
+67 -3
View File
@@ -1,7 +1,4 @@
#chessBoard {
margin: 2vw auto;
width: 60vw;
height: 60vw;
display: grid;
grid-template-columns: repeat(8, 1fr);
grid-template-rows: repeat(8, 1fr);
@@ -158,4 +155,71 @@
width: 50px;
height: 50px;
pointer-events: none; /* ensures img doesn't steal the click */
}
#colorModal {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: #1e1e1e;
padding: 20px;
border-radius: 10px;
box-shadow: 0 0 20px rgba(0,0,0,0.6);
color: white;
z-index: 1000;
text-align: center;
}
#colorModal p {
margin-bottom: 15px;
font-size: 18px;
font-weight: bold;
}
#colorButtonContainer {
display: flex;
gap: 10px;
justify-content: center;
}
#colorButtonContainer button {
background-color: #2c2c2c;
border: none;
padding: 10px 18px;
border-radius: 8px;
cursor: pointer;
transition: transform 0.2s ease;
color: white;
}
#colorButtonContainer button:hover {
transform: scale(1.1);
background-color: #3a3a3a;
}
.chessSquare .coordinate-label {
position: absolute;
font-size: 1.2vw;
font-weight: bold;
pointer-events: none;
user-select: none;
}
.chessSquare.light .coordinate-label {
color: #656770;
}
.chessSquare.dark .coordinate-label {
color: #ccc;
}
.chessSquare .row-label {
top: 2px;
left: 4px;
}
.chessSquare .col-label {
bottom: 2px;
right: 4px;
}
+396 -104
View File
@@ -1,137 +1,429 @@
html, body {
background-color: #2b2c30;
color: #d6d6d6;
cursor: default;
:root {
--bg: #2b2c30;
--panel: #21232a;
--panel-row: rgba(255, 255, 255, 0.035);
--bar: #1e2026;
--accent: #8cd5ed;
--accent-ink: #10222a;
--text: #d6d6d6;
--muted: #8b8f99;
--line: #34373f;
--bar-h: clamp(34px, 5.5vh, 50px);
--radius: 12px;
--font: 'Outfit', system-ui, -apple-system, Segoe UI, sans-serif;
}
html, body {
height: 100%;
margin: 0;
background: var(--bg);
color: var(--text);
font-family: var(--font);
cursor: default;
/* The chess page is a fixed, single-screen app: never scroll. */
overflow: hidden;
}
#startGameBtn {
background-color: #8cd5ed;
color: #262626;
border-radius: 30px;
border: 0px;
cursor: pointer;
order: 1;
padding: 2vh;
margin: 2vh;
main {
height: 100%;
}
#startCPUGame {
background-color: #8cd5ed;
color: #262626;
border-radius: 30px;
border: 0px;
cursor: pointer;
order: 1;
padding: 2vh;
margin: 2vh;
}
/* ---- Layout ------------------------------------------------------------ */
/* Portrait / narrow: board on top, panel stacked below. */
#chessContainer {
height: 100vh;
height: 100dvh;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-evenly;
height: 100vh;
box-sizing: border-box;
padding: 5vw;
gap: clamp(8px, 1.6vh, 16px);
padding: clamp(10px, 2vh, 20px);
overflow: hidden;
}
#boardArea {
display: flex;
flex-direction: column;
gap: 6px;
flex: 0 0 auto;
width: min(94vw, 56vh);
}
#chessBoard {
aspect-ratio: 1 / 1;
width: 90vw; /* use the smaller of width or height */
width: 100%;
height: auto;
max-height: 90vw;
aspect-ratio: 1 / 1;
border-radius: 6px;
overflow: hidden;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.45);
}
#boardContainer {
align-content: center;
flex-grow: 1;
}
.sideContent {
#gamePanel {
width: min(94vw, 56vh);
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
align-content: center;
justify-content: center;
text-align: center;
background: var(--panel);
border-radius: var(--radius);
overflow: hidden;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
}
#textContainer {
flex-direction: column;
order: -1;
flex-shrink: 1;
font-size: large;
margin: 5vw;
}
#buttonContainer {
flex-grow: 3;
}
/* Put UI to left/right when screen is short */
/* Landscape / wide: board left, game panel right (chess.com style). */
@media (min-aspect-ratio: 1/1) {
#chessContainer {
display: grid;
grid-template-columns: auto, auto;
grid-template-rows: auto, auto;
align-items: center;
flex-direction: row;
align-items: center;
justify-content: center;
align-items: center;
height: 100vh;
padding: 0;
gap: clamp(16px, 3vw, 48px);
padding: clamp(12px, 3vh, 28px);
}
.sideContent {
display: flex;
flex-direction: column;
flex-grow: 1;
flex-shrink: 0;
align-items: center;
text-align: center;
padding: min(5vw, 5vh);
}
#boardContainer {
grid-row: 1 / span 2;
grid-column: 1;
}
#textContainer {
grid-row: 1;
grid-column: 2;
text-align: center;
font-size: x-large;
}
#buttonContainer {
grid-row: 2;
grid-column: 2;
text-align: left;
align-self: start;
}
#startGameBtn {
padding: 1vw;
font-size: large;
}
#startCPUGame {
padding: 1vw;
margin: 1vw;
font-size: large;
#boardArea {
width: auto;
height: 100%;
justify-content: center;
flex: 0 0 auto;
}
#chessBoard {
aspect-ratio: 1 / 1;
flex-shrink: 1;
width: min(90vh, 90vw);
max-height: 90vh;
width: min(72vh, 54vw);
height: min(72vh, 54vw);
}
#difficultyButtonContainer {
grid-template-columns: repeat(10, 1fr);
.playerBar {
width: min(72vh, 54vw);
}
}
#gamePanel {
width: clamp(300px, 26vw, 380px);
height: min(86vh, 100%);
align-self: center;
}
}
/* ---- Player bars ------------------------------------------------------- */
.playerBar {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
box-sizing: border-box;
height: var(--bar-h);
padding: 0 12px;
background: var(--bar);
border-radius: 8px;
}
.playerDot {
width: 14px;
height: 14px;
border-radius: 50%;
flex: 0 0 auto;
}
.playerDot.white {
background: #ededed;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.4) inset;
}
.playerDot.black {
background: #2c2c2c;
box-shadow: 0 0 0 1px #565656 inset;
}
.playerName {
font-weight: 600;
white-space: nowrap;
}
.capturedTray {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1px;
flex: 1;
min-width: 0;
overflow: hidden;
}
.capturedPiece {
height: clamp(15px, calc(var(--bar-h) * 0.58), 26px);
width: auto;
margin-right: -5px;
}
.advantage {
margin-left: auto;
font-weight: 700;
color: var(--accent);
white-space: nowrap;
}
/* ---- Game panel -------------------------------------------------------- */
.panelHeader {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid var(--line);
flex: 0 0 auto;
}
.panelLogo {
font-size: 1.5rem;
color: var(--accent);
line-height: 1;
}
.panelHeader h1 {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
letter-spacing: 0.01em;
}
#moveList {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding: 6px 0;
}
.movePlaceholder {
margin: 0;
padding: 18px 16px;
color: var(--muted);
font-size: 0.95rem;
}
.moveRow {
display: grid;
grid-template-columns: 2.4em 1fr 1fr;
align-items: center;
gap: 6px;
padding: 4px 14px;
font-size: 0.98rem;
}
.moveRow:nth-child(odd) {
background: var(--panel-row);
}
.moveNum {
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.moveSan {
padding: 2px 7px;
border-radius: 5px;
}
.moveSan.latest {
background: var(--accent);
color: var(--accent-ink);
font-weight: 600;
}
#statusLine {
flex: 0 0 auto;
padding: 12px 16px;
border-top: 1px solid var(--line);
color: var(--muted);
font-weight: 500;
}
#statusLine.alert {
color: var(--accent);
font-weight: 700;
}
#panelButtons {
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px 16px 16px;
border-top: 1px solid var(--line);
}
.btn {
font-family: inherit;
font-weight: 600;
font-size: clamp(0.92rem, 1vw, 1.05rem);
border: 0;
border-radius: 8px;
padding: clamp(10px, 1.4vh, 14px) 16px;
cursor: pointer;
transition: transform 0.08s ease, filter 0.15s ease, background 0.15s ease;
}
.btn:active {
transform: translateY(1px);
}
.btn-primary {
background: var(--accent);
color: var(--accent-ink);
}
.btn-primary:hover {
filter: brightness(1.08);
}
.btn-secondary {
background: #363a44;
color: var(--text);
}
.btn-secondary:hover {
background: #424752;
}
.btn-ghost {
background: transparent;
color: var(--accent);
border: 1px solid var(--line);
}
.btn-ghost:hover {
background: #2a2e37;
}
#watchLink {
color: var(--muted);
text-decoration: none;
text-align: center;
font-size: 0.9rem;
margin-top: 2px;
}
#watchLink:hover {
color: var(--accent);
}
/* ---- Mobile chrome (hidden on desktop) --------------------------------- */
.iconBtn {
background: transparent;
border: 0;
color: var(--text);
font-size: 1.7rem;
line-height: 1;
cursor: pointer;
padding: 0 4px;
}
.iconBtn:hover {
color: var(--accent);
}
#menuClose {
margin-left: auto;
}
/* Default (tablet/desktop): no mobile bar, menu button, or sheet chrome. */
#mobileBar,
#menuBackdrop,
#menuClose {
display: none;
}
/* ---- Phone layout ------------------------------------------------------ */
/* The full panel doesn't fit alongside a usable board on a phone, so we show
the board + a slim status/menu bar, and tuck the move list and secondary
actions into a slide-up sheet. The page itself still never scrolls. */
@media (max-width: 640px) {
#chessContainer {
flex-direction: column;
justify-content: flex-start;
gap: 8px;
padding: 8px;
}
#boardArea {
width: min(94vw, 60vh);
flex: 0 0 auto;
}
.playerBar {
width: 100%;
}
/* Right rail becomes a hidden bottom sheet, revealed by the Menu button. */
#gamePanel {
display: none;
position: fixed;
left: 0;
right: 0;
bottom: 0;
top: auto;
width: 100%;
height: auto;
max-height: 85dvh;
border-radius: 16px 16px 0 0;
z-index: 60;
box-shadow: 0 -8px 30px rgba(0, 0, 0, 0.5);
}
body.menu-open #gamePanel {
display: flex;
}
/* The slim bar already shows status, so hide the sheet's inline copy. */
#gamePanel #statusLine {
display: none;
}
#menuClose {
display: block;
}
#menuBackdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
z-index: 55;
}
body.menu-open #menuBackdrop {
display: block;
}
#mobileBar {
display: flex;
align-items: center;
gap: 10px;
width: min(94vw, 60vh);
flex: 0 0 auto;
box-sizing: border-box;
background: var(--panel);
border-radius: 10px;
padding: 8px 10px;
}
#mobileStatus {
flex: 1;
min-width: 0;
color: var(--muted);
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#mobileStatus.alert {
color: var(--accent);
}
#mobileBar #menuToggle {
flex: 0 0 auto;
padding: 10px 20px;
}
}
@@ -0,0 +1,165 @@
html, body {
background-color: #2b2c30;
color: #d6d6d6;
margin: 0;
}
#watchHeader {
text-align: center;
padding: 2rem 1rem 0;
}
#watchHeader h1 {
margin: 0;
}
#watchStatus {
color: #9a9a9a;
}
#watchControls {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 0.75rem;
margin: 1rem 0;
}
.enginePicker {
display: flex;
align-items: center;
gap: 0.4rem;
border: 1px solid #444;
border-radius: 8px;
padding: 0.3rem 0.6rem 0.5rem;
}
.enginePicker legend {
color: #9a9a9a;
font-size: 0.8rem;
padding: 0 0.3rem;
}
#watchControls select {
background-color: #1e1e1e;
color: #d6d6d6;
border: 1px solid #444;
border-radius: 6px;
padding: 0.4rem;
}
#startCpuVsCpu {
background-color: #8cd5ed;
color: #262626;
border: 0;
border-radius: 30px;
padding: 0.6rem 1.2rem;
cursor: pointer;
}
#startCpuVsCpu:hover {
background-color: #a5e0f2;
}
#backToPlay {
color: #8cd5ed;
text-decoration: none;
}
#backToPlay:hover {
text-decoration: underline;
}
#gamesFeed {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
padding: 2rem;
box-sizing: border-box;
}
.gameCard {
background-color: #1e1e1e;
border-radius: 10px;
padding: 1rem;
box-shadow: 0 0 12px rgba(0, 0, 0, 0.4);
position: relative;
cursor: pointer;
}
.gameResult {
text-align: center;
font-weight: bold;
color: #8cd5ed;
margin-top: 0.75rem;
}
.copyPgnBtn {
display: block;
margin: 0.75rem auto 0;
background-color: #8cd5ed;
color: #262626;
border: 0;
border-radius: 30px;
padding: 0.5rem 1rem;
cursor: pointer;
}
.copyPgnBtn:hover {
background-color: #a5e0f2;
}
.backButton {
display: none;
}
body.fullscreen-open {
overflow: hidden;
}
.gameCard.fullscreen {
position: fixed;
inset: 0;
z-index: 1000;
margin: 0;
border-radius: 0;
cursor: default;
background-color: #2b2c30;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.gameCard.fullscreen .miniBoard {
width: min(90vh, 90vw);
height: min(90vh, 90vw);
}
.gameCard.fullscreen .backButton {
display: inline-block;
position: absolute;
top: 1rem;
left: 1rem;
background-color: #8cd5ed;
color: #262626;
border: 0;
border-radius: 30px;
padding: 0.6rem 1.2rem;
cursor: pointer;
}
.gameCardHeader {
text-align: center;
font-weight: bold;
margin-bottom: 0.75rem;
}
.miniBoard {
width: 100%;
aspect-ratio: 1 / 1;
display: grid;
grid-template-columns: repeat(8, 1fr);
grid-template-rows: repeat(8, 1fr);
}
+187
View File
@@ -0,0 +1,187 @@
body {
margin: 0;
background-color: #141414;
color: #d6d6d6;
font-family: "Segoe UI", system-ui, sans-serif;
}
#weightsHeader {
text-align: center;
padding: 1.5rem 1rem 0.5rem;
}
#weightsHeader h1 {
margin: 0 0 0.25rem;
font-size: 1.6rem;
}
#weightsStatus {
color: #9a9a9a;
margin: 0.25rem 0 1rem;
}
#weightsControls {
display: flex;
align-items: center;
justify-content: center;
gap: 1.25rem;
margin-bottom: 0.5rem;
}
.heatLegend {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
color: #9a9a9a;
}
.legendBar {
width: 120px;
height: 10px;
border-radius: 5px;
background: linear-gradient(to right, rgba(232, 74, 74, 1), #2a2a2a, rgba(74, 134, 232, 1));
}
#refreshWeights {
background-color: #8cd5ed;
color: #262626;
border: 0;
border-radius: 30px;
padding: 0.5rem 1.1rem;
cursor: pointer;
}
#refreshWeights:hover {
background-color: #a5e0f2;
}
#backToWatch {
display: inline-block;
margin-top: 0.5rem;
color: #8cd5ed;
text-decoration: none;
}
.weightsSection {
margin: 0 auto;
max-width: 1200px;
padding: 0.5rem 1rem;
}
.weightsSection h2 {
text-align: center;
font-size: 1.2rem;
margin: 1rem 0 0.25rem;
}
.sectionHint {
text-align: center;
color: #9a9a9a;
font-size: 0.8rem;
margin: 0 0 0.75rem;
}
.weightsGrid {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 1.25rem;
padding: 0.5rem 0;
}
.featurePanel {
max-width: 520px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.featureRow {
display: flex;
align-items: center;
gap: 0.6rem;
}
.featureLabel {
flex: 0 0 6.5rem;
font-size: 0.8rem;
color: #c8c8c8;
text-align: right;
}
.featureTrack {
flex: 1 1 auto;
height: 14px;
background-color: #232323;
border-radius: 7px;
overflow: hidden;
}
.featureBar {
height: 100%;
border-radius: 7px;
min-width: 2px;
}
.featureValue {
flex: 0 0 3rem;
font-size: 0.8rem;
color: #e8e8e8;
text-align: left;
}
.weightBoard {
background-color: #1c1c1c;
border: 1px solid #2e2e2e;
border-radius: 10px;
padding: 0.75rem;
}
.weightBoardHeader {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
font-weight: 600;
}
.weightBoardIcon {
width: 22px;
height: 22px;
}
.weightRange {
margin-left: auto;
font-weight: 400;
font-size: 0.75rem;
color: #888;
}
.miniHeat {
display: grid;
grid-template-columns: 1.1rem repeat(8, 34px);
}
.heatSquare {
width: 34px;
height: 34px;
box-sizing: border-box;
border: 1px solid #2a2a2a;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.62rem;
color: #f0f0f0;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
}
.heatLabel {
display: flex;
align-items: center;
justify-content: center;
padding: 2px 0;
font-size: 0.6rem;
color: #777;
}
+65
View File
@@ -0,0 +1,65 @@
/* ── Blog Section ── */
#BlogBox {
max-width: 1100px;
margin: clamp(4rem, 8vw, 8rem) auto;
padding: 0 clamp(1.5rem, 5vw, 4rem);
}
#blogPostsList {
display: flex;
flex-direction: column;
gap: 1rem;
}
.blog-teaser-item {
display: flex;
flex-direction: column;
gap: 0.3rem;
padding: 1.25rem 1.5rem;
border-left: 2px solid var(--color-accent-border);
background: var(--color-surface);
border-radius: 0 8px 8px 0;
transition: background 0.2s;
}
.blog-teaser-item:hover {
background: var(--color-surface-hover);
}
.blog-teaser-title {
font-size: clamp(1rem, 1.5vw, 1.15rem);
font-weight: 600;
color: var(--color-heading-secondary);
text-decoration: none;
transition: color 0.2s;
}
.blog-teaser-title:hover {
color: var(--color-accent);
}
.blog-teaser-date {
font-size: 0.75rem;
color: var(--color-text-subtle);
}
.blog-teaser-summary {
font-size: 0.85rem;
color: var(--color-text-muted);
margin: 0.25rem 0 0;
line-height: 1.6;
}
.blog-view-all {
display: inline-block;
margin-top: 1.5rem;
font-size: 0.85rem;
color: var(--color-accent);
text-decoration: none;
transition: color 0.2s;
}
.blog-view-all:hover {
color: var(--color-accent-hover);
}
+19 -11
View File
@@ -1,17 +1,25 @@
#Contacts {
margin-top: 20vw;
/* ── Footer / Contacts ── */
#Contacts {
display: flex;
justify-content: center;
gap: 1.5rem;
padding: clamp(4rem, 8vw, 6rem) 0 clamp(3rem, 5vw, 4rem);
}
.social-link {
display: flex;
align-items: center;
text-decoration: none;
}
.social-icon {
height: auto;
width: 3.5vw;
margin-bottom: 5vw;
margin-left: 3vw;
fill: rgb(212, 212, 212);
transition: fill 0.5s;
width: clamp(1.25rem, 2.5vw, 1.75rem);
fill: var(--color-text-subtle);
transition: fill 0.2s;
}
.social-icon:hover {
fill: white;
transition: fill 0.5s;
}
.social-link:hover .social-icon {
fill: var(--color-heading-secondary);
}
@@ -1,36 +0,0 @@
.diagonal-section-left {
position: absolute;
clip-path: polygon(0 0, 85vw 0, 60vw 100%, 0 100%);
background-color: rgb(104, 255, 0, 0.39);
transition: clip-path 0.5s ease;
}
.diagonal-section-left:has(.projectImage:hover) {
clip-path: polygon(0 0, 87vw 0, 58vw 100%, 0 100%);
transition: clip-path 0.5s ease;
}
.projectImageLeft {
width: 33vw;
height: auto;
margin: 3vw;
}
.projectTextRight {
position: relative;
z-index: 1;
margin-left: 50vw;
}
.projectHeaderRight {
margin: 0;
color: rgb(212, 212, 212);
font-size: 4vw;
}
.projectDescriptionRight {
margin: 0;
color: rgb(212, 212, 212);
font-size: 2vw;
width: 40vw;
}
@@ -1,38 +0,0 @@
.diagonal-section-right {
position: absolute;
clip-path: polygon(40vw 0, 100% 0, 100% 100%, 15vw 100%);
background-color: rgb(104, 255, 0, 0.39);
transition: clip-path 0.5s ease;
}
.diagonal-section-right:has(.projectImage:hover) {
clip-path: polygon(42vw 0, 100% 0, 100% 100%, 13vw 100%);
transition: clip-path 0.5s ease;
}
.projectImageRight {
width: 33vw;
height: auto;
margin: 3vw;
float: right;
}
.projectTextLeft {
position: relative;
z-index: 1;
}
.projectHeaderLeft {
margin: 0;
color: rgb(212, 212, 212);
font-size: 4vw;
margin-left: 10vw;
}
.projectDescriptionLeft {
margin: 0;
color: rgb(212, 212, 212);
font-size: 2vw;
width: 40vw;
margin-left: 10vw;
}
+78 -17
View File
@@ -1,29 +1,90 @@
#ProjectsBox {
/* ── Projects Section ── */
#ProjectsBox {
max-width: 1100px;
margin: 0 auto;
padding: 0 clamp(1.5rem, 5vw, 4rem);
}
.projects-grid {
display: grid;
grid-auto-rows: 1fr;
gap: 20vw;
grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr));
gap: 1.5rem;
}
.projectContainer {
position: relative;
display: flex;
align-items: center;
/* ── Card ── */
.project-card {
border: 1px solid var(--color-border);
border-radius: 12px;
overflow: hidden;
background: var(--color-surface);
transition: border-color 0.25s, transform 0.25s;
}
.displayBox {
width: 100vw;
height: auto;
.project-card:hover {
border-color: var(--color-border-hover);
transform: translateY(-2px);
}
a.projectContainer {
text-decoration: none;
/* ── Image ── */
.project-image-link {
display: block;
overflow: hidden;
}
.projectImage {
transition: width 0.5s ease;
width: 100%;
height: auto;
display: block;
transition: transform 0.4s ease;
}
.projectImage:hover {
width: 40vw;
transition: width 0.5s ease;
}
.project-card:hover .projectImage {
transform: scale(1.03);
}
/* ── Info ── */
.project-info {
padding: 1.25rem 1.5rem 1.5rem;
}
.project-title {
margin: 0 0 0.5rem;
font-size: 1.1rem;
font-weight: 600;
color: var(--color-heading);
letter-spacing: -0.01em;
}
.project-description {
margin: 0 0 1rem;
font-size: 0.85rem;
line-height: 1.65;
color: var(--color-text-muted);
}
.project-link {
font-size: 0.8rem;
color: var(--color-accent);
text-decoration: none;
transition: color 0.2s;
}
.project-link:hover {
color: var(--color-accent-hover);
}
/* ── Mobile ── */
@media (max-width: 600px) {
.projects-grid {
grid-template-columns: 1fr;
}
.project-info {
padding: 1rem 1.25rem 1.25rem;
}
}
+109 -42
View File
@@ -1,67 +1,134 @@
body {
margin: 0px;
background-color: #2b2c30;
color: #09ff00;
overflow-x: hidden;
cursor: default;
*, *::before, *::after {
box-sizing: border-box;
}
body {
margin: 0;
background-color: var(--color-bg);
color: var(--color-text);
overflow-x: hidden;
font-family: var(--font-body);
line-height: 1.6;
}
/* ── Navigation ── */
#Header {
margin-top: 2vw;
display: flex;
justify-content: space-between;
align-items: center;
max-width: 1100px;
margin: 0 auto;
padding: 2rem clamp(1.5rem, 5vw, 4rem);
}
.headerOption {
font: bold 2vw arial;
display: inline;
margin-left: 10vw;
.site-name {
font-size: 1.05rem;
font-weight: 700;
color: var(--color-heading);
letter-spacing: -0.01em;
}
.nav-links {
display: flex;
gap: clamp(1.25rem, 3vw, 2.5rem);
}
.nav-link {
font-size: 0.875rem;
color: var(--color-text-subtle);
cursor: pointer;
transition: color 0.5s ease, font 0.5s ease;
transition: color 0.2s;
text-decoration: none;
}
.headerOption:hover {
font: bold 2.3vw arial;
transition: color 0.5s ease, font 0.5s ease;
.nav-link:hover {
color: var(--color-heading-secondary);
}
/* ── Hero / Welcome Message ── */
#Hero {
max-width: 1100px;
margin: 0 auto;
padding: clamp(4rem, 10vw, 8rem) clamp(1.5rem, 5vw, 4rem) clamp(4rem, 8vw, 6rem);
}
#WelcomeMessage {
font: 3vw arial;
width: auto;
height: 40vw;
margin-top: 15vw;
text-align: left;
padding-left: 10vw;
padding-right: 10vw;
font-size: clamp(1.25rem, 2.2vw, 1.75rem);
max-width: 720px;
min-height: clamp(12rem, 28vw, 20rem);
line-height: 1.7;
color: var(--color-accent);
font-family: var(--font-mono);
}
/* ── Section Titles ── */
.section-title {
font-size: clamp(1.3rem, 2.2vw, 1.75rem);
font-weight: 700;
color: var(--color-heading);
margin: 0 0 clamp(1.5rem, 3vw, 2.5rem);
letter-spacing: -0.02em;
}
/* ── Demos ── */
#DemosBox {
margin: 10vw;
display: flex;
flex-direction: column;
justify-content: end;
width: 80%;
max-width: 1100px;
margin: clamp(4rem, 8vw, 8rem) auto;
padding: 0 clamp(1.5rem, 5vw, 4rem);
}
#DemoHeader {
margin: 0;
color: rgb(212, 212, 212);
font-size: 4vw;
#buttonWrapper {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(200px, 100%), 1fr));
gap: 0.75rem;
}
.demoButton {
margin-top: 5vw;
margin-right: 2vw;
padding-top: 0.5vw;
padding-bottom: 0.5vw;
padding: 0.875rem 1.25rem;
text-align: center;
border: none;
border-radius: 5vw;
width: 20vw;
height: 8vw;
font-size: 2vw;
background-color: rgb(104, 255, 0, 0.39);
border: 1px solid var(--color-border);
border-radius: 8px;
font-size: 0.875rem;
font-family: inherit;
background: var(--color-surface);
color: var(--color-text);
cursor: pointer;
transition: background 0.2s, border-color 0.2s, color 0.2s;
}
.demoButton:hover {
background: var(--color-surface-hover);
border-color: var(--color-border-hover);
color: var(--color-heading-secondary);
}
#MoreFiller {
cursor: default;
}
opacity: 0.3;
}
#MoreFiller:hover {
background: var(--color-surface);
border-color: var(--color-border);
color: var(--color-text);
}
/* ── Mobile ── */
@media (max-width: 600px) {
#Header {
padding: 1.25rem 1.25rem;
}
.site-name {
font-size: 0.95rem;
}
.nav-link {
font-size: 0.8rem;
}
}
+28
View File
@@ -0,0 +1,28 @@
:root {
/* Backgrounds */
--color-bg: #0a0a0a;
--color-surface: rgba(255, 255, 255, 0.02);
--color-surface-hover: rgba(255, 255, 255, 0.04);
--color-surface-raised: rgba(255, 255, 255, 0.05);
--color-surface-raised-hover: rgba(255, 255, 255, 0.08);
/* Text */
--color-heading: #fff;
--color-heading-secondary: #e0e0e0;
--color-text: #b0b0b0;
--color-text-muted: #aaa;
--color-text-subtle: #777;
/* Accent */
--color-accent: #09ff00;
--color-accent-hover: #5fff5f;
/* Borders */
--color-border: rgba(255, 255, 255, 0.07);
--color-border-hover: rgba(255, 255, 255, 0.15);
--color-accent-border: rgba(9, 255, 0, 0.4);
/* Typography */
--font-body: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

@@ -4,8 +4,8 @@ const ChessAPI = {
return await response.json();
},
async createCPUGame(difficulty) {
const response = await fetch(`/api/chess/new/${difficulty}`);
async createCPUGame(difficulty, color = "random") {
const response = await fetch(`/api/chess/new/${difficulty}?color=${color}`);
return await response.json();
},
@@ -14,6 +14,20 @@ const ChessAPI = {
return await response.json();
},
async getPgn(gameId) {
const response = await fetch(`/api/chess/${gameId}/pgn`);
if (!response.ok) throw new Error("PGN unavailable");
return await response.text();
},
async forfeit(gameId, playerId) {
await fetch("/api/chess/forfeit", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ GameId: gameId, PlayerId: playerId })
});
},
async getLegalMoves(pieceId) {
const response = await fetch(`/api/chess/${GameState.currentGameId}/legalMoves/${pieceId}`);
if (!response.ok) throw new Error("API failed");
@@ -27,13 +41,20 @@ const ChessAPI = {
body: JSON.stringify(moveDto)
});
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.message || "Invalid move or not your turn.");
if (!response.ok) {
let message = await response.text();
throw new Error(message || "Invalid move or not your turn.");
}
return result;
// The move endpoint returns both the move result and the full resulting
// board state, so the caller can render without a follow-up fetch.
const data = await response.json();
if (!data.result.success) {
throw new Error(data.result.message || "Invalid move or not your turn.");
}
return data;
},
async handleMove(targetRow, targetCol) {
@@ -66,16 +87,14 @@ const ChessAPI = {
GameState.setPreviousMove([GameState.selectedPiece.row, GameState.selectedPiece.col], [targetRow, targetCol]);
try {
const moveResult = await this.makeMove(moveDto);
const { result, state } = await this.makeMove(moveDto);
const updatedGame = await this.getGameState(GameState.currentGameId);
ChessBoard.renderPieces(updatedGame.pieces);
ChessBoard.renderState(state);
GameState.clearSelection();
ChessInteractions.clearHighlights();
await ChessSignalR.notifyMoveMade(moveDto, moveResult);
this.alertGameStatusChange(moveResult);
this.alertGameStatusChange(result);
} catch (error) {
alert("❌ " + error.message);
@@ -96,9 +115,13 @@ const ChessAPI = {
} else if (moveResult.isStalemate) {
alert("🤝 Stalemate!");
gameOver = true;
} else if (moveResult.isThreefoldRepetition) {
alert("🤝 Draw by threefold repetition!");
gameOver = true;
}
if (gameOver) {
showCopyPgn(GameState.currentGameId);
await ChessSignalR.leaveGame();
}
}, 500);
@@ -1,11 +1,141 @@
// Material value per piece type, indexed by PieceType (0=Pawn .. 5=King).
const PIECE_VALUES = [1, 5, 3, 3, 9, 0];
const ChessBoard = {
// Render from a full game-state payload (the shape returned by the move/state
// endpoints and pushed over SignalR). Ignores state older than what's already
// shown, so a slow initial fetch can't clobber a move that arrived first.
renderState(state) {
if (!state || !GameState.shouldApply(state.version)) return;
this.renderPieces(state.pieces);
this.renderCapturedTrays(state.pieces, state.capturedPieces);
this.renderMoveList(state.sanHistory);
this.renderStatus(state);
GameState.setVersion(state.version);
},
// Two-column numbered move list (white move, black move), latest highlighted.
renderMoveList(sanHistory) {
const list = document.getElementById("moveList");
if (!list) return;
const san = sanHistory ?? [];
if (san.length === 0) {
list.innerHTML = '<p class="movePlaceholder">Moves will appear here once a game begins.</p>';
return;
}
list.innerHTML = "";
for (let i = 0; i < san.length; i += 2) {
const row = document.createElement("div");
row.className = "moveRow";
const num = document.createElement("span");
num.className = "moveNum";
num.textContent = `${i / 2 + 1}.`;
row.appendChild(num);
row.appendChild(this.moveCell(san[i], i === san.length - 1));
if (i + 1 < san.length)
row.appendChild(this.moveCell(san[i + 1], i + 1 === san.length - 1));
list.appendChild(row);
}
list.scrollTop = list.scrollHeight;
},
moveCell(san, isLatest) {
const cell = document.createElement("span");
cell.className = isLatest ? "moveSan latest" : "moveSan";
cell.textContent = san;
return cell;
},
renderStatus(state) {
let text;
let alert = false;
if (state.isCheckmate) {
const winner = state.currentPlayer === "White" ? "Black" : "White";
text = `Checkmate — ${winner} wins`;
alert = true;
} else if (state.isStalemate) {
text = "Draw — stalemate";
alert = true;
} else if (state.isThreefoldRepetition) {
text = "Draw — threefold repetition";
alert = true;
} else {
text = `${state.currentPlayer} to move`;
if (state.isCheck) {
text += " — check";
alert = true;
}
}
// Mirror to both the desktop panel status and the mobile bar status.
["statusLine", "mobileStatus"].forEach(id => {
const el = document.getElementById(id);
if (!el) return;
el.textContent = text;
el.classList.toggle("alert", alert);
});
},
renderPieces(pieces) {
this.clearAllSquares();
this.renderCoordinateLabels();
this.placePieces(pieces);
this.setupSquareEventHandlers();
this.highlightPreviousMove();
},
// Show each side's captured pieces and the leading side's material advantage,
// arranged so the current player's tray sits below the board.
renderCapturedTrays(activePieces, capturedPieces) {
const captured = capturedPieces ?? [];
// A captured piece's color is the side that lost it, so White's haul is the
// captured Black pieces, and vice versa.
const whiteCaptured = captured.filter(p => p.color === 1);
const blackCaptured = captured.filter(p => p.color === 0);
// Net material from pieces still on the board, so promotions count correctly.
const advantage = (activePieces ?? []).reduce(
(sum, p) => sum + (p.color === 0 ? PIECE_VALUES[p.type] : -PIECE_VALUES[p.type]), 0);
const white = { captured: whiteCaptured, advantage: Math.max(advantage, 0) };
const black = { captured: blackCaptured, advantage: Math.max(-advantage, 0) };
const isWhite = GameState.currentPlayerIsWhite !== false;
this.fillCapturedTray("bottom", isWhite ? white : black);
this.fillCapturedTray("top", isWhite ? black : white);
},
fillCapturedTray(position, side) {
const tray = document.getElementById(`captured-${position}`);
const badge = document.getElementById(`advantage-${position}`);
if (!tray || !badge) return;
tray.innerHTML = "";
[...side.captured]
.sort((a, b) => PIECE_VALUES[a.type] - PIECE_VALUES[b.type])
.forEach(piece => {
const img = document.createElement("img");
img.src = ChessUtils.getPieceImageUrl(piece);
img.alt = piece.type;
img.className = "capturedPiece";
img.draggable = false;
tray.appendChild(img);
});
badge.textContent = side.advantage > 0 ? `+${side.advantage}` : "";
},
clearAllSquares() {
for (let i = 0; i < 64; i++) {
const square = document.getElementById(`square-${i}`);
@@ -106,6 +236,39 @@ const ChessBoard = {
ChessAPI.handleMove(targetRow, targetCol);
};
}
},
renderCoordinateLabels() {
const files = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'];
const ranks = ['8', '7', '6', '5', '4', '3', '2', '1'];
// If player is black, reverse the coordinates
if (!GameState.currentPlayerIsWhite) {
files.reverse();
ranks.reverse();
}
for (let i = 0; i < 64; i++) {
const square = document.getElementById(`square-${i}`);
const row = Math.floor(i / 8);
const col = i % 8;
// Add rank label (1-8) on the leftmost column
if (col === 0) {
const rankLabel = document.createElement('span');
rankLabel.className = 'coordinate-label row-label';
rankLabel.textContent = ranks[row];
square.appendChild(rankLabel);
}
// Add file label (a-h) on the bottom row
if (row === 7) {
const fileLabel = document.createElement('span');
fileLabel.className = 'coordinate-label col-label';
fileLabel.textContent = files[col];
square.appendChild(fileLabel);
}
}
}
};
@@ -23,6 +23,16 @@ const ChessModals = {
resolve(difficulty);
};
});
},
promptColor() {
return new Promise(resolve => {
document.getElementById("colorModal").style.display = "block";
window.selectColor = (color) => {
document.getElementById("colorModal").style.display = "none";
resolve(color);
};
});
}
};
@@ -11,8 +11,12 @@ const ChessSignalR = {
console.error("❌ SignalR connection closed:", err?.message);
});
this.connection.on("ReceiveMoveUpdate", async (gameId, moveDto, moveResultDto) => {
await this.handleMoveUpdate(gameId, moveDto, moveResultDto);
this.connection.on("ReceiveMoveUpdate", async (gameId, moveDto, moveResultDto, state) => {
await this.handleMoveUpdate(gameId, moveDto, moveResultDto, state);
});
this.connection.on("ReceiveGameOver", async (gameId, winner, reason) => {
await this.handleGameOver(gameId, winner, reason);
});
try {
@@ -24,20 +28,28 @@ const ChessSignalR = {
}
},
async handleMoveUpdate(gameId, moveDto, moveResultDto) {
async handleMoveUpdate(gameId, moveDto, moveResultDto, state) {
if (gameId !== GameState.currentGameId) return;
const gameState = await ChessAPI.getGameState(gameId);
// Drop the echo of our own move and any out-of-order delivery.
if (!GameState.shouldApply(state?.version)) return;
GameState.setPreviousMove([moveDto.sourceRow, moveDto.sourceCol], [moveDto.targetRow, moveDto.targetCol]);
ChessBoard.renderPieces(gameState.pieces);
ChessBoard.renderState(state);
ChessAPI.alertGameStatusChange(moveResultDto);
},
async notifyMoveMade(moveDto, moveResult) {
if (this.connection) {
await this.connection.invoke("MoveMade", GameState.currentGameId, moveDto, moveResult);
}
async handleGameOver(gameId, winner, reason) {
if (gameId !== GameState.currentGameId) return;
const youWon = (winner === "White") === GameState.currentPlayerIsWhite;
if (reason === "forfeit")
alert(youWon ? "🏳️ Your opponent forfeited — you win!" : "🏳️ You forfeited this game.");
showCopyPgn(gameId);
await this.leaveGame();
},
async leaveGame() {
@@ -6,6 +6,10 @@ const GameState = {
legalMoves: [],
previousMoveStart: null,
previousMoveEnd: null,
// Highest ply (move count) already rendered. Lets us ignore stale or
// already-applied updates that arrive out of order, including the echo
// of our own move.
lastVersion: -1,
reset() {
this.currentGameId = null;
@@ -15,12 +19,22 @@ const GameState = {
this.legalMoves = [];
this.previousMoveStart = null;
this.previousMoveEnd = null;
this.lastVersion = -1;
},
shouldApply(version) {
return typeof version !== "number" || version > this.lastVersion;
},
setVersion(version) {
if (typeof version === "number") this.lastVersion = version;
},
setGameInfo(gameId, playerId, isWhite) {
this.currentGameId = gameId;
this.currentPlayerId = playerId;
this.currentPlayerIsWhite = isWhite;
this.lastVersion = -1;
},
setSelectedPiece(piece) {
@@ -0,0 +1,341 @@
const Spectate = {
connection: null,
games: new Map(), // gameId -> { isVsComputer, isComputerVsComputer, result }
pgns: new Map(), // gameId -> PGN text (prefetched when a game finishes)
pieceTypeNames: ["Pawn", "Rook", "Knight", "Bishop", "Queen", "King"],
async init() {
this.connection = new signalR.HubConnectionBuilder()
.withUrl("/chessHub")
.configureLogging(signalR.LogLevel.Warning)
.build();
this.connection.on("ReceiveMoveUpdate", (gameId, moveDto, _moveResult, state) =>
this.handleMoveUpdate(gameId, moveDto, state));
this.connection.on("ReceiveGameOver", (gameId) => this.removeGame(gameId));
try {
await this.connection.start();
} catch (err) {
console.error("❌ SignalR failed to start:", err);
}
await this.refreshGames();
await this.loadAutoTrainCount();
setInterval(() => this.refreshGames(), 5000);
},
// Auto-training runs server-side; show its target count and let it be changed here.
async loadAutoTrainCount() {
const input = document.getElementById("autoTrainCount");
if (!input) return;
try {
const response = await fetch("/api/chess/autotrain");
const data = await response.json();
input.max = data.max;
// Don't clobber the value while the user is editing it.
if (document.activeElement !== input)
input.value = data.count;
} catch {
// Leave the control as-is if auto-training status can't be read.
}
},
async setAutoTrainCount() {
const input = document.getElementById("autoTrainCount");
const count = Math.max(0, parseInt(input.value, 10) || 0);
try {
const response = await fetch(`/api/chess/autotrain?count=${count}`, { method: "POST" });
const data = await response.json();
input.value = data.count;
} catch (err) {
console.error("❌ Could not set the auto-training game count.", err);
}
},
async startCpuGame() {
const params = new URLSearchParams({
whiteEngine: document.getElementById("whiteEngine").value,
whiteSkill: document.getElementById("whiteSkill").value,
blackEngine: document.getElementById("blackEngine").value,
blackSkill: document.getElementById("blackSkill").value
});
try {
await fetch(`/api/chess/watch/cpu?${params}`);
await this.refreshGames();
} catch (err) {
console.error("❌ Could not start CPU vs CPU game.", err);
}
},
async refreshGames() {
let games;
try {
const response = await fetch("/api/chess/active");
games = await response.json();
} catch {
return;
}
const activeIds = new Set(games.map(g => g.gameId));
for (const gameId of [...this.games.keys()])
if (!activeIds.has(gameId))
await this.removeGame(gameId);
for (const game of games)
if (this.games.has(game.gameId))
this.updateHeader(game.gameId, game.currentPlayer, game.moveCount, game.isCheck);
else
await this.addGame(game);
const status = document.getElementById("watchStatus");
status.textContent = games.length === 0
? "No games are being played right now."
: `${games.length} game${games.length === 1 ? "" : "s"} in progress`;
},
async addGame(game) {
this.games.set(game.gameId, {
isVsComputer: game.isVsComputer,
isComputerVsComputer: game.isComputerVsComputer,
whiteEngine: game.whiteEngine,
blackEngine: game.blackEngine
});
const card = document.createElement("div");
card.className = "gameCard";
card.id = `card-${game.gameId}`;
card.onclick = () => this.enterFullscreen(game.gameId);
const back = document.createElement("button");
back.className = "backButton";
back.textContent = "← Back";
back.onclick = (event) => this.exitFullscreen(game.gameId, event);
card.appendChild(back);
const header = document.createElement("div");
header.className = "gameCardHeader";
header.id = `header-${game.gameId}`;
header.textContent = this.headerText(this.games.get(game.gameId), game.currentPlayer, game.moveCount, game.isCheck);
card.appendChild(header);
const board = document.createElement("div");
board.className = "miniBoard";
for (let i = 0; i < 64; i++) {
const square = document.createElement("div");
square.id = `sq-${game.gameId}-${i}`;
square.className = `chessSquare ${(i + Math.floor(i / 8)) % 2 === 0 ? "light" : "dark"}`;
board.appendChild(square);
}
card.appendChild(board);
document.getElementById("gamesFeed").appendChild(card);
await this.connection.invoke("JoinWebsocketGroup", game.gameId).catch(() => { });
await this.renderGame(game.gameId);
},
async removeGame(gameId) {
this.games.delete(gameId);
this.pgns.delete(gameId);
document.getElementById(`card-${gameId}`)?.remove();
await this.connection.invoke("LeaveWebsocketGroup", gameId).catch(() => { });
},
async renderGame(gameId) {
const response = await fetch(`/api/chess/${gameId}`);
if (!response.ok) return;
this.renderFromState(gameId, await response.json());
},
renderFromState(gameId, state) {
const result = this.resultTextFromState(state);
const stored = this.games.get(gameId);
if (stored) stored.result = result;
this.renderPieces(gameId, state.pieces);
this.updateHeader(gameId, state.currentPlayer, state.moveHistory.length, state.isCheck);
this.setResult(gameId, result);
},
async handleMoveUpdate(gameId, moveDto, state) {
if (!this.games.has(gameId)) return;
// Render from the pushed state; fall back to a fetch only if it's missing.
if (state) this.renderFromState(gameId, state);
else await this.renderGame(gameId);
this.highlightMove(gameId, moveDto);
},
renderPieces(gameId, pieces) {
this.clearBoard(gameId);
pieces.forEach(piece => {
const square = document.getElementById(`sq-${gameId}-${piece.row * 8 + piece.col}`);
if (!square) return;
const img = document.createElement("img");
img.src = this.pieceImageUrl(piece);
img.alt = piece.type;
img.className = "chessPiece";
img.draggable = false;
square.appendChild(img);
});
},
clearBoard(gameId) {
for (let i = 0; i < 64; i++) {
const square = document.getElementById(`sq-${gameId}-${i}`);
if (!square) continue;
square.innerHTML = "";
square.classList.remove("previous-start", "previous-end");
}
},
highlightMove(gameId, moveDto) {
document.getElementById(`sq-${gameId}-${moveDto.sourceRow * 8 + moveDto.sourceCol}`)?.classList.add("previous-start");
document.getElementById(`sq-${gameId}-${moveDto.targetRow * 8 + moveDto.targetCol}`)?.classList.add("previous-end");
},
updateHeader(gameId, currentPlayer, moveCount, isCheck) {
const stored = this.games.get(gameId);
const header = document.getElementById(`header-${gameId}`);
if (stored && header)
header.textContent = this.headerText(stored, currentPlayer, moveCount, isCheck);
},
gameLabel(stored) {
if (stored.isComputerVsComputer)
return `${this.engineName(stored.whiteEngine)} (W) vs ${this.engineName(stored.blackEngine)} (B)`;
if (stored.isVsComputer) return "Vs CPU";
return "Player vs Player";
},
engineName(kind) {
switch (kind) {
case "CustomLearned": return "Learned";
case "Custom": return "Custom";
case "Stockfish": return "Stockfish";
default: return kind || "CPU";
}
},
headerText(stored, currentPlayer, moveCount, isCheck) {
if (stored.result)
return `${this.gameLabel(stored)} · move ${moveCount} · final`;
const check = isCheck ? " • check" : "";
return `${this.gameLabel(stored)} · move ${moveCount} · ${currentPlayer} to move${check}`;
},
resultTextFromState(state) {
if (state.isCheckmate)
return `${state.currentPlayer === "White" ? "Black" : "White"} wins by checkmate`;
if (state.isStalemate)
return "Draw — stalemate";
if (state.isThreefoldRepetition)
return "Draw — threefold repetition";
return null;
},
setResult(gameId, text) {
const card = document.getElementById(`card-${gameId}`);
if (!card) return;
let banner = card.querySelector(".gameResult");
if (!text) {
banner?.remove();
card.querySelector(".copyPgnBtn")?.remove();
card.classList.remove("over");
return;
}
if (!banner) {
banner = document.createElement("div");
banner.className = "gameResult";
card.appendChild(banner);
}
banner.textContent = text;
card.classList.add("over");
this.addCopyPgn(gameId, card);
},
addCopyPgn(gameId, card) {
if (card.querySelector(".copyPgnBtn")) return;
const btn = document.createElement("button");
btn.className = "copyPgnBtn";
btn.textContent = "Copy PGN";
btn.onclick = (event) => { event.stopPropagation(); this.copyPgn(gameId); };
card.appendChild(btn);
// Prefetch now (while the game is still in memory) so copy works during the
// brief window before the finished game is cleaned up.
fetch(`/api/chess/${gameId}/pgn`)
.then(r => r.ok ? r.text() : null)
.then(t => { if (t) this.pgns.set(gameId, t); })
.catch(() => { });
},
async copyPgn(gameId) {
let pgn = this.pgns.get(gameId);
if (!pgn) {
try {
const r = await fetch(`/api/chess/${gameId}/pgn`);
if (r.ok) pgn = await r.text();
} catch { /* ignore */ }
}
if (!pgn) {
alert("PGN is no longer available for this game.");
return;
}
try {
await navigator.clipboard.writeText(pgn);
alert("📋 PGN copied to clipboard!");
} catch {
alert("Couldn't access the clipboard. Here's the PGN:\n\n" + pgn);
}
},
enterFullscreen(gameId) {
document.getElementById(`card-${gameId}`)?.classList.add("fullscreen");
document.body.classList.add("fullscreen-open");
},
exitFullscreen(gameId, event) {
event?.stopPropagation();
document.getElementById(`card-${gameId}`)?.classList.remove("fullscreen");
document.body.classList.remove("fullscreen-open");
},
pieceImageUrl(piece) {
const color = piece.color === 0 ? "White" : "Black";
return `/images/Chess Images/${color}${this.pieceTypeNames[piece.type]}.svg`;
}
};
window.addEventListener("load", () => Spectate.init());
console.log("Spectate.js loaded");
@@ -0,0 +1,158 @@
const Weights = {
async init() {
await this.refresh();
},
async refresh() {
let data;
try {
const response = await fetch("/api/chess/weights");
data = await response.json();
} catch (err) {
console.error("❌ Could not load weights.", err);
document.getElementById("weightsStatus").textContent = "Could not load weights.";
return;
}
this.renderBoardSet(data.mg, "weightsGridMg");
this.renderBoardSet(data.eg, "weightsGridEg");
this.renderFeatures(data.features);
const trained = [...data.mg, ...data.eg].some(b => b.squares.some(v => v !== 0))
|| data.features.some(f => f.value !== 0);
document.getElementById("weightsStatus").textContent = trained
? "Where the learned engine thinks each piece belongs. Blue = preferred, red = avoided. Midgame vs endgame tables are blended by how much material is left."
: "No training yet — everything is neutral. Run some learned games on the Watch page.";
},
// pieces: [{ name, squares[64] }]. Prepends an "Overall" board summing the set.
renderBoardSet(pieces, containerId) {
const overall = new Array(64).fill(0);
for (const piece of pieces)
for (let sq = 0; sq < 64; sq++)
overall[sq] += piece.squares[sq];
const boards = [{ name: "Overall", squares: overall }, ...pieces];
const grid = document.getElementById(containerId);
grid.innerHTML = "";
boards.forEach(board => grid.appendChild(this.buildBoard(board)));
},
buildBoard(board) {
const wrapper = document.createElement("div");
wrapper.className = "weightBoard";
const maxAbs = board.squares.reduce((m, v) => Math.max(m, Math.abs(v)), 0);
const header = document.createElement("div");
header.className = "weightBoardHeader";
if (board.name !== "Overall") {
const icon = document.createElement("img");
icon.src = `/images/Chess Images/White${board.name}.svg`;
icon.alt = board.name;
icon.className = "weightBoardIcon";
header.appendChild(icon);
}
const title = document.createElement("span");
title.textContent = board.name;
header.appendChild(title);
const range = document.createElement("span");
range.className = "weightRange";
range.textContent = maxAbs === 0 ? "neutral" : `±${maxAbs}`;
header.appendChild(range);
wrapper.appendChild(header);
const heat = document.createElement("div");
heat.className = "miniHeat";
for (let row = 0; row < 8; row++) {
const rankLabel = document.createElement("div");
rankLabel.className = "heatLabel";
rankLabel.textContent = 8 - row; // rank 8 at top, 1 at bottom
heat.appendChild(rankLabel);
for (let col = 0; col < 8; col++) {
const rank = 7 - row; // rank index, 0 = rank 1
const sq = rank * 8 + col; // white-relative square (A1 = 0)
heat.appendChild(this.buildSquare(board.squares[sq], sq, maxAbs));
}
}
heat.appendChild(this.cornerSpacer());
for (let col = 0; col < 8; col++) {
const fileLabel = document.createElement("div");
fileLabel.className = "heatLabel";
fileLabel.textContent = String.fromCharCode(97 + col);
heat.appendChild(fileLabel);
}
wrapper.appendChild(heat);
return wrapper;
},
buildSquare(value, sq, maxAbs) {
const cell = document.createElement("div");
cell.className = "heatSquare";
if (value !== 0 && maxAbs > 0) {
const ratio = Math.abs(value) / maxAbs;
const alpha = (0.12 + 0.88 * ratio).toFixed(3);
cell.style.backgroundColor = value > 0
? `rgba(74, 134, 232, ${alpha})` // high -> blue
: `rgba(232, 74, 74, ${alpha})`; // low -> red
cell.textContent = value;
}
const file = String.fromCharCode(97 + (sq & 7));
const rank = (sq >> 3) + 1;
cell.title = `${file}${rank}: ${value}`;
return cell;
},
cornerSpacer() {
const spacer = document.createElement("div");
spacer.className = "heatLabel";
return spacer;
},
// features: [{ name, value }]
renderFeatures(features) {
const panel = document.getElementById("featureWeights");
panel.innerHTML = "";
const maxAbs = features.reduce((m, f) => Math.max(m, Math.abs(f.value)), 0);
features.forEach(feature => {
const row = document.createElement("div");
row.className = "featureRow";
const label = document.createElement("span");
label.className = "featureLabel";
label.textContent = feature.name;
const track = document.createElement("div");
track.className = "featureTrack";
const bar = document.createElement("div");
bar.className = "featureBar";
const ratio = maxAbs === 0 ? 0 : Math.abs(feature.value) / maxAbs;
bar.style.width = `${(ratio * 100).toFixed(1)}%`;
bar.style.backgroundColor = feature.value >= 0
? "rgba(74, 134, 232, 0.85)"
: "rgba(232, 74, 74, 0.85)";
track.appendChild(bar);
const value = document.createElement("span");
value.className = "featureValue";
value.textContent = feature.value;
row.append(label, track, value);
panel.appendChild(row);
});
}
};
window.addEventListener("load", () => Weights.init());
console.log("Weights.js loaded");
@@ -1,5 +1,65 @@
let lastPgn = null;
async function showCopyPgn(gameId) {
try {
lastPgn = await ChessAPI.getPgn(gameId);
const btn = document.getElementById("copyPgnBtn");
if (btn) btn.style.display = "";
} catch (err) {
console.warn("Could not load PGN.", err);
}
}
async function copyPgn() {
if (!lastPgn) return;
try {
await navigator.clipboard.writeText(lastPgn);
alert("📋 PGN copied to clipboard!");
} catch {
alert("Couldn't access the clipboard. Here's the PGN:\n\n" + lastPgn);
}
}
function resetCopyPgn() {
lastPgn = null;
const btn = document.getElementById("copyPgnBtn");
if (btn) btn.style.display = "none";
}
window.copyPgn = copyPgn;
window.showCopyPgn = showCopyPgn;
// Mobile slide-up menu (move list + secondary actions).
function toggleMenu() {
document.body.classList.toggle("menu-open");
}
function closeMenu() {
document.body.classList.remove("menu-open");
}
window.toggleMenu = toggleMenu;
window.closeMenu = closeMenu;
async function forfeitCurrentGame() {
const gameId = ChessUtils.getCookie("chessGameId");
const playerId = ChessUtils.getCookie("chessPlayerId");
if (!gameId || !playerId) return;
try {
await ChessAPI.forfeit(gameId, playerId);
} catch (err) {
console.warn("Could not forfeit previous game.", err);
}
}
async function startNewGame() {
closeMenu();
await ChessSignalR.stopConnection();
await forfeitCurrentGame();
resetCopyPgn();
try {
const gameData = await ChessAPI.joinGame();
@@ -16,7 +76,7 @@ async function startNewGame() {
await ChessSignalR.setupConnection();
const gameState = await ChessAPI.getGameState(gameData.gameId);
ChessBoard.renderPieces(gameState.pieces);
ChessBoard.renderState(gameState);
} catch (error) {
console.error("Failed to start new game:", error);
@@ -24,11 +84,15 @@ async function startNewGame() {
}
async function startCPUGame() {
closeMenu();
await ChessSignalR.stopConnection();
await forfeitCurrentGame();
resetCopyPgn();
try {
const difficulty = await ChessModals.promptDifficulty();
const gameData = await ChessAPI.createCPUGame(difficulty);
const color = await ChessModals.promptColor();
const gameData = await ChessAPI.createCPUGame(difficulty, color);
GameState.setGameInfo(gameData.gameId, gameData.id, gameData.isWhite);
GameState.clearPreviousMove();
@@ -42,7 +106,7 @@ async function startCPUGame() {
await ChessSignalR.setupConnection();
const gameState = await ChessAPI.getGameState(gameData.gameId);
ChessBoard.renderPieces(gameState.pieces);
ChessBoard.renderState(gameState);
} catch (error) {
console.error("Failed to start CPU game:", error);
@@ -62,7 +126,7 @@ async function resumeSavedGame() {
GameState.setGameInfo(savedGameId, savedPlayerId, savedPlayerIsWhite === "true");
await ChessSignalR.setupConnection();
ChessBoard.renderPieces(gameState.pieces);
ChessBoard.renderState(gameState);
} catch (err) {
console.warn("Saved game not found or expired.", err);
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More