Compare commits

..
Author SHA1 Message Date
jheaps 139e2b831c Merge pull request 'Socialism: make the lock hold, and close its back doors' (#125) from feature/socialism into master
Build and Deploy / build-deploy (push) Successful in 1m25s
Reviewed-on: #125
2026-09-25 11:08:48 -06:00
jheaps 96d2dbe5ad Merge branch 'master' into feature/socialism 2026-09-25 11:08:31 -06:00
jheapsandClaude Opus 5.5 2c269d075d Socialism: pay capital accounts on a fixed schedule so they stay clear of pension law
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-25 07:42:01 -06:00
jheapsandClaude Opus 5.5 7baa6e9810 Socialism: say where labor law leaves worker-owners out and how the co-op fills the gaps
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-25 07:08:43 -06:00
jheapsandClaude Opus 5.5 f76b766d0e Socialism: measure founders on both sides so a pilot result says how much was down to who applied
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-25 03:08:51 -06:00
jheapsandClaude Opus 5.5 aad548cd07 Socialism: name the data that decides which low-paid care work qualifies for public help
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-25 02:38:30 -06:00
jheapsandClaude Opus 5.5 f4271831f4 Socialism: price the savings insurance pool from real failure data and size it for a long recession
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-25 02:03:24 -06:00
jheapsandClaude Opus 5.5 1e82052324 Socialism: fit profit sharing to co-op tax law so members are taxed once
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-24 21:44:16 -06:00
jheapsandClaude Opus 5.5 a9efaa2725 Socialism: keep the co-op bank from drifting toward only the safest co-ops
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-24 21:07:30 -06:00
jheapsandClaude Opus 5.5 19bba58f7c Socialism: draft the bankruptcy amendment that keeps a failed co-op a co-op
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-24 17:39:10 -06:00
jheapsandClaude Opus 5.5 4006764a23 Socialism: stop high pay and worn-out equipment from draining the reserve, and check big bets
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-24 17:09:11 -06:00
jheapsandClaude Opus 5.5 a0b16e7d48 Socialism: work through the 1/N problem, with team bonuses and a fair dismissal process
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-24 16:33:57 -06:00
jheapsandClaude Opus 5.5 059299db74 Socialism: let a formula, not lobbyists, decide which low-paid work gets public help
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-24 15:59:16 -06:00
jheapsandClaude Opus 5.5 20d0ee8910 Socialism: let co-ops grow by starting new co-ops, with rules against misuse
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-24 11:35:54 -06:00
jheapsandClaude Opus 5.5 e6befaff5e Socialism: work through scale after a pass, and a local test of the labor-market effect
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-24 11:05:38 -06:00
jheapsandClaude Opus 5.5 8305c72843 Socialism: test help before the first loan, and ask founders for a stake
Add before-launch.md: a founding lottery testing whether serious
pre-launch help (a founder's stipend, a feasibility study, legal setup)
creates more co-ops that last, measured independently of the bank, and
a 5% founder stake payable in verified unpaid work. Fund it from the
loan budget, and correct the pilot's growth arithmetic.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-24 06:31:46 -06:00
jheapsandClaude Opus 5.5 1f297c244d Socialism: decide pay in the open, with the lower-paid protected
Add pay.md: pay bands open to every worker, a ratio each co-op must
choose and measure honestly, a double majority for high pay and for
raises to jobs most members hold, and no one voting alone on their own
pay. Stop the reserve rule from charging co-ops for raising the bottom,
and close its gaps for perks, non-members and related companies.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-24 05:59:02 -06:00
jheapsandClaude Opus 5.5 a77d985135 Socialism: make membership work where people don't stay long
Add high-turnover.md: in home care, a 12-month probation and the
three-quarters rule could fail a co-op doing everything right. Make
membership automatic after a short probation, take the buy-in from
profit shares rather than pay, count agency hours and push-outs, and
phase the rule in. Align the lock, the pilot's safeguards and measures,
worker ownership, the index and open problems.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-24 02:03:06 -06:00
jheapsandClaude Opus 5.5 78650c8ee7 Socialism: work through Conservice as a co-op counterfactual
Add conservice.md: the company as it is (from public sources only),
the same business founded as a locked co-op, what the profit split
could mean at its scale under labeled assumptions, whether it would
have grown, and who would have received less. Link it from the
thesis, worker ownership and the index.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-24 01:33:27 -06:00
jheapsandClaude Opus 5.5 d4f12c2b47 Socialism: lend for voluntary sales to workers, on a separate track
Add buyouts.md: owners who choose to sell a healthy firm to its workers
can borrow on a separate $100M track with its own test (money plus a
pay floor), a capped independent price, a seller note that shares the
risk, a vote of the whole workforce and the lock. Restate the total
worst case as $450M and answer open problem #12.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-24 00:59:20 -06:00
jheapsandClaude Opus 5.5 037e29055a Socialism: build the bank to survive a hostile government
Add "Making It Last": how public programs get killed (Ex-Im, CDFI Fund,
GGRF, NCCB) and survive (Bank of North Dakota, electric co-ops, Farm
Credit), and rules that assume any president can replace the board
after Trump v. Slaughter. Lending standards fixed in law, a "not tested"
verdict if good applicants are turned away, no freeze from board
vacancies, honest limits on where the money sits, and federal test with
state copies.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-23 21:03:11 -06:00
jheapsandClaude Opus 5.5 27dd0ccb3e Socialism: insure workers' final wages and savings, without taxpayers
Add savings-guarantee.md: a co-op-funded insurance pool covering final
unpaid wages, former members' payouts and a protected slice of current
accounts, priced for near-zero recovery by an independent actuary, with
a credit-union-style deposit, phased coverage, pro-rata payment when
short, and rules against phantom profits, early quitting and fraud
rings. Co-ops may no longer take member deposits or sell members bonds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-23 20:28:16 -06:00
jheapsandClaude Opus 5.5 4c1c81fd2f Socialism: make the lock hold, and close its back doors
Explain why a permanent lock has precedent (UK CICs, steward ownership,
US nonprofit conversions) and what it prevents (building-society
demutualisation). Enforce it in layers: an independent veto-only lock
share, articles, statute, and a backstop that sends the value to the
federation fund if the lock is ever broken. Treat piecemeal asset sales,
hire-backs and subsidiaries as sales; adapt Argentina's bankruptcy rule,
noting it needs a Bankruptcy Code amendment; state the lock's costs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-23 19:54:25 -06:00
jheaps 7cc964380b Merge pull request 'Socialism: capital accounts, the founder problem, and the horizon problem' (#124) from feature/socialism into master
Build and Deploy / build-deploy (push) Successful in 1m15s
Reviewed-on: #124
2026-09-23 16:33:31 -06:00
jheapsandClaude Opus 5.5 3e072dbd5d Socialism: study the precedents and test whether the help matters
Add precedents.md: Mondragon's Caja Laboral, the US National Consumer
Cooperative Bank, Italy's Coopfond and Marcora Law, each with what went
wrong. Add a support lottery to the pilot (intensive help for a random
half, advisers walled off from lending, all help counted against the
bank), count distressed mergers as closures, and replace unsourced
precedent claims on the bank and argument pages.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-23 16:02:27 -06:00
jheapsandClaude Opus 5.5 4e686aaf72 Socialism: answer the horizon problem with reserve rules, not share sales
Add the-long-run.md: the Furubotn-Pejovich worry, the French and
Italian evidence, the plywood "failure of success", and four rules:
the reserve takes its share of pay above the going rate, asset-sale
gains go to the reserve, qualifying development can be spread over
five years with abandonment charged to those who chose it, and
accounts earn Treasury+2 only while at risk. Update capital accounts,
worker ownership, the pilot's measures, open problems and the index.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-23 15:32:19 -06:00
jheapsandClaude Opus 5.5 a75c24fcf2 Merge unmerged socialism work back onto master
PR #123 was merged before 56c041f was pushed; bring it back in.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-23 15:22:12 -06:00
jheapsandClaude Opus 5.5 56c041f405 Socialism: member capital accounts and the founder problem in numbers
Add capital-accounts.md (profit split, cash share, formula interest,
payout rules, solvency test, savings protections, worked outcomes).
Rewrite founder-problem.md with a discounted co-op vs conventional
comparison, the risk evidence (Hall-Woodward, Hamilton), and bounded
founder tools. Add founder-related measures and safeguards to the
pilot pages, and renumber open problems.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-23 14:51:56 -06:00
jheaps f9cf2752d2 Merge pull request 'Add unlisted /socialism pages for the worker-ownership model' (#123) from feature/socialism into master
Build and Deploy / build-deploy (push) Successful in 1m21s
Reviewed-on: #123
2026-09-23 12:17:16 -06:00
jheapsandClaude Opus 5.5 54f951520a Socialism: spec the pilot in numbers, and guard it against gaming
Add "The Pilot, in Numbers": a one-time $350M, ~400-co-op pilot with
a power-calculated sample size, loan limits, a checked definition of
market rate, two pre-registered deciding measures (fair survival with
a pay and membership guardrail, and a Treasury-rate hurdle for the
loan book), a matched SBA comparison plus a lottery at the margin, a
year-12 review, a fast-track vote on success and a self-executing
wind-down on failure. Built around the FONDES and Venezuela failures.

Add "Guarding the Pilot": rules against hidden-boss shells, capital
claim drains, split borrowers, relabeled rescues, zombie survival,
sabotage, a rigged review, and political favoritism.

Link both from the index, bank, test, capital and lock pages, and add
the pilot's weak points and the federal-vs-state question to Open
Problems.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QWPs69B8nQZjtPEUXMKzZx
2026-09-23 10:00:03 -06:00
jheapsandClaude Opus 5.5 64852cf6b7 Socialism: rebuild on the v3 model (co-op bank and lock, no forced conversion)
Replaces the chapters with the v3 foundation: new chapters for the bank,
the lock, the founder problem, the test and intellectual property; removes
conversion, conversion-tax and valuation. Rewrites the overview to lead
with "nobody is forced" and rewords draft-history references for
first-time readers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-23 08:20:05 -06:00
jheapsandClaude Opus 5.5 0340c33bfb Socialism: collect the conversion tax so it stays a tax
Adds The Conversion Tax chapter. The tax is withheld in cash from each
buyout payment, the rate schedule is locked on conversion day, brackets
run on cash actually received, pre-bill gifts are added back to the
lifetime ledger, and above-market side payments to former owners count
as proceeds. It explains why taking the tax in kind was rejected
(Koontz, Horne, Tyler). It also corrects the 1950s precedent (capital
gains were capped at 25%) and states plainly how the recycled tax
lowers what workers pay. Open problems now cover proceeds vs gains,
treaty overrides and recession cyclicality.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GsSJ98117VXm9t7rBra5NX
2026-09-23 08:10:48 -06:00
jheapsandClaude Opus 5.5 b1ed1b9b13 Socialism: set conversion prices by a rule ladder, not dueling experts
Adds a Valuation chapter. It covers market price, control-size arm's-length
sales, and a formula whose industry multiples come from real acquisitions
and whose profit figures are cleaned of related-party games. Disputes stay
in court (Monongahela), but each side files a final number, costs follow
reasonableness, inflated claims draw a penalty, and workers get an
independent public valuation counsel. Conversion brackets now apply to
lifetime proceeds and look through foundations and trusts. Open problems
are updated for the anticipation discount and conversion-tax durability.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GsSJ98117VXm9t7rBra5NX
2026-09-23 05:01:56 -06:00
jheapsandClaude Opus 5.5 e8f930e9fc Add unlisted /socialism pages for the worker-ownership model
Renders the markdown documents in Content/Socialism: a landing overview at
/socialism and chapters at /socialism/{slug}, with a sectioned sidebar,
on-page table of contents, prev/next paging and cross-document links.
Pages are noindex and nothing on the site links to them. Underscore-prefixed
files (founding draft, progress log) are never served, deployed or committed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-23 03:10:35 -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 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
167 changed files with 32920 additions and 964 deletions
+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
+6 -1
View File
@@ -366,4 +366,9 @@ MigrationBackup/
FodyWeavers.xsd
# Native chess engine CMake build output (the compiled .dll/.so are committed under Resources/)
native/**/build/
native/**/build/
# Playwright MCP scratch output
.playwright-mcp/
# Private /socialism working files (founding draft, progress log) - repo is public
JoshHeaps.Net/Content/Socialism/_*.md
@@ -0,0 +1,133 @@
---
title: When a Locked Co-op Goes Bankrupt
section: The Co-op
order: 31
summary: Federal bankruptcy law would override the lock, so a failed co-op could be sold whole to anyone, and this page lays out the amendment Congress would need to pass, modeled on how it already treats charities, along with what it costs creditors and how workers get a real chance to buy.
---
# When a Locked Co-op Goes Bankrupt
[The lock](the-lock.md) says a co-op funded by the bank can never be sold to conventional owners. That holds while the co-op is healthy. It can break at the worst moment, when the co-op fails and a bankruptcy court takes charge of what's left.
This page explains why, what Congress already does for a similar kind of organization, and what the amendment would say. A few terms first:
- **The lock-holder** is the independent organization that holds the co-op's *lock share*, a single share that pays nothing but can veto a sale ([The Lock](the-lock.md#how-the-lock-is-made-to-hold)).
- **A going concern** is a business sold whole and still running, with its customers, contracts and trained staff. It's usually worth much more than the same equipment sold off piece by piece.
- **Creditors** are everyone the co-op owes: lenders, suppliers, workers, tax agencies, and anyone it has injured.
## Why bankruptcy breaks the lock
When a business files for bankruptcy, everything it owns goes into a pool the court controls, called the *estate*, and is used to pay creditors as much as possible. There are three main routes:
- **Liquidation (Chapter 7).** A trustee sells what the business owns and pays creditors in order of priority. The trustee can keep the business running for a limited time if that helps sell it ([11 U.S.C. § 721](https://www.law.cornell.edu/uscode/text/11/721)).
- **Reorganization (Chapter 11).** The business keeps running under a plan the court approves. Debts are cut or stretched out, and ownership often passes to creditors. Under the *absolute priority rule*, if a group of creditors votes against the plan, the old owners can't keep anything until those creditors are paid in full ([11 U.S.C. § 1129(b)(2)(B)](https://www.law.cornell.edu/uscode/text/11/1129)).
- **Small-business reorganization (Subchapter V).** A faster version of Chapter 11 for businesses owing no more than $3,424,000 ([11 U.S.C. §§ 1182(1) and 101(51D)](https://www.law.cornell.edu/uscode/text/11/101), as adjusted in 2025). Here the absolute priority rule doesn't apply. If creditors don't agree to the plan, the owners can still keep the business by paying creditors all of its projected *disposable income*, what's left after the costs of running it, for three to five years ([11 U.S.C. § 1191](https://www.law.cornell.edu/uscode/text/11/1191)). Most locked co-ops would fit under that limit.
The lock can fail in four places:
1. **The estate ignores limits on selling.** The Bankruptcy Code says a business's property goes into the estate "notwithstanding any provision" in an agreement or in state law "that restricts or conditions transfer" of it ([11 U.S.C. § 541(c)(1)](https://www.law.cornell.edu/uscode/text/11/541)). The lock is exactly that kind of provision.
2. **The court can sell everything, "free and clear."** A trustee can sell the estate's property outside the ordinary course of business, free and clear of other people's interests in it ([11 U.S.C. § 363(b) and (f)](https://www.law.cornell.edu/uscode/text/11/363)). A court could treat the lock-holder's rights as one of those interests and pay them off in cash. In practice, a failed business usually goes to the highest bidder.
3. **A reorganization plan can hand the firm over.** A plan can sell the business, merge it or rewrite its charter "notwithstanding any otherwise applicable nonbankruptcy law" ([11 U.S.C. § 1123(a)(5)](https://www.law.cornell.edu/uscode/text/11/1123)). A standard plan cancels the old ownership and issues new shares to creditors. The Code also requires the reorganized company's charter to ban shares that carry no vote ([§ 1123(a)(6)](https://www.law.cornell.edu/uscode/text/11/1123)), while this model relies on outside money that carries no vote ([Capital, After](capital.md)).
4. **A lender can sell without going to court at all.** A lender with a claim on all of a business's assets can sell them, or take them, after a default under state law ([UCC § 9-610](https://www.law.cornell.edu/ucc/9/9-610)). No bankruptcy is needed.
So without a change in the law, a failed co-op's whole business could be bought by a conventional company. The lock would protect only healthy co-ops, which need it least.
## What Congress already does for charities
Congress already keeps some organizations' property dedicated to its purpose in bankruptcy. The way it does it is simple: the Bankruptcy Code steps back and lets the ordinary law that governs those organizations decide.
- **A charity's property can go to a non-charity "only under the same conditions as would apply if the debtor had not filed"** for bankruptcy ([11 U.S.C. § 541(f)](https://www.law.cornell.edu/uscode/text/11/541)). This covers tax-exempt charities.
- **Court-approved sales by a nonprofit corporation must follow the law** that governs its transfers outside bankruptcy ([§ 363(d)(1)](https://www.law.cornell.edu/uscode/text/11/363)), and so must transfers under its reorganization plan ([§ 1129(a)(16)](https://www.law.cornell.edu/uscode/text/11/1129)). These three rules were added in 2005.
- **Since 1978, creditors can't force a nonprofit into bankruptcy** ([§ 303(a)](https://www.law.cornell.edu/uscode/text/11/303)), and a court can't turn its reorganization into a liquidation unless it asks ([§ 1112(c)](https://www.law.cornell.edu/uscode/text/11/1112)).
- **Also since 1978, valid limits on transferring a person's interest in a trust hold in bankruptcy** ([§ 541(c)(2)](https://www.law.cornell.edu/uscode/text/11/541)). That rule keeps some assets away from creditors, so it's a precedent for protecting a purpose at creditors' expense, not a neutral one.
A locked co-op isn't a charity. It's a business that makes and shares profits. The comparison is narrower: in both cases, assets are meant to stay dedicated to a purpose, not sold for private gain. So the amendment copies the *technique*: the Code defers to the law that governs locked co-ops, and that law, the bank's statute, says what can be sold to whom.
**Is it constitutional?** Bankruptcy laws must be "uniform." In 1982 the Supreme Court struck down a law written for the workers of one bankrupt railroad, calling it "nothing more than a private bill." But it said a bankruptcy law must "apply uniformly to a defined class of debtors," and that its ruling didn't impair Congress's ability "to define classes of debtors and to structure relief accordingly" ([*Railway Labor Executives' Ass'n v. Gibbons*, 455 U.S. 457](https://www.law.cornell.edu/supremecourt/text/455/457)). A rule for every locked co-op, defined by what it is and not by name, is that kind of class. The parts that reach sales outside bankruptcy (point 9 below) rest on Congress's power over interstate commerce instead.
## What the amendment does
### Who it covers
**1. Every locked co-op on the federal register.** The bank's statute sets up a public register of locked co-ops. A co-op is covered while its lock is on the register. The register decides the question. A notice in the state filing system lenders already search (a UCC financing statement) points to it, but if that notice lapses, the co-op stays locked.
**2. Old debts are protected.** A lender already secured on the co-op's assets when the lock is registered has to consent in writing before registration. For a business that converts to a co-op, that's part of the buyout deal ([When an Owner Wants to Sell to the Workers](buyouts.md)).
**3. Creditors who never chose to lend are made whole for the lock's cost.** Some creditors never had a chance to price the lock in: people the co-op injured, tax agencies, and creditors whose unsecured claims came before registration. If the lock makes them recover less than they would have from an open sale, the federation fund pays them the difference. For co-ops outside a federation, the bank pays it. Locked co-ops must also carry standard liability insurance, so injured people are mostly paid by insurers anyway.
### What can be sold, and to whom
**4. The Code defers to the lock.** In every chapter, and in plan sales too, the lock's limits hold. The estate takes the co-op's property subject to them. A sale "free and clear" can't strip them. The lock agreements can't be rejected as ordinary contracts. And no plan can override them. The detailed sale rules live in the bank's statute, as the rules for charities live in state law.
**5. Workers can keep the business running while it's sold.** Argentina changed its bankruptcy law in 2011 to do this. The trustee must report within 20 days on whether the business can keep operating. Workers making up two-thirds of the staff can ask to run it as a co-op while the case goes on, with a plan due within 20 days. The judge decides, and sets a time limit that can be extended once ([Law 26.684, amending articles 190 and 191 of the bankruptcy law](https://www.argentina.gob.ar/normativa/nacional/ley-26684-183856/texto)). The amendment adapts this: workers can ask to run the business as a new co-op, under court supervision, while it's up for sale.
**6. The whole business goes only to a qualified co-op.**
- **A qualified bidder** is a locked co-op whose members do at least three-quarters of its work hours, and whose outside money meets the limits in [Capital, After](capital.md): no vote, at most 15 years. A co-op formed by the failed co-op's own workers qualifies from the start. So does the federation. A shell set up by a conventional company doesn't.
- **The minimum price is what the assets would bring sold off piece by piece,** set by an appraiser the court appoints. A higher floor, like the full value of a running business, could stop any co-op from buying and force a breakup, which is worse for workers and creditors both. The co-op bidders compete in open bidding, so the price is usually higher than the floor.
- **No buying back your own failure cheaply.** People who managed the co-op in the two years before it failed can't get bank financing to buy it ([The Lock](the-lock.md#when-a-co-op-goes-bankrupt)). In a reorganization where the members are running the case and also bidding, an independent officer runs the sale.
- **The time limit is 90 days from when the sale is announced, or from when the workers' co-op files its plan, whichever is later.** The court can extend it once if a qualified bidder is lining up financing.
**7. Workers can pay with what they're owed.** A secured lender can already bid using its claim instead of cash ([§ 363(k)](https://www.law.cornell.edu/uscode/text/11/363)). Argentina lets workers do the same: a workers' co-op can count the wage and severance claims its workers voluntarily hand over toward the price, at their full amount, and the handover happens at a hearing before the judge (Law 26.684, article 27, adding article 203 bis). The amendment does this too, within limits:
- **Only claims workers choose to hand over,** in writing. Workers who don't join the new co-op keep their claims.
- **At full value, up to the amounts bankruptcy law already puts first:** unpaid wages and benefit contributions earned in the last 180 days, up to $17,150 per worker for wages ([11 U.S.C. § 507(a)(4) and (a)(5)](https://www.law.cornell.edu/uscode/text/11/507)). That's a deliberate transfer from other creditors to workers, capped per person, as Argentina chose.
- **The insurance pool's claims count too.** The [savings insurance pool](savings-guarantee.md) pays workers' final wages quickly. Normally, whoever pays a wage claim loses its priority ([§ 507(d)](https://www.law.cornell.edu/uscode/text/11/507)). Without a fix, that would leave workers nothing to bid with. So the pool keeps the priority, and it can hand the claims it paid to the workers' co-op for its bid, to be repaid by the new co-op over time.
- **No last-minute claims.** Pay deferred by a vote in the 180 days before filing ranks as an ordinary claim, not a priority one, and can't be used to bid.
**8. If no qualified co-op buys it, the pieces go to anyone, but not the whole.** No buyer, together with anyone financially tied to it, may end up with substantially all of the business. The brand, the core technology or the main customer contracts count as substantially all, whatever their size, as the lock already says. A buyer of the pieces who hires more than a third of the members within two years owes the difference between what it paid and what the running business was worth, as set by the court's appraiser. The sale order says so up front. That money goes to the co-op's creditors, not the federation fund.
**9. Lenders can't sell around it outside court either.** The same rules apply to any transfer of substantially all of a locked co-op's assets because it can't pay its debts: a lender's sale or taking of its collateral, a state receivership, an assignment for the benefit of creditors, or a tax sale. The [model state law](durability.md#federal-test-state-copies) adds that a sale following these rules counts as "commercially reasonable" under the UCC, so lenders aren't penalized for following them.
### When the co-op reorganizes
**10. A reorganized co-op stays a co-op, and creditors are treated as they would be anyway.**
- **Only working members hold votes.** A plan can give creditors new debt, or voteless claims on the terms in [Capital, After](capital.md), but never ownership, and no loan terms giving a lender approval over the co-op's management or strategy. The rule requiring a ban on voteless shares doesn't apply.
- **Small co-ops follow the ordinary small-business rule.** In Subchapter V, owners of any small business can keep it by paying creditors all of its disposable income for three to five years. A co-op gets the same deal, no better. To stop members shrinking that income by paying themselves more, disposable income is worked out with members' pay at the going rate ([Investing for the Long Run](the-long-run.md#the-rules)), and raises in the two years before filing are reversed.
- **Larger co-ops pay creditors the business's full value.** If a group of creditors votes against the plan, they must get at least what the business would sell for on the open market, as if the lock didn't exist, in cash, new debt or voteless claims. That's what the absolute priority rule protects in any company. Members can keep a co-op they can't sell, but not at their creditors' expense.
- **Members' own accounts go first.** What members have in their capital accounts is wiped out before creditors lose anything. Members were taxed each year on those allocations, so this is a real loss, and it's the cost of being an owner ([Member Capital Accounts](capital-accounts.md#protecting-the-co-op-and-the-people-who-stay)).
### Everything else
**11. Former members are paid before current members.** A former member's payout is a fixed debt the co-op owes ([Member Capital Accounts](capital-accounts.md#how-its-paid-out)). It ranks behind every other unsecured creditor, but ahead of current members' accounts. It isn't pushed down to the bottom as if it were a shareholder's claim ([§ 510(b)](https://www.law.cornell.edu/uscode/text/11/510)). Anyone who left within a year before the failure has their balance recalculated first, as the insurance pool already does ([Protecting Members' Savings](savings-guarantee.md#closing-the-loopholes)).
**12. The lock-holder can go to court, carefully.** The lock-holder can take part in the case. If the trustee declines to challenge a deal the co-op made with insiders before it failed, the lock-holder can ask the court's permission to challenge it, and pays the other side's costs if the court finds the challenge frivolous.
**What it doesn't do.** Unlike charities, locked co-ops *can* be forced into bankruptcy by their creditors, and a failed reorganization *can* be turned into a liquidation. A business that can't pay its debts shouldn't be able to shelter behind the lock. The amendment only decides who can buy it, not whether it can fail.
## What it costs, and who pays
- **Lenders get back less when a co-op fails.** The whole business can only go to a co-op, so it may sell for less than on the open market. Lenders who lend after the lock is registered know that, and they'll charge for it, in higher rates or more collateral.
- **The bank bears it first.** The bank is usually the biggest lender, so the pilot publishes its recovery rate on failed loans ([The Pilot, in Numbers](pilot-spec.md#what-gets-measured)). That shows what the lock costs.
- **The federation fund bears the rest.** Creditors who never chose to lend are paid the lock's cost from the fund. That's a real call on money the co-ops put in.
- **Other lenders may lend less, or not at all.** Some banks will refuse locked co-ops, or lend only against equipment they can take back. Lenders willing to fund a failing co-op through a rescue may be harder to find, because they can't sell the business to anyone. That's a real cost, and it's part of why the model needs its own bank.
- **Workers' claims come ahead of other creditors when workers bid.** That's a choice to favor keeping a business running in its workers' hands. It's capped per worker, and it's paid for by other creditors.
- **Some viable businesses will still be broken up.** When no co-op can buy a failed co-op whole, a conventional buyer who would have kept it running and kept the jobs can't. While the co-op sector is small, this will happen. Whether to allow a conventional buyer as a last resort, with any surplus going to the federation fund, is an open question ([Open Problems](open-problems.md)).
## Draft text
For readers who want to see what this could look like in the statute, here is a first draft of the core Bankruptcy Code changes. The detailed sale rules (points 5 to 9) go in the bank's own statute. This is a starting point for lawyers, not finished legislation.
> **Sec. 101 (new definition).** The term "locked cooperative" means an entity entered, as of the date of the filing of the petition, on the register of locked cooperatives kept under [the bank's authorizing act].
>
> **Sec. 541(g).** Notwithstanding subsection (c)(1) and any other provision of this title, property held by a debtor that is a locked cooperative may be transferred only as permitted by the nonbankruptcy law governing locked cooperatives, as if the debtor had not filed a case under this title.
>
> **Sec. 363(q).** In a case in which the debtor is a locked cooperative, (1) the trustee may use, sell, or lease property under subsection (b) or (c), including under subsection (f), only in accordance with the nonbankruptcy law governing locked cooperatives; (2) the rights of a lock-holder under that law are not an interest that may be sold free and clear under subsection (f); and (3) section 365 does not permit rejection of any agreement required by that law.
>
> **Sec. 507(d) (amended).** Insert at the end: "except that an insurance pool established under [the bank's authorizing act] that pays a claim of a kind specified in subsection (a)(4) or (a)(5) is subrogated to the holder's right to priority."
>
> **Sec. 1123(e).** Notwithstanding subsection (a), a plan for a debtor that is a locked cooperative (1) may not provide for any transfer of property, merger, consolidation, or amendment of the debtor's charter except as permitted by the nonbankruptcy law governing locked cooperatives; and (2) need not comply with subsection (a)(6) as to any nonvoting interest permitted by that law.
>
> **Sec. 1129(a)(17).** If the debtor is a locked cooperative, (A) all transfers of property under the plan are made in accordance with the nonbankruptcy law governing locked cooperatives; and (B) no equity security carrying voting power is issued to any entity other than members who work for the reorganized debtor.
>
> **Sec. 1129(b)(2)(B)(iii).** In a case in which the debtor is a locked cooperative, membership in the reorganized debtor held by persons who work for it is not property received or retained on account of a junior interest, if each holder of a claim of the class receives or retains under the plan property of a value, as of the effective date of the plan, not less than the amount such holder would receive if all of the debtor's property were sold as a going concern without regard to section 541(g).
## What this doesn't solve
- **It needs Congress, and a later Congress can undo it.** The lock's other layers hold if the bank's law is repealed. This one doesn't ([Making It Last](durability.md)).
- **Courts will test it.** Treating membership as something other than property the owners keep is new in bankruptcy law. The draft ties it to paying dissenting creditors full open-market value, which answers the fairness point, but the first cases will still be fought.
- **Reaching lenders' sales outside court is a federal reach into state law.** It rests on Congress's power over commerce, not bankruptcy, and it will be challenged. States that adopt the model law make it much safer.
- **Lenders can route around it.** A co-op that leases its equipment, or sells its unpaid invoices to a lender, doesn't own those assets, so the lock doesn't cover them. Co-ops already report new leases to their members ([Investing for the Long Run](the-long-run.md#running-it-down)). How much of a co-op's business can sit outside the lock this way isn't limited.
- **Workers still have to find the money fast.** Keeping the business running and counting workers' claims toward the price help. But a new co-op still needs a lender within a few months, and whether the bank or federation will stand ready isn't settled.
- **The register has to exist and be trusted.** It's a new federal job, and every lender to a co-op will have to check it.
@@ -0,0 +1,92 @@
---
title: Help Before the First Loan
section: Growing the Sector
order: 67
summary: The pilot's hardest job is getting enough good co-ops started, so it tests, by lottery, whether serious help before launch creates more of them that last, and asks founders for a small stake they can pay in work instead of cash.
---
# Help Before the First Loan
The pilot aims to fund about 400 new co-ops in five years, about 80 a year. The whole American worker co-op sector grew by about 50 a year over the last decade, after counting the co-ops that closed ([The Pilot, in Numbers](pilot-spec.md#why-about-400-co-ops)). If good groups don't show up, the bank has nobody to lend to, and the whole test stalls.
The pilot already tests help *after* the first loan, by lottery ([the support lottery](pilot-spec.md#the-support-lottery)). This page is about help *before* it: whether serious support for groups who want to start a co-op makes more of them succeed in starting one that lasts, and whether founders should put in something of their own.
## What we know
**Mondragon's help started before a co-op existed.** When a group came with an idea, its bank picked one of them to be the manager, paid that person a salary while a team studied whether the business could work, and later lent the new co-op enough to cover its losses for its first two years ([Has This Been Tried?](precedents.md#mondragons-bank-caja-laboral)). Nobody knows how much of Mondragon's success came from that help and how much from the loans.
**Light training doesn't last.** At the time, the largest randomized trial of entrepreneurship training ever run was America's GATE project. It assigned 4,197 people who wanted to start businesses to be offered free training or not. People not offered it could still find training elsewhere, so the real difference was modest: about 9 more hours of classes and advice, at about $1,321 per person. Six months later, the trained group was about 5 percentage points more likely to own a business. By 18 months the gap in who currently owned a business was small and no longer clear, and by five years there was no clear difference ([Fairlie, Karlan and Zinman, 2012](https://www.nber.org/papers/w17804)).
**Heavier help is expensive, and nobody has measured what it does.** New York City has funded community groups that help people start worker co-ops since 2015. Its partners often work with a group for several years. In the program's seventh year, a pandemic year, it cost about $3.1 million, and 11 new co-ops formally launched or converted, meaning they got a tax ID, formation papers and permits. Another 21 were in progress ([NYC Small Business Services, 2021](https://www.nyc.gov/assets/sbs/downloads/pdf/about/reports/worker_coop_report_fy21.pdf)). That's about $280,000 for each co-op launched that year, though the program also helps existing co-ops, so it isn't a true cost per co-op. And there's no comparison group, so nobody can say how many of those co-ops would have started anyway.
So light help is cheap and fades, and heavier help might work but hasn't been tested. That's exactly the kind of question a lottery answers.
## The founding lottery
### Who can apply
- **A group of at least three people** who plan to work in the business, with an idea and a rough sense of what it would take. There's no loan application yet.
- **Or a sponsor applying for a group.** Unions, worker centers, community groups and existing co-ops often bring groups together. Many co-ops start that way. They can apply on a group's behalf, and results are reported by who sponsored the group.
- **A short screen, run by the reviewers, not the bank,** checks that the people are real and intend to work there, and that the business is legal. Any government ID is enough, including a taxpayer ID number for people without a Social Security number, or a consular or city ID.
- **One entry per person for the whole pilot.** Groups that share a member, a family, an address, an idea or an organizer go into the draw as one unit. Groups that lose the draw can't re-enter under a new name.
### The draw
The reviewers, not the bank, draw half the groups by lottery, using a method filed in advance. The draw is balanced by industry, region, and the founders' gender, race and wealth. As with the support lottery, groups agree to the lottery when they apply, and an independent research ethics board approves the design. Groups that weren't drawn are offered the same help once the two-year comparison window ends.
### What the helped groups get, for up to a year
- **A stipend for one founding member** for up to six months, so that one of the group can work on getting the business ready full time. That's how Mondragon did it: it paid one of the founders, not an outsider.
- **A feasibility study** by an outside specialist: is there a market, what will it cost, and can it pay a loan?
- **Legal setup, bylaws and a business plan,** in the group's own language, with interpreters if needed.
- **Specialists come from a vetted panel,** assigned by the reviewers, not picked by the group or by anyone who referred it. No specialist can be a member, a member's relative, or someone who referred the group. For three years they can't sell paid services to a group they helped, the same rule that applies to the support lottery's advisers. No one person or organization can advise more than a set number of helped groups.
- **About $40,000 per group in all.**
### What the other groups get
The same public help any would-be business owner can already get, like the Small Business Administration's free counseling centers, and the bank's published standards. They can apply for a loan whenever they're ready.
### What it doesn't change
Help doesn't lower the bar. Every group applies for its loan through the same underwriting. The help doesn't cover early losses. A co-op that needs money to get through its first years borrows it from the bank at market rates, like any other loan.
### How big
About 250 groups in each half, so $10 million for the help. With groups that size, the lottery has an 80% chance of detecting a difference of about 12 percentage points in how many groups end up running a co-op, for example 25% against 37%. Smaller effects might be missed.
**The money comes from the loan budget.** Of the roughly $300 million set aside for loans, $10 million pays for this help instead, which leaves the pilot's cash cushion and running costs untouched. The pilot's capped help budget is $30 million in all: $20 million after the first loan and $10 million before it ([The Pilot, in Numbers](pilot-spec.md#how-much-money-and-why)). Every dollar counts against the bank in the [money test](pilot-spec.md#the-decision-rule), the same as help after the loan.
## What gets measured
- **Are they running a co-op?** The main result: the share of all groups in each half that have a co-op operating two years after the draw, with at least three members and a minimum level of sales, however it was financed. It's measured from government business records by the reviewers, not decided by the bank. So the result can't come from helped groups simply being coached to pass the bank's own underwriting.
- **Are they still running at year four?** The share of all groups in each half with a co-op still open and paying fairly four years after the draw. That counts every group, so help that pushes weak groups into launching and failing shows up as a cost, not a benefit.
- **What did it cost?** The public cost for each extra co-op the help created, and for each extra co-op still open at year four. At the smallest effect the lottery can detect, about 30 extra co-ops for $10 million, that's roughly $330,000 each. A ceiling is filed in advance. Above it, the help isn't recommended for any larger program, however many co-ops it creates.
- **Who did it reach?** Founders' prior earnings, wealth, gender, race, immigration status and language, in each half.
- **Where else did groups get help?** Groups report other help and funding they used, so any effect from help found elsewhere, or co-ops simply moving from other lenders to this one, can be estimated.
The co-ops from both halves count in the main test, and the main result is also reported without the helped half's extra co-ops. So help can't quietly change what the main test measures.
## Should founders put in money of their own?
Mondragon required it: its founders put in a fifth of the start-up money. Under Italy's Marcora program, which helps workers buy out closing firms, workers put in their own money too, often from their severance pay and unemployment benefits ([Has This Been Tried?](precedents.md)). A stake makes founders care what happens to the loan. But a cash buy-in shuts out workers with no savings, who are the people co-ops most often serve.
**The model's answer: a small stake, payable in work.** The founding members together put in at least 5% of the first loan. They can pay it in cash, in work, or both.
- **Work is valued at the local median wage for the job**, from the government's wage survey, not at whatever pay the founders plan to give themselves.
- **Only hours logged at the time count,** from the date of application, and a specialist from the panel or the reviewers checks them. Hours paid by the stipend don't count.
- **Only work before the co-op is formed can be unpaid.** After that, founders are its employees and are paid at least the minimum wage. Only the difference between the minimum wage and the median wage counts toward the stake ([Owners Are Still Workers](labor-law.md#founders-unpaid-work)).
- **It's a real stake.** Unpaid work is recorded as back pay the co-op owes, ranked behind the bank ([The Founder Problem](founder-problem.md#the-tools)). Founders lose it if the co-op fails.
For a typical first loan of $500,000, that's $25,000 in all. Five founders could meet it with about $5,000 each, or with a few months of evenings and weekends. Three founders would need about $8,300 each, or more hours.
**What the stake does and doesn't do.** It shows commitment, and it makes sure founders have something to lose. It doesn't stop fraud. A group that plans to take the money and disappear gives up only work it never intended to be paid for. The defense against that is elsewhere: loans paid out in stages as the business hits milestones, and the rules in [Guarding the Pilot](pilot-safeguards.md).
## What this doesn't solve
- **It tests help for groups that come forward.** Many workers never think of starting a co-op at all. Sponsors like unions and community groups help reach them, and results are reported by sponsor. But the lottery can't test outreach to people nobody reaches.
- **A year's help may not be enough.** New York's partners often work with groups for several years. If the effect needs longer, this design will understate it.
- **Groups that lose the draw may find help elsewhere.** That makes the helped half look less different, so the result may understate the real effect, as happened in GATE.
- **Four years of survival is a small sample.** Only the groups that launch can survive, perhaps 60 to 90 in each half. So the year-four result will be rough.
- **The work stake still costs time.** Founders who can't spare unpaid hours, because they need every paid hour they can get, still face a barrier. The stipend in the helped half partly makes up for that. Outside it, nothing does.
- **Help only for co-ops will be called unfair competition.** It's a research cost, time-limited to the pilot, published each year, and counted against the bank. That's the honest answer. It won't stop the attack.
- **$10 million is $10 million.** If the help doesn't create more co-ops that last, the pilot will have spent it to learn that.
+118
View File
@@ -0,0 +1,118 @@
---
title: When an Owner Wants to Sell to the Workers
section: Growing the Sector
order: 60
summary: Owners who choose to sell their company to its workers can get a loan from the bank to do it, on a separate track with its own money and its own test, so the sale can't overcharge the workers or muddy the main experiment.
---
# When an Owner Wants to Sell to the Workers
This model buys nobody's company. But some owners *want* to sell to their workers. Most often, they're retiring, have no family member who wants the business, and would rather it stay in the town with the people who built it than go to a competitor or a private equity fund.
American banks already lend for some of these sales, as the evidence below shows. But those are mostly sales to employee stock ownership plans (ESOPs), trusts that come with large federal tax breaks and don't give workers one member, one vote. Sales to a real worker co-op, especially of smaller firms, are harder to finance. Lenders usually want personal guarantees, which a whole workforce can't give ([Why a new bank](pilot-spec.md#why-a-new-bank-not-existing-programs)).
So should [the bank](the-bank.md) lend for these sales? This page's answer is **yes, but on a separate track**, with its own money, its own rules and its own test, and alongside private lenders wherever they'll join in. The evidence for buyouts of healthy firms is among the strongest in the co-op literature. The ways they go wrong are also well known, and every rule below answers one of them.
## The case for it
**Healthy firms sold to their workers survive.** In France, five years after a healthy business is handed over to its workers as a co-op, 90% are still running. For French co-ops as a whole the figure is 79%, and for French businesses generally it's 61%. Even struggling firms taken over by their workers reach 76% (France's co-op federation, cited in a [2025 report to the French National Assembly](https://www.assemblee-nationale.fr/dyn/opendata/RINFANR5L17B1696.html)). That isn't a like-for-like comparison. The general figure includes brand-new businesses, and firms chosen for a handover were already doing well. Still, it's hard to read as a warning.
**Loans for these sales get repaid.** A study of 1,232 bank loans for ESOP buyouts at three large banks, from 2009 to 2013, found only 0.2% a year defaulted in a way that cost the lender money. By comparison, loans to mid-sized companies generally defaulted at 2% to nearly 4% a year over similar periods, though the two figures don't count defaults in quite the same way ([NCEO, 2014](https://www.nceo.org/hubfs/assets/pdf/Default-Study-full.pdf)). So the comparison only suggests something. It doesn't prove it.
**It can grow the sector fast.** Britain created a simple way for owners to sell to a trust for their employees in 2014, with a tax break. By January 2025 there were about 2,000 of these employee ownership trusts, and one estimate puts them at 6% of all business sales in 2024. Most are doing well. Very few have failed, mostly in construction, where profit margins are thin ([Pendleton and Robinson, 2025](https://eprints.whiterose.ac.uk/id/eprint/223994/)).
**It reaches workers the startups don't.** A startup co-op begins with a handful of people. A buyout brings in a whole existing workforce at once, often in trades where new co-ops rarely start.
**A caution about all of this evidence.** None of it comes from exactly this design. ESOPs and British trusts come with big tax breaks, and neither gives workers one member, one vote. Italy's buyouts under the Marcora Law also show high survival, 87% still operating, but most were rescues of firms in crisis, and many were only a few years old when counted ([Has This Been Tried?](precedents.md#italys-co-op-funds-coopfond-and-the-marcora-law)). The evidence shows that selling a healthy firm to its workers can work. It doesn't show that this particular track will.
## What goes wrong
**Workers overpay.** The seller usually knows the business best and wants the highest price. In one American case, the trustee who was supposed to protect the workers approved a price after a half-hour meeting about the valuation, and the workers' plan overpaid for Constellis, a private security contractor, by almost $30 million. A federal appeals court upheld the judgment ([Brundle v. Wilmington Trust, 4th Cir. 2019](https://www.ca4.uscourts.gov/opinions/171873.P.pdf)). In a British case, a founder gave his adviser rosy forecasts, the adviser raised them further, and the managers who had to run the company were given no time to read the terms. The company nearly failed under its payments to the founder and was saved only by renegotiating (Pendleton and Robinson).
**The seller keeps control.** Under Britain's original rules the seller could appoint the trustees who were meant to act for the workers. Britain changed that in 2024. Now the seller and their associates can't make up half or more of the trustees, and the trustees must make sure the price is no more than market value ([Pendleton and Robinson](https://eprints.whiterose.ac.uk/id/eprint/223994/)).
**The debt comes out of the workers' future.** Almost nine in ten British trust sales are paid for by the company itself, out of future profits, over about six years (Pendleton and Robinson). That's the workers' money, paying the seller. Most firms manage it, but it can squeeze out investment and pay.
**Tax breaks get expensive and attract the wrong sellers.** Britain let owners who sold to a trust pay no capital gains tax at all. By 2021–22 the tax break cost £600 million a year, and it was forecast to reach £2 billion by 2028–29. One accounting firm puts that at about 20 times what was originally expected ([Scholes Chartered Accountants, 2025](https://www.scholesca.co.uk/blog/why-did-the-autumn-budget-change-employee-ownership-trusts)). In November 2025 the government cut it back, so that only half of the gain is tax-free ([Charles Russell Speechlys, 2025](https://www.charlesrussellspeechlys.com/en/insights/expert-insights/corporate/2025/employee-ownership-trusts---government-reduces-capital-gains-tax-relief-on-employee-ownership-trusts-in-2025-budget/)). The researchers noted that a big tax break "could attract owners who have little or no interest in employee ownership."
**A trust isn't a co-op, and it isn't forever.** In a British trust, the workers usually don't vote one member, one vote. The company is typically run with an ordinary management hierarchy, and a trust can later sell the company. Nothing like [the lock](the-lock.md) stops it.
**Rescues of failing firms lose money.** Uruguay's public fund lost almost 90% of its loans, mostly on firms that had already failed ([The Pilot, in Numbers](pilot-spec.md#two-warnings-from-real-life)).
## The rules
### Healthy firms, willing sellers, no new tax break
- **Only healthy businesses.** At least three years of profits, with the latest year audited by an outside accountant and every year matching the tax returns. Sales to the seller's own other businesses or relatives don't count toward those profits. The seller must have owned the business for at least three years, and it can't be in bankruptcy or behind on taxes. Rescues stay out, as they do for [the rest of the pilot](pilot-spec.md#who-can-borrow).
- **The owner starts it, in writing.** The bank never approaches an owner, and neither do the advisers or groups the pilot pays. The owner can back out at any time before closing.
- **No new tax break.** American law already lets some owners put off capital gains tax when they sell to an ESOP or a worker co-op. It applies to one kind of corporation (a "C corporation"), requires the workers to end up owning at least 30%, and requires the seller to reinvest the money in other American companies ([26 U.S.C. § 1042](https://www.law.cornell.edu/uscode/text/26/1042)). This model leaves that rule alone and adds nothing to it. The bank's only offer is that it will lend. That keeps out most owners who are only after a tax deal, and it keeps the cost to the public from growing the way Britain's did.
- **Private lenders go first.** The bank lends alongside a private or community lender that takes at least a quarter of the main loan, on the same terms. If no lender will join, the bank can lend alone only after two lenders have turned the deal down in writing. Those deals are marked and reported separately. If private lenders join almost every deal, that's a sign the bank isn't needed for buyouts, and the results will show it.
### A fair price
- **Appraisers come from a panel, in rotation.** An independent appraiser, drawn in turn from a panel the bank keeps and paid by the bank, values the business under standard professional rules. The value is what the business is worth on its own, to a buyer who'll run it as it is. It doesn't include the premium a competitor might pay to combine it with their own business, since the workers can't get that value out of it.
- **Profits are checked, not taken on trust.** Before the appraisal, an outside accountant restates the profits as they'd look under new owners. The owner's own job is counted at a normal salary for that work. Upkeep and equipment the owner put off are counted as costs. One-off gains are taken out.
- **The seller can challenge it.** The seller can order a second appraisal from the panel. If the two are more than 10% apart, a third panel appraiser settles it, and that figure is final. The price can't be higher than the final figure.
- **Appraisers are checked too.** One deal in ten, picked at random, is re-appraised after closing by a different appraiser. Appraisers whose values run consistently high are dropped from the panel.
- **Everything the seller gets counts as the price.** For five years after the sale, every payment to the seller, their family or their other companies counts toward the price: salary, consulting fees, rent, royalties, payments for not competing. The only exception is a payment set at a going rate by an independent appraiser. If the seller keeps the building, the co-op gets a lease of at least ten years at appraised rent and the right to buy it at appraised value. Patents, brands, customer lists and a normal level of working cash all come with the business.
- **The seller tells the truth, in writing.** The seller discloses in writing any big customer or contract that's at risk, any lawsuit, and anything else a buyer would need to know. If the disclosure turns out to be false, the co-op can cut what it still owes the seller by the damage, and sue for more.
- **The workers get their own adviser**, chosen by the workers (or their union, if they have one) from a list the bank approves, and paid from the buyout track's own budget. The adviser explains the price, the debt and the risks in plain terms. The workers get at least 30 days with the full terms before they vote.
### The seller shares the risk
- **At least 20% of the price is paid to the seller over time**, and only after the bank's loan payments are made. If the price was too high and the business struggles, the seller's payments stop before the workers' jobs do.
- **That share shrinks if the forecast was wrong.** If profits in the first three years come in more than 20% below what the appraisal assumed, the seller's share is cut by a formula written into the sale. That puts a price on selling just before a drop the seller saw coming.
- **The seller is protected like a lender, and only like a lender.** The seller's share carries a legal claim on the business's assets, behind the bank's, and the right to be paid. It carries no say in how the co-op is run.
- **Pay isn't cut to pay the seller.** In any year the co-op cuts its members' pay rates below where they were before the sale (after inflation), the seller's payments wait. They're owed later, not cancelled.
- **The debt has to fit the business.** The bank lends only if the business's checked profits cover all the loan payments, the bank's, the private lender's and the seller's, plus the cash members need to pay tax on their profit shares ([Member Capital Accounts](capital-accounts.md#where-each-years-profit-goes)), with a real margin to spare. The margin (for example, profits of at least 1.25 times the payments) is part of the lending standards filed before the first loan ([Making It Last](durability.md#what-a-new-board-cant-change)).
### The workers decide
- **Who votes.** Every worker on the payroll 12 months before the owner applied, and still there, can vote, including part-time workers and agency temps who work there regularly. The seller and their family can't. Nobody can be laid off, reclassified or offered money tied to how they vote in that time.
- **How they vote.** By secret ballot, run by an independent election body, with a neutral ballot and after the 30 days with the full terms. The sale needs a majority of *everyone eligible*, not just of those who turn out.
- **Unions keep their place.** A union contract carries over to the co-op. Becoming a member doesn't take away anyone's right to organize.
### Then they govern
- **Everyone becomes a member unless they say no.** At closing, every worker is enrolled as a member unless they opt out in writing. The buy-in is kept small and can be paid by payroll deduction, so no one's shut out for lack of savings. Workers who opt out can't be fired for it, and can join whenever they like.
- **The membership rule, with a fair start.** New hires follow [the lock](the-lock.md)'s usual rule: membership after at most 12 months. For the first three years, workers who were there before the sale and chose not to join don't count against the rule that members must do three-quarters of the work hours. After that, the normal rule applies, as it does for every co-op the bank funds.
- **One member, one vote, from day one.** A converted firm meets the full [co-op definition](worker-ownership.md), unlike a British trust. It can keep managers, but they answer to a board elected by the members. The first board election happens within 90 days of closing.
- **The seller's control ends at the sale.** The seller can stay on as a paid adviser for up to two years, and the members can end that by vote at any time. If the seller works there, they can join as a member, with one vote like anyone else. Neither they nor their family or associates can sit on the board, hold a special share or choose who governs.
- **Paying off the debt builds the members' accounts.** Loan payments come out of profit, but they aren't a cost in the co-op's books. So the profit that pays down the loan is still credited to members' [capital accounts](capital-accounts.md) under the usual split, and paid out on the usual schedule. The workers who pay for the business build up a claim on it.
- **Help with the change.** Moving from one owner to a members' democracy is hard. The French parliament's report calls support and training for newly elected leaders essential. Converted firms get governance training and the hands-on help that [the support lottery](pilot-spec.md#the-support-lottery) tests, paid from the buyout track's own budget.
- **Who benefits is published.** Who became a member, who sits on the board and how profit shares are split, by race, gender and job, the same as for [the startups](pilot-spec.md#what-gets-measured).
### Locked, like every co-op the bank funds
A converted firm accepts [the lock](the-lock.md) and stays a co-op forever. This is the biggest difference from Britain's trusts. The seller gets a fair price once. After that, nobody can cash in the business again. Every seller signs knowing that.
The lock costs the seller something too, and they should know it. If the co-op fails while the seller is still owed money, the business can't be sold whole to an ordinary buyer. It can only go to another co-op, or be sold off in pieces. So the seller will likely get back less than from a failed conventional firm. That's one more reason to set the price where the business can carry it.
## Why a separate track
Buyouts would make the pilot's main result look better than it deserves. A business that has already survived for years is far more likely to keep surviving than a new one. If buyouts were mixed into the main test, the co-ops' survival would look better whether or not worker ownership had anything to do with it. That's why [the pilot](pilot-spec.md#who-can-borrow) already scores existing firms separately.
So buyouts get:
- **Their own money.** $100 million, set aside in the same one-time charter, separate from the $350 million for new co-ops. That raises taxpayers' worst case from $350 million to $450 million, and the model says so. Up to $5 million of it pays for the workers' advisers and governance help. Buyout loans can never draw on the startup money or the startup help budget, so they can't shrink the main test.
- **Their own limits.** The same $3 million lifetime limit, which covers every business owned by the same seller or group, so one owner can't split a company into pieces to get around it. The same caps on industries and places apply. With a private lender's share and the seller's share on top, that means companies priced up to about $5 million: small and mid-sized firms. Bigger sales can use the ESOP market that already exists.
- **Their own test, judged at year 12 with the rest.** It has two parts, and both must pass:
1. **The money.** The buyout money has to earn at least the Treasury rate after all losses and costs, and its losses can't be higher than the loss rate the bank publishes before its first buyout loan.
2. **The pay.** Among converted firms still open, members' median pay per hour can't have fallen below where it was before the sale, after inflation. A track that gets repaid by cutting its members' pay hasn't passed.
The two tracks pass or fail on their own. A good buyout record can't rescue a failing startup test, and a bad one can't sink a passing one.
If the bank's share of a typical deal is about $1.5 million, half the limit, $100 million funds roughly 60 to 70 buyouts. That's too few for the tight survival comparison the startup test uses. So survival is published and compared, but as evidence, not as a pass-or-fail line. The comparisons are with similar businesses bought by conventional owners using government-backed small-business loans, and with ESOP buyouts, which federal pension filings make it possible to follow. The terms of every deal are published: the price, the appraisals, the loans and the seller's share.
**Expect opposition.** Business brokers and private equity funds, who make money when owners sell, may see a public lender as a competitor. Italy's main employers' association got part of its worker-buyout program suspended by the EU ([Has This Been Tried?](precedents.md#italys-co-op-funds-coopfond-and-the-marcora-law)). Keeping the bank at market rates with no tax break is the best defense, because it leaves little to call unfair. The rest is in [Making It Last](durability.md).
## What this doesn't solve
- **The workers pay for what they helped build.** A fair price is a price for the owner's years of ownership, and the workers pay it out of profits their own work will produce. This model accepts that, because the alternative is taking the business, and it doesn't take anyone's business. The rules keep the price fair. They can't make it free.
- **The seller always knows more.** Written disclosures, checked profits and a shrinking seller's share make hiding problems costly. They can't make it impossible.
- **A fair price is a matter of judgment.** Appraisals are estimates. The rules make overpaying harder and put the seller's own money behind the price, but they can't make valuation exact.
- **The vote can be pressured.** A secret ballot, a neutral ballot and a majority of everyone eligible help. They can't stop a well-liked owner or manager from leaning on people in the break room.
- **One failure can become a story.** A single buyout that goes wrong will make a better headline than sixty that don't. Publishing the expected loss rate in advance, and every deal's terms, lets anyone check whether a failure was bad luck or bad lending. It doesn't stop the headline.
- **"Forever" is a lot to ask of a business that wasn't founded that way.** Some owners won't sell under the lock, and some critics will say it's wrong to lock a firm its members never chose to lock at founding. Whether the lock should protect the co-op form or only its value is an open question for every co-op, not just these ([Open Problems](open-problems.md)).
- **It may pull good founders away from co-ops.** A founder who expects to sell to their workers one day can start a conventional company, keep full control for 20 years, and then sell at a fair price. That may be a better deal than founding a co-op ([The Founder Problem](founder-problem.md)). For the model's goal, more workplaces owned by the people who work there, it's still a win. But it could leave fewer strong founders for the startup test.
- **Sixty buyouts is a small sample.** The track shows whether the loans can be repaid safely without cutting pay. It can't show much more than that about whether the workers who were bought in end up better off.
@@ -0,0 +1,122 @@
---
title: Member Capital Accounts
section: The Co-op
order: 25
summary: How a co-op member builds real savings from the firm's profits without the firm ever being sold, how those savings are protected, and what they add up to.
---
# Member Capital Accounts
In a conventional company, you build wealth from the business by owning shares and eventually selling them. In a locked co-op, nobody can sell the firm (see [The Lock](the-lock.md)). So there has to be another way for the value you help build to become *yours*. That's what a member capital account is for.
The idea isn't new. The Mondragon co-ops in Spain have used it since the 1950s, and US worker co-ops use it under ordinary federal tax law. This page describes how it works in this model, and how members' savings are protected. Protection matters, because savings held inside your own employer are exactly the kind that can vanish along with your job.
## The basic idea
Every member has a personal account on the co-op's books. It works like a savings account the business holds for you:
- **Each year, part of your share of the profits goes into it** instead of being paid out as cash.
- **It earns interest.**
- **It's paid back to you** on a fixed schedule: each year's credit about five years after it's made, whether or not you still work there.
Meanwhile, the co-op gets to use that money to grow. In effect it's a loan from the members to their own business. That's why Mondragon valued it: a firm that can't sell shares to outsiders can still keep its own earnings working ([Morris, *The Mondragón System*, 1992](https://www.ilsr.org/wp-content/uploads/2013/03/mondragon-3.pdf)).
## Where each year's profit goes
When the co-op makes a profit, it gets split. These are the defaults. Each co-op can change them in its bylaws, as long as it stays within the minimums.
| Share of profit | Where it goes | Why |
| --- | --- | --- |
| **At least 30%** | The co-op's permanent reserve | Nobody can ever withdraw it (see [indivisible reserves](the-lock.md)). It's the firm's cushion and its own source of investment money. |
| **Up to 10%** | The federation's shared fund, if the co-op belongs to one | Pools risk between co-ops (see [The Federation](federation.md)). |
| **The rest, about 60%** | Everyone who worked that year, divided by hours worked | This is the "profit share" from [Worker Ownership](worker-ownership.md). It ramps up with tenure, then caps. The part of a share someone hasn't reached yet goes to the reserve ([How a Locked Co-op Is Taxed](taxes.md)). |
Mondragon's original split was similar: 10% to education and the community, at least 20% to reserves, and up to 70% to members. It later raised the reserve minimum to 50% (Morris, 1992).
The members' share is then split again, between cash now and the account:
- **At least 20% of it is paid in cash right away.** Members owe income tax on their whole allocation in the year it's made, including the part that goes into the account. The cash is there so they can pay that tax. This is how US co-op tax law works ([Democracy at Work Institute guide](https://institute.coop/sites/default/files/resources/Internal-Capital-Accounts.pdf)). The default is half in cash.
- **Lower-income members can take it all in cash.** For a low-wage worker, being taxed on money they can't touch can cost them tax credits and health-care subsidies. So any member whose household income is under twice the poverty line can take their whole share in cash. If they leave some in their account, by default they aren't taxed on it until it's paid out ([How a Locked Co-op Is Taxed](taxes.md#capital-accounts)).
- **The rest goes into each member's account.**
Members can also raise their own salaries instead of taking profit, but that doesn't get around the reserve. It takes its share of pay above the going rate too (see [Investing for the Long Run](the-long-run.md#the-rules)).
**A worked example.** A 40-person co-op makes $600,000 in profit after paying everyone's salaries. It puts $180,000 in its permanent reserve and $60,000 in the federation fund. The remaining $360,000 is divided by hours worked. If all 40 members work the same hours, each one's hours earn $9,000. A member at full share gets it all: $4,500 as a check and $4,500 added to their account. A member at half a share gets $4,500, and the other $4,500 goes to the reserve. Profit earned by the hours of workers who aren't members yet is shared the same way, paid in cash, at the share of a member just starting out. Federal tax law is why it works this way ([How a Locked Co-op Is Taxed](taxes.md)).
## How the account grows
- **Interest is set by formula, not by vote.** A current member's account earns the one-year Treasury rate plus two percentage points in years the co-op makes a profit, and the plain Treasury rate in loss years. Once a member leaves, their balance earns the plain Treasury rate. So does the slice of a current member's account that's protected by [insurance](savings-guarantee.md), since it's no longer at risk. If current members voted on the rate, they could set it to zero on accounts that mostly belong to people who've left. Or senior members could set it high to pay themselves out of the firm. A formula stops both.
- **Why the extra two points.** Money in a current member's account is at risk: it shares in losses and ranks behind lenders. If it only earned what a risk-free government bond pays, every member would take as much cash as they could, and the co-op would have less of its own money to invest. Once you leave, your balance stops sharing in losses, so it stops earning the premium. See [Investing for the Long Run](the-long-run.md#the-rules).
- **Losses are shared.** In a year the co-op loses money, the loss is charged partly to the reserve and partly to members' accounts, in the same proportions as profits. Your account can go down, just like shares in any company.
## How it's paid out
- **Each year's credit is paid back after five years.** The money credited to your account for 2030 is paid to you in 2035, the 2031 credit in 2036, and so on, oldest first, each after that year's audited accounts are closed. Each co-op can set the wait anywhere from three to seven years in its bylaws. It's one of the two ways US worker co-ops already pay out accounts, called a "roll-over" ([Democracy at Work Institute guide](https://institute.coop/sites/default/files/resources/Internal-Capital-Accounts.pdf)). The co-op gets to use each year's credit for years, and nobody's savings are stuck until they retire.
- **The wait is fixed when the credit is made.** A vote to change it applies only to credits made starting the year after the vote, so senior members can't speed up their own money or slow down anyone else's.
- **Leaving doesn't change the schedule.** When you leave, your balance is fixed at the end of that year, after that year's profit or loss is counted, so nobody can quit just ahead of a bad year and escape it. After that it stops sharing in losses, earns the plain Treasury rate, and each year's credit is still paid on its own date.
- **Small balances are paid early, for everyone.** Once anyone's whole balance is under $2,000, member or not, it's paid at the next payout date. That keeps a co-op from tracking small sums for years, and since it doesn't depend on leaving, nobody gains by quitting.
- **There's no penalty for leaving.** Mondragon used to cut the payout by up to 30% for people who quit. This model doesn't, because a core promise of [the tenure cap](worker-ownership.md) is that you can change jobs without losing what you've built.
- **Accounts are capped at one year's pay,** measured as the highest pay in the last three years, so nobody can cut their own pay to get their money sooner. Anything above that is paid out every year in cash, which a member can put in their own retirement account and invest in anything. Nobody should end up with most of their savings tied to the same company that pays their wages.
- **Any payout can be spread out.** Members get 90 days' notice of each payout and can ask for it in up to 12 monthly payments, so one check doesn't push a family over a limit for public benefits ([How a Locked Co-op Is Taxed](taxes.md#benefits)).
- **On death, the account goes to the person you name,** your spouse unless you name someone else with their written consent, on the normal schedule.
- **In a divorce, a court can split the account.** The former spouse gets a separate account of their own, with no vote, paid out on the normal schedule.
- **Every year, each person gets a statement** showing each credit, any losses charged to it, and the date it will be paid. Former members get one too. A dispute gets a written answer within 90 days and can go to an outside arbitrator from the federation's list, or to court.
### Why not pay it out when you leave?
Mondragon pays members their accounts when they retire or leave, and so do some US co-ops. This model doesn't, because of a federal pension law. Under the Employee Retirement Income Security Act (ERISA), any program an employer runs that holds back workers' income "for periods extending to the termination of covered employment or beyond" is a pension plan ([29 U.S.C. 1002(2)(A)](https://www.law.cornell.edu/uscode/text/29/1002)). The Labor Department's rule says the same about bonuses "systematically deferred to the termination of covered employment" ([29 CFR 2510.3-2(c)](https://www.law.cornell.edu/cfr/text/29/2510.3-2)). Members are the co-op's employees ([Owners Are Still Workers](labor-law.md)). There's a case that a co-op's profit allocations are an owner's share, not an employer's bonus, but the model doesn't stake members' savings on winning it.
A pension plan would break the account. Its money has to be held in a trust, apart from the business ([29 U.S.C. 1103](https://www.law.cornell.edu/uscode/text/29/1103)), and the plan can't lend to the employer ([29 U.S.C. 1106](https://www.law.cornell.edu/uscode/text/29/1106)). The whole point of the account is that the co-op uses the money.
**A fixed schedule is designed to stay outside the law's definition, but that isn't settled.** In 1995 the Labor Department said a company's profit-based program wasn't a pension plan because it didn't "expressly provide for a deferral of income to the termination of covered employment." But that program paid nothing to people who quit, and the Department pointed to that. It also warned that a program can still become a pension plan "as a result of surrounding circumstances," such as "an inordinate percentage" of participants being near retirement age when payments arrive ([Advisory Opinion 95-23A](https://www.dol.gov/sites/dolgov/files/EBSA/about-ebsa/our-activities/resource-center/advisory-opinions/erisa-section-3-3-employee-pension-benefit-plan-advisory-opinion-1995-23a.pdf)). Here, people who leave are still paid afterward, on the same dates as everyone else. A court could count that against the design. So:
- **Nothing about the payout depends on leaving:** not the date, not the amount, not the small-balance rule.
- **The wait is short and the same for everyone,** at most seven years, not tied to age or retirement.
- **The account isn't sold as retirement savings.** A co-op that wants to help members save for retirement does it the ordinary way, with a regular retirement plan held outside the business.
- **The first pilot co-ops ask the Labor Department for an advisory opinion on their real bylaws, and the bank pays.** An opinion only covers the co-op that asked, on its own facts, so the bank also asks the Department for a rule any employer could use, for profit-sharing paid on a short fixed schedule. It doesn't ask Congress for an exemption only co-ops would get.
**The cost is capital.** Money paid back after five years is money the co-op can't keep using for fifteen. So a co-op that wants to invest more has to keep more in its permanent reserve, and that weakens the model's answer to the horizon problem ([Investing for the Long Run](the-long-run.md#who-pays-for-an-investment-and-when)).
## Protecting the co-op, and the people who stay
A payout system can also be used to strip a failing firm. People who see trouble coming leave first and get paid in full, while everyone who stays absorbs the loss. So:
- **Every payout must pass a solvency test.** No payment may push the co-op below its loan requirements or a minimum cash level set in its bylaws when it's founded. Members can't lower that minimum without the bank's consent.
- **If payouts fail the test, they're paused, all together, for at most two years.** Credits from the same year are treated the same whether their owner still works there or not, and the oldest are paid first when money allows. Paused amounts earn the Treasury rate plus two points, since the people waiting are carrying extra risk. While any payout is paused, **current members get no cash profit share, no bonuses and no raises above the going rate.** Nobody gets paid ahead of the people who are waiting.
- **After two years, a pause becomes a debt with a schedule.** Whatever is still unpaid becomes a note the co-op repays in equal parts over five years, still behind its lenders, and the [insurance pool](savings-guarantee.md) covers former members' insured amounts if the co-op fails. So the worst case for a member who leaves is a long wait: up to seven years for the last credit, plus the pause, plus the note. The co-op's bylaws have to say so in plain words.
- **Losses are spread evenly.** When a loss is charged to members' accounts, it's charged to every year's credits that haven't been paid yet, in proportion, not to the newest or the oldest first. So the timing of payouts can't be used to dodge a loss.
- **Payouts rank behind the co-op's lenders.** In a bankruptcy, members' accounts are paid only after the co-op's creditors. That's the honest cost of being an owner, and it's the main reason for the one-year-of-pay cap.
- **The co-op doesn't lend to members against their accounts.** That would just be an early withdrawal by another name. Members can use their account as collateral with an outside lender.
- **Savings are insured, up to a point.** Every locked co-op belongs to a members' savings insurance pool, paid for by the co-ops. It covers workers' final unpaid wages, former members' unpaid payouts up to $50,000, and a protected slice of current members' accounts. See [Protecting Members' Savings](savings-guarantee.md).
- **No other savings in the co-op.** A co-op can't take deposits from its members or sell them bonds. A member's only money in the co-op is their account.
**Why this much caution.** Fagor, Mondragon's appliance maker and once the world's largest industrial co-op, went bankrupt in 2013 with about €800 million (about $1.1 billion) of debt ([The Local, 2013](https://www.thelocal.es/20131114/major-manufacturer-fagor-files-for-bankruptcy)), putting 5,600 jobs at risk. Savers who had bought its subordinated co-op bonds, which had been sold as safe deposits, ended up suing ([Wikipedia summary of the Mondragon record](https://en.wikipedia.org/wiki/Mondragon_Corporation)). Shortly before Enron collapsed in 2001, about 63% of its workers' 401(k) retirement savings were in Enron's own stock ([GAO-02-480T](https://www.gao.gov/assets/gao-02-480t.pdf)). It's the same lesson: savings concentrated in the company you work for are the savings most likely to disappear with your job.
## What the account doesn't do
This matters, because it's the core of [the founder problem](founder-problem.md).
**The account holds the profits you earned. It doesn't hold the value of the firm's future profits.** When a conventional firm is sold, the buyer pays for years of its future earnings, and the seller keeps that money. A co-op member's account never includes that part. The future profits go to whoever works there in the future.
That's intentional. It's what the lock is for. But it means a co-op member who helped build something valuable walks away with much less than a conventional owner would, and the model shouldn't pretend otherwise.
## What it adds up to
Here's what a member who joined a successful co-op in its third year has received by year 15, on top of their salary, in four kinds of business. The numbers come from a simple model with these assumptions:
- A one-year probation, then a profit share that grows from 20% to 100% over five years.
- 60% of profit goes to members.
- Money received later counts for less than money received now. It's discounted at 5% a year, the rate used in the founder study on the [next page](founder-problem.md).
| Kind of business | Profit per worker (after salaries) | Size at year 15 | **What the member received** |
| --- | --- | --- | --- |
| Care work (home care, cleaning) | $3,000 | 40 people | about **$14,000** |
| Main Street (trades, services) | $15,000 | 40 people | about **$68,000** |
| Strong (skilled services, light manufacturing) | $40,000 | 80 people | about **$200,000** |
| Very high margin (a firm like Conservice) | $150,000 | 200 people | about **$780,000** |
In a conventional firm, all of that goes to the owners.
**Be honest about the first row.** Home care runs on thin margins. For a care worker, a co-op offers a real say in how the work is done, and very little wealth. That's why the model has a separate answer for low-margin work: [the floor](the-floor.md).
These are all success cases. A co-op that struggles pays little or nothing. One that fails pays out only what's left after its debts.
@@ -0,0 +1,24 @@
---
title: Capital, After
section: Growing the Sector
order: 80
summary: Outside investment in a co-op is rented, not owned: profit-sharing claims that expire and carry no vote.
---
# Capital, After
Outside investment into a locked co-op doesn't disappear. It changes shape.
**Rented, not enthroned.** An investor funds the company and receives repayment plus a share of profits for the life of the loan. Then the claim **extinguishes**, and the workers hold 100%.
Today's equity is *perpetual*: an original investor's great-grandchildren collect forever off work they never did. This claim *expires*. That's the whole reform, in one word. Capital gets rented.
The returns can be enormous. A profit-share loan into a risky seed-stage co-op might pay 10x or 30x. That's fine. The principle was never *no big payday*. It was **no perpetual claim and no control**.
There is one limit, though. A claim that takes 90% of the profits for 40 years is "voteless and expiring" on paper, but it turns the co-op into a shell. So for co-ops borrowing from the bank, outside claims rank behind the bank's loan, last at most 15 years, and need the bank's approval. See [Guarding the Pilot](pilot-safeguards.md#draining-it-through-investors).
**Be honest about what this is.** A profit-contingent return means the investor eats the loss when things go badly. That is residual risk, which is the economic job we call equity, whatever we print on the paper. Don't claim it "isn't a stake in the work." It is. Claim what's actually true and actually better: *finite, and voteless.*
**The consequence, priced in.** Capital charges for risk. An instrument with equity-like downside and a terminating upside gets funded last, or demands a bigger profit share. Revenue-based financing already exists in today's market and stays niche for exactly this reason. It bites hardest on brand-new ventures and barely at all on mature, profitable firms.
This rule applies only to co-ops. Conventional firms keep conventional equity. That makes the cost-of-capital question an empirical one: [the test](the-test.md) will show whether co-ops can raise enough on these terms, alongside the bank, to grow.
@@ -0,0 +1,101 @@
---
title: What If Conservice Had Been a Co-op?
section: Making the Case
order: 160
summary: A real company that has taken on new private equity owners three times in ten years, imagined instead as a locked co-op from the day it was founded, with who would govern it, where its profit would go, and who would have received less.
---
# What If Conservice Had Been a Co-op?
[The thesis](thesis.md) uses one example: "TPG's stake in Conservice." This page works that example through.
It isn't a plan to take Conservice from anyone. This model doesn't buy or seize companies, and nothing here says any owner or founder did anything wrong. Every sale described below was lawful and chosen by the people selling. The question is about the structure, not the people: **what if a company with Conservice's business had been founded as a [locked co-op](the-lock.md) in 2000?** Who would govern it, where would its profit go, and who would have received less?
Everything on this page comes from public sources.
## The company as it is
Conservice, based in Logan, Utah, was founded on August 28, 2000, by David Jenkins and a business partner. It started out billing apartment residents for their utilities ([Herald Journal, 2020](https://www.hjnews.com/news/business/progressive-workplace-conservice-marks-20-years-as-standout-local-employer/article_5768eef4-b633-58a2-b036-7077c76cf86e.html)). Today it handles utilities for nearly 8 million rental units and other properties, working with more than 20,000 utility providers ([TPG, 2025](https://www.tpg.com/news-and-insights/tpg-to-acquire-majority-stake-in-conservice)).
It had 2,083 employees in 2020, up almost 900 in four years. By 2025 more than 2,000 came from its home county alone, making it one of the county's largest private employers. About 5,000 local people have worked there over its 25 years ([Herald Journal, 2025](https://www.hjnews.com/news/business/logan-based-company-conservice-celebrates-25-years/article_c24ef502-2cb3-4c43-916d-9a7320ec5977.html)). Its total headcount across all offices isn't public.
A local-paper headline called it a "standout local employer." It moved its staff home early in the pandemic when, as its founder said, "we could make people come in the office." It gives paid time for community service and matches part of workers' retirement savings ([Conservice careers page](https://www.conservice.com/careers)). Those benefits are real. They're also the owners' to give and the owners' to take back. **This page isn't about bad management.** It's about who holds the claim on everyone's work.
**Who owns it.** Private equity firms, which buy into companies with investors' money and borrowed money, have held stakes in it one after another:
- **2016:** TA Associates invests ([TA](https://www.ta.com/portfolio/investments/conservice/)).
- **2020:** Advent International buys a minority stake, at a value of $2.66 billion for the whole company ([Herald Journal, 2020](https://www.hjnews.com/news/business/progressive-workplace-conservice-marks-20-years-as-standout-local-employer/article_5768eef4-b633-58a2-b036-7077c76cf86e.html)).
- **December 2025:** TPG agrees to buy a majority stake. TA sells out completely, and Advent keeps a large stake. The price wasn't published ([TPG](https://www.tpg.com/news-and-insights/tpg-to-acquire-majority-stake-in-conservice)).
**What it earns.** TPG's purchase was backed by a loan package of more than $1 billion. The company was presented to lenders as earning about $230 million a year before interest, taxes and the accounting charges for wear and tear on its assets (a measure called EBITDA). People close to the deal told the trade press the real figure was more likely $175 million to $225 million ([Octus, January 2026](https://octus.com/resources/articles/update-1-blue-owl-crescent-expected-to-lead-private-credit-financing-for-conservice-buyout-by-tpg/)).
## Where the money goes now
- **Workers** get salaries and benefits. As far as public sources show, they have no vote on who owns the company, who runs it or where its profit goes.
- **Lenders** get the interest on more than $1 billion. That money backed TPG's purchase. In deals like this it usually also pays off the company's earlier loans. Either way, it's the company's own earnings that pay it back.
- **Owners** get what's left, and then the gain when they sell to the next owner. Each sale paid the previous owners, including the founders, at a price the buyer put at risk. And each price was a bet on the future work of people who still work there and had no say in the sale.
That's what the thesis means by a claim on other people's labor that the owner doesn't work alongside. A stake like that can be sold, again and again, and each sale puts a price on thousands of people's future work.
## The same company, founded as a co-op
Now imagine the same business, the same customers and the same growth, but founded in 2000 as a worker co-op under this model. That's a big assumption, discussed below.
**Who governs.** The workers are members, each with one vote. They elect a board, and the board hires the chief executive. Managers still manage. Members don't vote on every decision, only on what their bylaws say they vote on ([Worker Ownership](worker-ownership.md)). The company could have leaders much like its current ones. The difference is who they answer to.
**Not everyone is equal on day one.** New workers become members after a probation of up to a year. Their share of the profit then grows over about five years until it's full. Non-members can do up to a quarter of the work hours, but no more ([Guarding the Pilot](pilot-safeguards.md#the-hidden-boss)).
**No sales.** Under [the lock](the-lock.md), the company can never be sold to conventional owners. The 2016, 2020 and 2025 deals don't happen. No buyer means no billion-dollar loan to pay back.
**Where the profit goes.** Under the [default split](capital-accounts.md#where-each-years-profit-goes), 30% goes to a permanent reserve that nobody can ever withdraw, which pays for growth, the job outside investors' money does today. Up to 10% goes to the [federation](federation.md)'s shared fund. About 60% goes to members, split by how long each has worked there, up to a cap. At least a fifth of each member's share is paid in cash, half by default. The rest goes into their [capital account](capital-accounts.md), which is paid out after they leave. So nearly everyone among the 5,000 people who have worked there, everyone who stayed past probation, would have left with something.
**Unions still have a place.** Members can still organize, and a union can still bargain over pay, schedules and grievances with the managers the board hires. Owning the company doesn't make every disagreement disappear.
## What it could mean in dollars
This model's case isn't about money ([Making the Case](the-argument.md)). But the numbers show the scale.
They need two assumptions, because the real figures aren't public:
- **Profit.** EBITDA is higher than real profit, because it leaves out the cost of equipment and software that wears out. So assume real profit of **$125 million to $175 million** a year.
- **Headcount.** More than 2,000 employees come from the home county, and there are other offices. So assume **2,500 to 4,000** workers.
The table also assumes the average member holds about 70% of a full share, since newer members are still ramping up.
| Real profit | Workers | **A full-share member's profit share each year** (before income tax; about half in cash, the rest in their account) |
| --- | --- | --- |
| $125 million | 4,000 | about $27,000 |
| $125 million | 2,500 | about $43,000 |
| $175 million | 4,000 | about $38,000 |
| $175 million | 2,500 | about $60,000 |
In a co-op, salary and profit share are two parts of one income. Members set the split, within the model's rules. The figures above are on top of whatever salaries the co-op pays, which might not match today's. On taxes: a co-op generally doesn't pay corporate income tax on the profit it assigns to members, but members pay income tax on their whole share in the year it's assigned. The reserve is taxed like any company's profit.
These are rough numbers from public reporting, for a company that may never have existed in this form. They show the scale, not a promise.
## Would it have grown?
This is the honest weak point.
- **It grew for sixteen years before any private equity firm bought in.** By 2016 it already had roughly 1,200 employees (2,083 in 2020, less the almost 900 added in the four years before). So its early growth didn't depend on private equity.
- **Private equity's years brought acquisitions.** In 2020 it bought Capturis, a North Dakota company that pays commercial buildings' utility bills ([Conservice, 2020](https://www.conservice.com/press-releases/conservice-acquires-capturis-bringing-value-to-commercial-markets-served/)), and it has bought others since. A co-op can buy companies too. But under [the lock](the-lock.md), the people who work there have to become members, and some sellers wouldn't accept that. Otherwise the co-op ends up like Mondragon's foreign subsidiaries: a members' club that owns firms full of workers with no stake. So a co-op version would probably be smaller, and some of today's earnings came from companies it might not have bought.
- **Borrowing would be harder.** A first loan from [the bank](the-bank.md), at market rates with no personal guarantees, is exactly what the bank exists for. (The real company had two founders. The [pilot](pilot-spec.md#who-can-borrow) would have required a third.) But the pilot caps each co-op at $3 million in loans over its lifetime, so the bank gets a company like this started, not grown. After that, a profitable business can borrow from ordinary lenders. Conservice's earlier loan came from private lending funds at a normal rate for a company like it ([Octus](https://octus.com/resources/articles/update-1-blue-owl-crescent-expected-to-lead-private-credit-financing-for-conservice-buyout-by-tpg/)). But those lenders were lending to a company with owners' money beneath them to absorb losses first. A co-op, with its members' accounts paid out as people leave and [the lock](the-lock.md) narrowing who could buy it if it failed, would likely borrow less, at a higher price.
Whether co-ops can grow on those terms is exactly what [the test](the-test.md) is for.
## Who would have received less
**The founders.** As members, they would have had a founding share, their accounts, and executive pay while they ran it. They would never have sold. In the model's 200-person example, a co-op founder ends up with about $2 million against $29 million to $38 million for a conventional owner ([The Founder Problem](founder-problem.md#the-numbers)). Conservice was valued at $2.66 billion in 2020, so at its scale the gap would be far wider. What the founders actually received hasn't been published.
The model's aim is for that value to stay with the people who keep producing it. But that raises the question the whole model rests on: **would these founders have started this company as a co-op?** Nobody knows. The founder problem isn't solved, and this example doesn't solve it.
**The investors.** TA, Advent and TPG could still have lent to the co-op, or put in money for a share of profits that ends after a set time ([Capital, After](capital.md)). That would still be a claim on the members' work. The model's answer isn't that such a claim is free. It's that the claim ends and carries no vote. What they couldn't have bought is a permanent claim, the kind that can be sold again.
**The workers, in one way.** A member's account rises and falls with the company. The [insurance pool](savings-guarantee.md) protects part of it, not all of it.
## What this example doesn't prove
- **It assumes the company would exist either way.** That's the biggest assumption on the page. If worker ownership makes firms less productive, or if the founders would never have started it, the comparison falls apart. [The pilot](pilot-spec.md) is built to find out.
- **Conservice is unusually profitable.** A home-care co-op has very little profit to share. For low-margin work, ownership brings a say and not much money, which is why the model also has [a floor](the-floor.md).
- **Ownership changes who gets the profit, not who pays it.** A co-op version would bill the same renters, who don't choose their building's billing company. They'd still need the same protection from unfair fees that they need now.
- **Dollars aren't the main point.** It's about who decides. Thousands of people in one valley have seen new owners buy into their company three times in ten years, and none of them had a vote on any of those deals. In the co-op version, they would.
@@ -0,0 +1,27 @@
---
title: The Constitution
section: Guardrails
order: 130
summary: The biggest risk is that the model gets repealed, not that the state overreaches. Plus money out of politics and rights the majority can't take away.
---
# The Constitution
This model asks little of the state: charter [a bank](the-bank.md), enforce [the lock](the-lock.md), run [the floor](the-floor.md). The concentration-of-power argument still applies to the floor (see [Open Problems](open-problems.md) on "essential" sectors), but it isn't the biggest risk.
**The main risk is durability, not overreach.** A model that can be built gradually can be dismantled gradually. A future Congress can defund the bank, and a statutory lock can be repealed by the same kind of majority that passed it. Postwar social democracy got rolled back once the coalition that built it weakened; a voluntary co-op sector is exposed to the same thing. So the design question is: **what makes this hard to reverse?** Partial answers: the lock written into each co-op's own charter and backed by an independent lock share (see [The Lock](the-lock.md#how-the-lock-is-made-to-hold)), and into state co-op law, not just federal statute; a federation that becomes financially independent of the public bank; and, above all, a co-op sector large enough to have its own constituency. How each of those works, what it can't protect, and what happens now that any president can replace the bank's board, is in [Making It Last](durability.md).
**Money out of politics.** Legislative work pays well enough that good people want the job, but any money, gift, or benefit from a non-government entity ends the salary and starts a prison sentence. Politicians should benefit only from serving their people.
**But representation alone is not a guardrail.** This is the trapdoor, and it needs saying explicitly:
> *A government that perfectly represents its people will target a group whenever the people want that group targeted.*
Representation amplifies the majority; it does not restrain it. Jim Crow was representative: it represented its white voters faithfully, and that is exactly how it ground people down for a century. Internment was popular. Clean elections stop capture *by the rich*. They do nothing to stop the majority deciding a minority is the enemy.
**So: counter-majoritarian rights.** Things a person keeps *even when everyone around them votes to strip them away.* The whole point of a right is that it holds on the day the majority is wrong, and you cannot assume in advance that you'll always be the majority, or always be right.
- Keep the Bill of Rights. Make rights that protect individuals **extremely difficult to repeal.**
- **Two tiers.** Speech, due process, equal protection, freedom from imprisonment without trial: near-absolute. Property: strong, but balanceable against the public interest with compensation, which is what the Takings Clause already says. *(This model doesn't touch existing property at all. It asks nothing of the property tier.)*
- **The Senate had the right idea, badly built.** Keep the cooling-saucer function: a second chamber that slows things down and forces broad coalitions instead of bare-51% steamrolls. Drop the geographic weighting. Land should not get votes. Equal state representation and the protection of slavery were the *same bargain* in 1787, and that structure later shielded Jim Crow.
- **No final guardrail exists.** Entrench rights and you've moved power to whoever *interprets* them. There's no rule so perfect it guards itself. The answer isn't a better final rule; it's **redundancy**: several overlapping checks, none supreme, so that capturing any one of them doesn't hand someone the whole apparatus.
@@ -0,0 +1,131 @@
---
title: Making It Last
section: Guardrails
order: 125
summary: Anything built by one law can be undone by the next, so the model is built to survive a hostile government, and this page says what that protects and what it can't.
---
# Making It Last
The biggest threat to this model isn't that the state grows too strong. It's that a later government undoes it. Everything here rests on ordinary laws: a charter for [the bank](the-bank.md), a law backing [the lock](the-lock.md), and money set aside for [the pilot](pilot-spec.md). Anything one Congress passes, a later one can repeal ([The Constitution](constitution.md)).
Repeal is only the most obvious way to kill it. Public programs die in quieter ways too. They get starved of money and staff, run by people who want them to fail, or left without anyone able to sign off on decisions. A hostile government can also make an experiment *look* like it failed. This page goes through how those things have happened to real programs, and what the model does about each one. Some of it can be protected well. Some of it can't be protected at all, and the page says which is which.
## How public programs get killed
**By doing nothing: the Export-Import Bank.** America's export-credit bank has to be reauthorized by Congress every few years. In 2015 Congress let its charter lapse, and from July 1 until early December it couldn't make new loans. Then a second problem hit. Its board needs three voting members to act, and when two terms expired in July 2015, the Senate didn't confirm replacements. For nearly four years the bank couldn't approve any deal over $10 million. By mid-2016, more than 30 deals worth over $20 billion were stuck waiting ([Congressional Research Service, 2016](https://www.everycrsreport.com/reports/IN10574.html); [2019](https://www.everycrsreport.com/files/20190809_R43581_3f42ff567900d03421b053fdee99f7450d886877.html)). Nobody had to vote to shut it down. Opponents only had to not act.
**By cutting its staff: the CDFI Fund.** The Treasury's Community Development Financial Institutions Fund has backed community lenders for three decades. In March 2025, a presidential order told it to shrink to "the minimum presence and function required by law." In October 2025 the Treasury moved to lay off its roughly 100 staff. 105 Republican members of Congress wrote to defend it, and a court order blocking layoffs during that fall's government shutdown held them off for the time being ([Roll Call, 2025](https://rollcall.com/2025/10/27/senate-and-house-gop-defend-community-finance-fund-from-trump-cuts/)). A program that runs on the government's staff can be starved without being repealed.
**By taking the money back: the Greenhouse Gas Reduction Fund.** Congress created this $27 billion fund in 2022 to seed green lenders. By early 2025, $20 billion had been awarded to eight nonprofit lenders and was held at Citibank, which acted as the government's financial agent. In March 2025 the new administration terminated the grants and froze the money. The courts have gone back and forth, and the case is still going ([Columbia Law School Climate Law Blog, 2026](https://blogs.law.columbia.edu/climatechange/2026/08/07/a-big-win-in-few-words-what-to-glean-from-the-climate-united-fund-decision/)). Then, in July 2025, Congress repealed the program and took back all of its money that hadn't yet been committed. It did that inside a huge budget bill, passed by simple majorities under special rules that block a Senate filibuster. Because almost all of the money had already been awarded, the repeal itself took back only about $19 million ([Congressional Record, July 9, 2025](https://www.congress.gov/119/crec/2025/07/09/171/118/modified/CREC-2025-07-09-pt1-PgS4281-4.htm)). Two lessons: money the executive branch can reach can be frozen with a letter, and money not yet committed can be taken back by a line in a bill few people read. Money already awarded was much harder to take back.
**By changing what it is: America's co-op bank.** When the Reagan administration tried to close the National Consumer Cooperative Bank in 1981, it survived by being privatized. Then it drifted toward lending to co-ops that could have borrowed anywhere ([Has This Been Tried?](precedents.md#americas-co-op-bank-the-national-consumer-cooperative-bank)).
## How public programs survive
**A fair trial and a steady base: the Bank of North Dakota.** North Dakota's state-owned bank opened in 1919, created by a farmers' movement openly sympathetic to socialism. Its opponents fought it every way they could. They sued and lost at the US Supreme Court in 1920. They recalled the governor in 1921, the first governor recall in American history. They put abolishing the bank on the ballot, and lost by 4,238 votes. They worked with East Coast financiers to boycott the state's bonds, and that boycott, along with the recall, helped push the movement that founded the bank out of power. What saved the bank itself, historians say, was the governor from the opposing side who replaced the recalled one. He decided it was "right and just and wise to give it a full, fair, and honest trial," and ran it "in a sane and conservative way" ([Bank of North Dakota history](https://thebndstory.nd.gov/years-of-desperation/organized-opposition-tried-to-take-the-bank-down/)). It also has a structural advantage: by state law, the state's own money must be deposited with it ([N.D. Century Code § 6-09-07](https://www.ndlegis.gov/cencode/t06c09.pdf)). It's still running more than a century later. It survived by being careful, which this model's bank is designed to be from the start.
**Owners spread everywhere: rural electric co-ops.** In the mid-1930s, nine in ten rural American homes had no electricity. Private utilities wouldn't wire sparse areas. From 1936, the federal Rural Electrification Administration lent money to farmers who formed co-ops to do it themselves. By 1953, over 90% of farms had power. In 1942, facing false accusations during the war, the co-ops formed a national association to speak for them in Washington. Today more than 900 electric co-ops serve over 40 million people ([America's Electric Cooperatives](https://www.electric.coop/our-organization/history); [2026](https://www.electric.coop/rea-at-90-years-celebrating-the-landmark-law-that-birthed-rural-electrification)). The program built its own defenders: owners in almost every congressional district. That cuts both ways. Critics can fairly say that defenders like these keep subsidized loans flowing long after the original job is done.
**Paying the government back: the Farm Credit System.** Congress started a system of farmer-owned co-op lenders in 1916 and expanded it in 1933, with federal money. The lenders repaid all of it, and by 1968 they were wholly owned by the farmers who borrowed from them ([Farm Credit Administration](https://www.fca.gov/about/historical-highlights-of-fca-and-the-fcs)). Once the system belongs to its borrowers, no budget vote can starve it and no president can replace its lenders' boards. It wasn't the end of the risk: in the 1980s farm crisis the system needed a federal rescue, of which about $1.3 billion was used.
**What political scientists find.** Eric Patashnik studied what happens to major reforms after they pass. He found that "the reforms that stick destroy an existing policy subsystem and reconfigure the political dynamic," creating their own supporters ([Reforms at Risk, Princeton University Press](https://press.princeton.edu/books/paperback/9780691138978/reforms-at-risk)). Reforms that leave the old interests in place tend to unravel.
## Who controls the bank now
The model says the bank should be walled off from politicians "the way monetary policy is walled off from Congress." The obvious way to do that is to give the bank's board fixed terms, so a president can remove them only for misconduct. That route is now closed.
In June 2026, in *Trump v. Slaughter*, the Supreme Court held that the Federal Trade Commission's protection from removal was unconstitutional, because officials under the President's "general administrative control" must be removable at will. It put aside only the Federal Reserve, "to the extent that it follows in the distinct historical tradition of the First and Second Banks of the United States—both of which influenced monetary policy" ([Trump v. Slaughter, 2026](https://www.supremecourt.gov/opinions/25pdf/25-332_qn12.pdf)). The same day, in *Trump v. Cook*, the Court upheld the Fed governors' protection, on the ground that "monetary policy should not be subject to political interference" ([Trump v. Cook, 2026](https://www.supremecourt.gov/opinions/25pdf/25a312_5468.pdf)). A bank that lends to co-ops doesn't set monetary policy, so it almost certainly doesn't fit that exception.
So this model assumes **any president can replace the bank's whole board, any time.** The protection has to come from what the board *can't* do, not from who sits on it. That's also more honest. A bank run by people nobody can remove would be exactly the unaccountable power this model warns against elsewhere.
## The rules
### What a new board can't change
The things that decide whether the experiment is fair are written into the law that creates the bank, not left to the board:
- the [co-op definition and the lock](worker-ownership.md) that borrowers must accept;
- the measures, thresholds, reviewers and deadlines in [the decision rule](pilot-spec.md#the-decision-rule);
- **the lending standards**, meaning the scoring model used to judge applications, filed with the rest of the plan before the first loan;
- the [rate audit](pilot-spec.md#what-market-rate-means), which works in both directions;
- the loan limits and the rules against [political favoritism](pilot-safeguards.md#political-favoritism);
- **how loans are managed once made.** A loan can be called in early only if payments stop, never for a technicality. The rules for handling a co-op in trouble are filed in advance too.
The board can propose changes to the lending standards, with published reasons. The reviewers then report every result both ways: under the original standards and under the new ones. By law, no official can direct a decision on a particular loan, and any attempt is logged and published.
### Rigging the result
A hostile board has two ways to make co-ops look like failures without breaking any rule openly. The reviewers check for both.
- **Turning away good applicants.** Every year, the reviewers audit a sample of rejected applications against the filed standards and publish the rejection rate for applications that met them. If too few co-ops get funded because applicants didn't show up, that's a real result, as [the pilot](pilot-spec.md#why-about-400-co-ops) says. If too few get funded because the bank turned away applicants who met its own standards, the final verdict says so: **"not tested,"** not "failed." The audit also covers co-ops in lower-paid work separately, so the bank can't quietly drift toward the safest co-ops ([Who the Bank Lends To](who-gets-loans.md)).
- **Making bad loans on purpose.** If the [emergency stop](pilot-spec.md#the-decision-rule) fires, the reviewers check whether the losses come mainly from loans that broke the filed standards. If they do, it's treated like fraud, not like a verdict on co-ops. Lending restarts under the original standards, and those loans are reported separately.
**This isn't a way to keep the bank alive.** "Not tested" means exactly what "failed" means for the bank: no new loans, the wind-down, and no second round of money. It only changes what the record says about co-ops. Nothing on this page extends the timeline either. The latest the final review can happen is year 15, which is the pilot's existing rule for when good applications are scarce.
### No quorum, no freeze
Unlike the Export-Import Bank, this bank can't be frozen by leaving board seats empty. It uses ordinary delegation, which keeps the President in charge:
- **Standing authority for smaller loans.** The board appoints senior credit officers and gives them authority to approve loans up to $1 million under the filed standards. That authority doesn't lapse when board seats go empty. Any board can take it back, publicly, but it can't disappear by default.
- **No quorum means an acting chair, not no one.** If the board can't meet, the Treasury Secretary, or an acting official named under the normal federal vacancy law, can act for it on loans too big for staff.
- **Applications can't be buried.** A complete application gets a decision within 120 days, yes or no, with reasons. The clock pauses while the bank is waiting on information it asked for. The rule forces an answer, not a yes.
- **A second check during vacancies.** Any loan over $500,000 approved while the board lacks a quorum is cleared by the independent auditor before any money goes out.
### Where the money sits
The $350 million, and the separate $100 million for [worker buyouts](buyouts.md), is paid into the bank as its own capital when it's chartered, not handed over year by year. The model says plainly what that does and doesn't protect.
- **It needs explicit legal cover.** By default, the Treasury keeps a federal corporation's accounts ([31 U.S.C. § 9107](https://www.law.cornell.edu/uscode/text/31/9107)). The farm co-op banks were exempted from that rule by name, and this bank's law has to do the same, so its money isn't held where one agency can freeze it.
- **Running costs come from the bank's capital and income**, as [the pilot](pilot-spec.md#how-much-money-and-why) already says, not from yearly appropriations. So nobody can starve it by refusing to pass a budget line.
- **The reviewers are paid in advance.** Money for the independent review is set aside from the capital on day one and paid to the reviewers by contract. A hostile government can't quietly disable the referee by not funding it.
- **What it doesn't protect.** Money not yet lent can still be taken back by Congress, the way the green-bank program's uncommitted money was. That takes a law, and it happens in public, but it can be a single line in a large budget bill passed by a simple majority. The pilot's capital will sit partly unlent for years, and that's exactly the kind of money that gets taken. Congress can also attach riders to spending bills that bar the bank from using its money. Neither can be prevented. The only defense is that doing it is visible.
### Loans already made keep their terms
Every loan is a binding contract. If a later law ends the bank, money already lent keeps its terms, and the loans are offered first to co-op and community lenders, as they would be after a failed test (see [the decision rule](pilot-spec.md#the-decision-rule)).
The wind-down has its own guards. Loans are sold for no less than an independent appraisal, only to lenders named in the bank's law (co-op lenders, credit unions and nonprofit community lenders), and they can't be resold to anyone else for five years. Otherwise a hostile Treasury could sell them cheaply to a front for a distressed-debt buyer.
Future stages of a loan that haven't been paid out are *not* promised. The contract says so in plain words. If Congress ends the bank, it doesn't owe anyone money for loans it never made, and one Congress can't lock a later one into paying out.
If a later law tried to change the terms of money already lent, there's precedent for the government owing damages. In *United States v. Winstar* (1996), Congress changed banking rules in a way that broke promises regulators had made to savings-and-loan companies that took over failing ones. The Court's lead opinion held that Congress was free to change the law, but the government still owed damages for breaking its contracts ([Winstar, 1996](https://www.law.cornell.edu/supremecourt/text/518/839)). It's only partial protection. There was no single majority opinion, the damages cases dragged on for years, and it only helps where the contract clearly puts the risk of a change in the law on the government. So the loan contracts say that explicitly.
### The lock doesn't depend on Washington
[The lock](the-lock.md) lives in each co-op's own charter and is enforced by an independent lock-holder, not by the bank. So repealing the bank's law doesn't unlock a co-op in ordinary times. There's one big exception. The lock only holds in bankruptcy if federal bankruptcy law is amended (see [When a Locked Co-op Goes Bankrupt](bankruptcy.md)), and a Congress that repeals the bank could repeal that amendment too.
States are the second layer. Co-op law in America is state law. Massachusetts, for example, has had a law for worker co-ops since 1982, but a co-op can leave that status by a two-thirds vote of its members ([Mass. Gen. Laws ch. 157A, § 4](https://malegislature.gov/Laws/GeneralLaws/PartI/TitleXXII/Chapter157A/Section4)). That's exactly the exit the lock closes. So the model includes a short model state law that recognizes locked co-ops and their lock shares. States adopt it or don't. Nothing in the federal law requires it. It only protects co-ops that chose the lock, in exchange for a loan they chose to take.
### Federal test, state copies
[Open Problems](open-problems.md) asked whether the bank should be federal or run by states. The answer here is both, for different jobs:
- **The test is federal.** One bank, one plan and one set of reviewers make one clean experiment. Fifty small ones, each designed a little differently, can't be added up into one answer.
- **Any state can copy it.** A state can charter its own co-op lender under the same published rules. A state lender can even be given a steady base the way North Dakota's is, by holding some state money on deposit. Its results are reported separately and don't count toward the federal test in either direction. If Washington abandons the idea, the states that copied it keep running.
### The sector pays its own way
The last layer is the only one that doesn't depend on anybody's goodwill: the co-ops' own money. The [federation](federation.md)'s shared fund is built to become a lender itself, the way Italy's Coopfond has lent to co-ops for three decades out of their own profits ([Has This Been Tried?](precedents.md)). The sooner co-ops can finance each other, the less a hostile Congress can do.
## Why a hostile government might leave it alone
The Bank of North Dakota survived because the side that had fought it decided to give it a fair trial. This model tries to make that the cheaper choice for an opponent:
- **It's a one-time cost.** There's no yearly spending line to fight over.
- **It shuts itself down if it fails.** An opponent who expects co-ops to fail can let [the decision rule](pilot-spec.md#the-decision-rule) prove it, and the rules above make it hard to rig that result either way.
- **It buys no one's company.** It still spends public money, and conventional competitors and some employers will oppose it anyway. Italy's main employers' association got part of its worker-buyout program suspended by the EU ([Has This Been Tried?](precedents.md#italys-co-op-funds-coopfond-and-the-marcora-law)). But no industry loses its property.
- **Every result is public,** so whoever ends it early has to explain why.
None of that stops a determined opponent. It only raises the cost of acting in bad faith.
The electric co-ops show the longer-term defense. If the test passes and the sector grows, it will eventually have members in most congressional districts. People who own part of something defend it. The same defenders can also lobby to keep a program running after it's stopped earning its place. That's why the pilot's growth depends on a fixed test, not on how many friends it has.
## What this can't protect
- **The bank itself.** A Congress and President who both want it gone can repeal it. Nothing here prevents that, and nothing should. A democracy has to be able to change its mind. The design only makes sure the repeal is done by law, and that it can't take the co-ops' charters with it.
- **Money not yet lent,** which a budget bill can take back, and riders that bar spending.
- **A board that's merely timid.** The rules stop a freeze and expose rejecting good applicants. They can't make a hostile board eager, stop it hiring timid staff, or stop it pulling back the credit officers' authority in public.
- **A small constituency.** At pilot scale, around 400 co-ops employ perhaps several thousand people. That's nowhere near enough to defend anything politically. The electric co-ops became defenders after decades and hundreds of co-ops. Until this sector is large, durability comes from the co-ops' own charters and money, not from votes. How long it could take to get large is worked through in [After a Pass: How Big, How Fast](scale.md).
- **Pressure outside the law.** Other banks refusing to work with co-ops, co-ops shut out of government contracts, targeted inspections. The federation and co-op-friendly lenders like credit unions are the only real answers, and they take time to build.
- **The courts.** How far *Slaughter* reaches, and whether damages follow when a later law breaks a loan contract, will be settled by judges, not by this design.
@@ -0,0 +1,114 @@
---
title: Who Decides What's Essential
section: Beyond Co-ops
order: 112
summary: A public subsidy for "essential" low-paid work invites every low-wage industry to call itself essential, so a fixed formula decides which work qualifies, the money goes to workers by the hour, and states can't use it as a reason to pay less.
---
# Who Decides What's Essential
[The floor](the-floor.md) includes a targeted subsidy for essential low-paid work, paid to workers, not firms. Somebody has to clean the nursing home for the poor, and if the work can't pay a decent wage, the public should.
The danger is in the word "essential." Whoever decides which work counts gets to set part of the price of labor in that industry. Every low-wage industry will discover that it's essential, and every one has lobbyists.
## How fast "essential" grows
We've seen it happen. Early in the COVID-19 pandemic, the federal government published an advisory list of "essential critical infrastructure workers" who should keep working. Researchers at the CDC's occupational safety institute later matched it to standard industry codes. It covered industries employing about 81% to 87% of American workers, depending on how industries are counted ([Billock and others, 2022](https://pubmed.ncbi.nlm.nih.gov/35532007/)). That list was only advice about who could keep working, with no money attached, and "essential" still ended up meaning nearly everyone. A list that came with money would be under even more pressure.
## Who really gets the money
The second danger is quieter. Money aimed at workers can end up with employers.
The Earned Income Tax Credit tops up low earners' pay through the tax system. Because it draws more people into low-wage work, it pushes those wages down. One careful simulation, using a range of plausible assumptions about how workers and employers respond, found that each dollar spent raises workers' after-tax incomes by only about 73 cents. Employers capture much of the rest through lower wages, partly at the expense of low-wage workers who don't get the credit at all ([Rothstein, 2010](https://www.nber.org/papers/w14966)).
Washington, D.C., tried a more direct route. Its Early Childhood Educator Pay Equity Fund paid child care workers supplements of $10,000 to $14,000 a year, sent to the workers themselves, outside their employers' payrolls. More than 4,000 educators received them, and over $80 million went out in the first two years ([D.C. OSSE](https://osse.dc.gov/ecepayequity)). An independent study found the fund added about 219 child care educators, around 7% more than there would otherwise have been. Employers' own pay didn't change, so in those two years they didn't claw the money back by cutting wages ([Mathematica, 2024](https://www.mathematica.org/publications/two-year-labor-market-impacts-of-washington-dcs-early-childhood-educator-pay-equity-fund)). Two years is short, though, and D.C. has since switched to paying the money through employers' payrolls. D.C. also shows a limit of what follows: child care is mostly paid for by families, so the test below may leave it out.
Minnesota took a third route, and it gives workers a voice. In 2023 it created a board of three state officials, three employer representatives and three worker representatives, with the power to set minimum pay for nursing home workers. The board set $22.50 an hour for certified nursing assistants and at least $19 for everyone else, rising in 2027. The state pays for it through the rates Medicaid pays nursing homes, and it took effect in September 2026 after federal approval ([Minnesota Nursing Home Workforce Standards Board, 2025](http://www.dli.mn.gov/sites/default/files/pdf/nhwsb_annual_report_2025.pdf); [RMCEP](https://www.rmcep.com/success-stories/news-archive/p/item/69158/minnesotas-minimum-wage-standards-for-nursing-home-workers-in-effect-sept-10)).
## The model's answer
### A formula decides, not people
No official, agency or Congress picks industries by name. Work qualifies only if it passes two tests, measured from published government data:
1. **The public already pays for most of it.** Over the last three years, at least half of the industry's revenue came from public benefit programs like Medicaid, Medicare, veterans' care and public child care assistance. Ordinary government purchasing, like cleaning or security contracts, doesn't count. How that's measured, and which kinds of work pass today, is below.
2. **The job pays less than most.** The typical hourly wage for the job, within that industry, is below the typical wage for all American jobs. In May 2025 that was about $24.50 an hour ($50,980 a year). Home health and personal care aides earned a typical $17.21 an hour, and nursing assistants $20.13 ([BLS, home health and personal care aides](https://www.bls.gov/ooh/healthcare/home-health-aides-and-personal-care-aides.htm); [BLS, nursing assistants](https://www.bls.gov/ooh/healthcare/nursing-assistants.htm)). The test is by job, not by industry, so the nurses and managers in a nursing home don't pull the aides' number up, and splitting staff into separate companies doesn't change anything.
The first test is the important one, and it's really a test of who pays, not of what's "essential." That's on purpose: it's the one test lobbying can't argue its way around. Where the public is already the main buyer, the public is already setting the price, and a low public rate is a choice about what these workers are paid. It also keeps the program narrow. Retail, restaurants and other low-wage work paid for by private customers don't qualify. They're covered by the parts of the floor that apply to everyone, like the minimum wage and tax credits. Averaging over three years means an industry can't tip itself over the line in a single year.
The national statistics agencies apply the formula every three years and publish the list, with old industry codes mapped to new ones when the codes are revised. Congress can change the formula, but only for everyone at once, with a published cost estimate. It can't add one industry by name.
### Measuring who pays
The first test needs data on who pays each industry's bills. That data exists for some kinds of care, is misleading for others, and is missing for the rest.
- **The Census Bureau's yearly survey of service businesses** splits health care industries' revenue by who paid it ([Service Annual Survey, Table 4, 2022 release](https://www.census.gov/data/tables/2022/econ/services/sas-naics.html)). But it counts payments from private plans that run Medicare and Medicaid for the government, like Medicare Advantage and Medicaid managed care, as "private health insurance." In 2021, 24 states paid for Medicaid long-term care through those plans ([MACPAC](https://www.macpac.gov/subtopic/managed-long-term-services-and-supports/)), so the survey undercounts public money.
- **The federal health spending accounts** count those payments as Medicare and Medicaid, where they belong ([CMS methodology](https://www.cms.gov/Research-Statistics-Data-and-Systems/Statistics-Trends-and-Reports/NationalHealthExpendData/Downloads/dsm-17.pdf)). But they're organized by type of care, not by industry, and some categories mix several industries.
- **For social assistance, like child care and non-medical home care, the Census survey doesn't ask who pays at all.**
**What the two health sources show.** The survey column counts only payments made directly by Medicare, Medicaid and other government programs, so it's a floor. The spending accounts column counts managed care too, but its categories are broader.
| Kind of work | Census survey, 2020–22 average | Health spending accounts, 2021 |
| --- | --- | --- |
| Home health care | About 63% | About 72% |
| Nursing homes | About 66% | About 56%, combined with retirement communities |
| Residential care for developmental disabilities, mental health and addiction | About 67%, but only 45% from Medicare and Medicaid. The rest is other government programs, mostly state ones | Part of a mixed category that's about 61% public |
| Assisted living and retirement communities | About 16% (2020–21; 2022 withheld). Families pay nearly half | Combined with nursing homes, above |
| Hospitals | About 34% (2020–21; part of 2022 withheld) | About 52% |
| Child care | Not measured | Not covered |
Sources: Census [Service Annual Survey, Table 4](https://www.census.gov/data/tables/2022/econ/services/sas-naics.html), 2022 release; [MACPAC, from the national health spending accounts](https://www.macpac.gov/wp-content/uploads/2023/12/EXHIBIT-3.-National-Health-Expenditures-by-Type-and-Payer-2021.pdf), counting Medicare, Medicaid, CHIP and veterans' and military care.
Three things stand out:
- **Home health passes on any measure.** Non-medical home care, where most personal care aides work, isn't measured by industry at all. Medicaid paid about 60% of the broader category that includes most home and community-based care in 2023 ([MACPAC](https://www.macpac.gov/medicaid-101/spending/)), so it very likely passes, but real data has to show it.
- **Counting managed care properly puts hospitals near the line.** Because the wage test is by job, a hospital that qualifies brings in its low-paid aides, housekeepers and cooks. That's consistent with the rule, since the public does pay for most hospital care, but it would add a great deal to the cost. It's shown here so nobody is surprised by it later.
- **Child care is the hard case.** The US Treasury estimated that federal and state spending equaled about 47% of child care businesses' revenue in 2019, and said that probably overstates the public share, since some of that money pays for care outside those businesses ([US Treasury, 2021](https://home.treasury.gov/system/files/136/The-Economics-of-Childcare-Supply-09-14-final.pdf)). Pandemic relief money pushed the share up for a few years. Until there's real data, child care's status is undetermined.
**So the law fixes the method in advance:**
- **Which money counts.** A closed list: Medicare and Medicaid, including their private plans; CHIP; veterans' and military health care; the Indian Health Service; state programs for people with disabilities; child care assistance and Head Start. Only money paid to businesses in the industry counts. Workers' compensation, tax credits, Social Security benefits paid on a resident's behalf (which are the resident's own income), ordinary government contracts, and temporary emergency money don't. Changing the list follows the same rule as changing the formula: for everyone at once.
- **Measured from payment records.** Public money comes from the programs' own payment records, matched to businesses by tax ID, not from what businesses say in a survey. Revenue comes from the Census survey, counting revenue from care, not donations or investment income. Where the Census withholds a figure, the last published one is used, and flagged.
- **New data, on a deadline.** The Census survey has three years to report Medicare and Medicaid plan payments separately from private insurance, and to ask child care and home care businesses who pays them. The cost comes out of the supplement's own budget.
- **Status is sticky.** An industry joins the list only after two reviews in a row at 50% or more, and leaves only after two reviews in a row below 45%. So sampling noise near the line, or one year's emergency money, can't flip it. A newly qualifying industry is paid only after its cost estimate has been public for a full budget year.
- **No losing status for missing data.** If a list isn't published on time, the last one stays in force. When industry codes are revised, an industry keeps its status for two reviews while the new codes are mapped, and the mapping is published for comment first.
- **No moving into a qualifying industry.** A company that works mainly for a related business takes that business's industry, and industry codes are checked against state licensing. An assisted living operator can't qualify by moving its aides into an affiliated home health agency.
### Paid to workers, by the hour
- **Straight to the worker,** as a fixed amount per hour worked, as D.C. did at first. It's taxed like wages but doesn't count as income for the worker's own benefits, like Medicaid or food assistance, so it can't knock anyone off them.
- **Real hours only.** Federal payroll forms report pay, not hours. So hours come from records that already exist for public programs, like the electronic visit records federal law has required for Medicaid home care since 2020 ([Medicaid.gov](https://www.medicaid.gov/medicaid/home-community-based-services/guidance/electronic-visit-verification-evv)), and from employers' own payroll hours where those aren't available. Hours per worker are capped, samples are audited, and when padded hours are found, the employer repays.
- **Everyone doing the work counts,** including home care workers employed directly by the people they care for through a state program, and self-employed providers paid by a public program.
- **It fades out gradually** as a worker's own hourly pay rises above the threshold, instead of stopping all at once. That means someone just above the line keeps less of each raise for a while, a real cost stated openly.
- **Same for everyone.** Co-ops and conventional firms qualify on exactly the same terms. The subsidy doesn't favor co-ops, and it doesn't skew [the pilot's](pilot-spec.md) comparison with conventional firms, because both sides get it.
### Stopping the money from leaking
Money aimed at workers has three ways out. Each gets its own rule.
- **The public payer.** The biggest risk is that states cut their Medicaid rates, knowing the federal supplement will fill the gap. So a state's rates for qualifying care can't fall behind inflation. If they do, the federal share of the state's Medicaid spending on that care drops by the same amount. The supplement also can't count toward any rule requiring a share of rates to go to wages, and it's left out when rates are set.
- **The employer.** An employer can't let any job's total pay, wages plus benefits, fall behind inflation compared with the year before its workers qualified. One that does repays the difference to its workers, with a penalty. Pay in qualifying work is compared every year with similar work that doesn't qualify, so slower raises show up.
- **The owner.** In industries full of related-party rent and management fees, public money that frees up payroll can still end up with owners. The supplement doesn't fix that. It's a reason for the rate rules states set, and for co-ops.
### The budget
The supplement is a fixed amount per hour, set in law and adjusted for inflation, so it doesn't shrink when Congress wants to starve it quietly. The cost is whatever that amount times the qualifying hours comes to, published every year. When the formula adds an industry, the cost rises, and everyone can see by how much.
For scale: $2 an hour for 5 million workers averaging 1,500 hours a year costs $15 billion a year. There are about 4.7 million home health and personal care aides alone ([BLS](https://www.bls.gov/ooh/healthcare/home-health-aides-and-personal-care-aides.htm)), though not all of them would qualify.
### Worker voice, too
A formula keeps lobbyists out, but it also leaves workers out. Minnesota's board shows another way: workers, employers and the state set minimum pay together, and the public rates pay for it. The two fit together. States can set up boards like Minnesota's for qualifying work, and the federal supplement comes on top, because states can't cut their own spending.
### A way off the list
The founding plan pairs this subsidy with public research on automating the same work, and says plainly that it's meant as a bridge, not forever ([Ownership and the Floor](the-floor.md)). The formula makes the exit automatic. The wage test is measured without the supplement, so the supplement itself can't push an industry off the list. When real pay rises, because of automation, higher public rates or anything else, the industry drops off, fading out over two reviews rather than all at once. For care work, the bridge will probably be long. It ends when the public pays what the work is worth.
## What this doesn't solve
- **Some essential work won't qualify.** Where families, not the public, pay most of the cost, the formula leaves the work out. That may include a lot of child care, where D.C.'s evidence comes from. Families who pay for care themselves, and people who care for relatives unpaid, get nothing from this. That's the price of a rule that can't be bent. States can still do what D.C. did.
- **The formula will be fought over.** Industries will argue about how their work is classified and what counts as public revenue. Checking workers against public program records, not just the codes employers pick, is the defense. It isn't perfect.
- **Capture isn't ruled out.** The rules catch pay cuts, including cuts in benefits, and states cutting their rates. They can't catch every raise that would have happened anyway.
- **It costs real money.** Tens of billions of dollars a year is plausible once home care and nursing homes qualify, and much more if hospitals do. That's a choice about what care workers are paid, and the page states it so voters can make it.
- **It's federal money for work states largely pay for.** States already can, and some do, raise care workers' pay themselves. The federal role here is to make that pay not depend on where you live, and to stop states using it as a reason to pay less. That sits uneasily with leaving child care out: its workers are overwhelmingly women, and if it doesn't qualify, their pay still depends on their state.
- **Congress can still change the formula.** It's harder to bend than a list, not impossible.
@@ -0,0 +1,21 @@
---
title: The Federation
section: Growing the Sector
order: 70
summary: Co-ops pool risk through a shared fund that absorbs shocks, moves workers when a firm fails, and becomes a lender in its own right.
---
# The Federation
Worker ownership has a risk problem that's easy to miss: **concentration.** It matters even more in a young, voluntary co-op sector.
A worker-owner holds their salary, their profit share, and their skills all inside one firm. That is exactly the risk a shareholder diversifies away and a worker cannot. It is the real source of risk-aversion: if one bad bet can kill the company that is also your paycheck and your equity, you take the safe option every time, and an economy of safe options stops producing breakthroughs.
The fix is a structure between firms, and Mondragon already built it:
- **Firms pool a slice of profits into a mutual solidarity fund.** The fund absorbs shocks. A firm having a bad year draws support instead of dying.
- **When a firm does die, the federation redeploys its workers into member firms.** Your company failing stops meaning your life failing. *This* is what licenses healthy risk-taking: not lower stakes on paper, but a floor under the fall.
- **The fund becomes a second lender.** Over time, the federation (financed by its members and by the indivisible reserves of dissolved co-ops) becomes a source of patient capital that doesn't depend on the public bank at all. That is the path to the co-op sector outliving the political coalition that started it.
- **Federations can insure their members' savings.** A federation can run its own savings insurance pool, as long as it meets the national standards and reinsures with the others. Its co-ops' workers are then covered close to home (see [Protecting Members' Savings](savings-guarantee.md)).
Membership is voluntary, but the incentives all point in: unfederated co-ops face the concentration problem alone.
@@ -0,0 +1,84 @@
---
title: The Founder Problem
section: The Co-op
order: 40
summary: Without the chance to sell the company, why would a strong founder start a co-op? The numbers, the tools, and the honest answer.
---
# The Founder Problem
This is the hardest part of the design, and it needs to be said plainly.
**In a conventional business, a founder who succeeds gets paid in two ways: a share of the profits every year, and a big payday when the company is sold.** A co-op founder gets neither in full. The profits are shared with every member, and [the lock](the-lock.md) means the company can never be sold. So the obvious risk is *adverse selection*, meaning the pool you get is the one nobody else wanted: the best entrepreneurs go conventional, and the bank's applicants are the people who couldn't raise money anywhere else. If that happens, the experiment is rigged to fail before it starts.
This page runs the numbers, lays out the tools, and says honestly what they can and can't fix.
## The numbers
Take the same business, with five founders who all draw normal salaries, built two ways:
- **As a conventional firm:** the five founders own it outright, financed by a bank loan. Each year they pay out 60% of profit to themselves and keep 40% in the business to fund growth. At year 15 they sell it for three to six years' profit.
- **As a co-op:** the five founders are the first members. The profit split is the same (60% to members, 40% kept in the reserve and the federation fund), but it's shared by everyone who joins, and nobody sells. (See [Member Capital Accounts](capital-accounts.md) for how the money flows.)
Here's what **one founder** has received by year 15 if the business succeeds, on top of salary. Money received later counts for less than money now. It's discounted at 5% a year, the rate used in the founder study below.
| Kind of business | Profit per worker (after salaries) | Size at year 15 | **Co-op founder** | **Conventional founder** (one-fifth owner) |
| --- | --- | --- | --- | --- |
| Care work | $3,000 | 40 people | about $30,000 | $140,000 to $180,000 |
| Main Street | $15,000 | 40 people | about $150,000 | $710,000 to $880,000 |
| Strong | $40,000 | 80 people | about $450,000 | $3.2 million to $4.2 million |
| Very high margin | $150,000 | 200 people | about $2 million | $29 million to $38 million |
**In the success case, a co-op founder ends up with somewhere between a fifth and a twentieth of what the owner of the same firm would.** The bigger and more profitable the firm, the wider the gap. Nothing on this page makes it go away.
Three caveats about these numbers:
- **They assume the firm exists and works equally well either way.** That's the big assumption. If co-op ownership makes firms less productive, or if the firm would never have been founded as a co-op, the comparison is moot. The first is exactly what [the pilot](pilot-spec.md) measures. The second is the founder problem itself.
- **They're a simple model, not a forecast.** New hires join as members after a year and ramp up to a full share over five. The workforce grows steadily to the size shown. Change the growth rate or the sale price and the ratios move, but the gap never comes close to closing.
- **Taxes are left out on both sides.**
## Where the money went
In the very-high-margin case, a worker who joined in year three has received about $780,000 by year 15 (see [Member Capital Accounts](capital-accounts.md#what-it-adds-up-to)). The people hired after them have less, because they've had fewer years at full share, but all 200 are building a share of what the business earns.
**If the firm exists either way, the founder's "missing" money isn't lost. It went to the people who did the work.** That's the whole model. A version of the co-op that paid founders like owners would just be a conventional firm with extra steps.
So the real question isn't "can a co-op match a conventional exit?" It can't. The question is: **are there enough good founders for whom the co-op deal is good enough?**
## Why it's less lopsided for most founders
The table compares a co-op with a conventional firm *that succeeds*. Most don't. And for a founder without much money, a big risky payoff is worth far less than its average.
- **Most venture-backed founders get nothing from the exit.** Robert Hall and Susan Woodward studied nearly every venture-capital-backed US startup from 1987 to 2008, about 22,000 companies. The average founder got $5.8 million at exit, but **almost three-quarters got nothing at all**. A few enormous wins drive the average. For a founder with $100,000 in savings and an ordinary aversion to risk, they calculated that the chance to found a venture-backed startup was worth *slightly less than nothing* compared with a salaried job ([Hall and Woodward, *American Economic Review*, 2010](https://web.stanford.edu/~rehall/HallWoodward6.pdf)). That study covers tech and biotech startups, not the small businesses the bank would fund, but the logic carries over. A long-shot payday is worth much less than its average to someone who can't afford to lose.
- **Most small-business owners earn less than they would as employees.** Barton Hamilton found that after ten years in business, the median self-employed person earned 35% less than in a paid job. That counts the growth in value of their business ([Hamilton, *Journal of Political Economy*, 2000](https://www.journals.uchicago.edu/doi/abs/10.1086/262131)). The median small business that does sell goes for about $345,000 ([BizBuySell, 2024](https://smallbiztrends.com/bizbuysell-insights-report-q4-2024/)). Hamilton read the gap as people paying to be their own boss, and co-op founders give up some of that, since a co-op founder is one vote among many.
- **The co-op's downside is smaller.** A co-op that fails costs its members their jobs and what's left in their accounts. Under [the pilot's loan terms](pilot-spec.md#loan-terms), members give no personal guarantees, while SBA loans require one from every owner of 20% or more. That's a feature of how the bank lends, not of co-ops as such. It still matters to the founder deciding which to start.
So for a founder *without* family money, the co-op isn't simply a worse lottery ticket. It's a different bet: a much smaller top prize, a lower chance of losing everything, and a salary throughout.
**Where the co-op really does lose is at the top:** the founder who is confident, wealthy enough to absorb a failure, and building something with very high profit per worker. That founder will usually go conventional, and the model should expect them to.
## The tools
These are the ways a co-op can reward founders without selling the firm. Each is limited so it can't turn a founder back into an owner, or let founders pay themselves ahead of the bank.
- **Member capital accounts.** A founder builds up an account like every other member, from year one. See [Member Capital Accounts](capital-accounts.md).
- **A founding share.** For the first ten years, 10% of the members' profit pool is set aside for the founding members. It's split among everyone who was a member in the first three years, in proportion to how much of that time they were there. So the people who took the early risk get paid for it, including the first hires, not only the people named in the paperwork. And because it's a fixed slice of the pool, not extra votes or weight per head, founders gain nothing by keeping the co-op small.
- **Back pay for unpaid early work.** If founders take reduced pay before the business has revenue, the difference is recorded as a debt the co-op owes them. Pay can't go below the minimum wage once the co-op exists ([Owners Are Still Workers](labor-law.md#founders-unpaid-work)). The limits:
- It's valued at no more than the pay the co-op sets for the same job once it has revenue.
- It earns interest at the Treasury rate.
- It ranks behind the bank, can't be repaid from the bank's loan money, and isn't repaid while any bank payment is late.
- **Founder money.** Cash a founder puts in comes in one of two forms. It can be a loan at no more than the Treasury rate plus three points. Or it can be the same voteless, expiring investment any outside investor could make under [Capital, After](capital.md). Either way it ranks behind the bank. A founder who remortgaged their house to start the co-op can take the investor's terms, which reward that risk.
- **Founder salary premiums.** A founder who is also the chief executive can be paid like one. The founding bylaws can set that pay for at most the first five years. After that, the members set it, like every other salary.
- **No founder vetoes.** Founding bylaws can't give founders extra votes, vetoes, or rules requiring a supermajority to remove them. Founders write the first bylaws before any other worker exists, so this is the one place the model protects future members from them.
**What's deliberately not here: a founder's tail.** One idea considered was letting founders keep a voteless share of profits for some years after they leave, as a stand-in for the sale price. It was left out. It would be a claim on the work of people the founder no longer works alongside, which is exactly what [the thesis](thesis.md) says this kind of firm exists to prevent. In the largest case it would also have paid each departed founder several times what a working member earns.
## The honest bottom line
None of this replaces a large exit. The model is betting on three kinds of founders:
1. **People without wealth** who would never risk a conventional startup. For them, a salaried, lower-risk path to building something may be the best deal available.
2. **Groups of workers founding together.** When the "founders" are the first ten employees, the gap between founder and worker mostly disappears.
3. **People who value what the co-op offers**: control, stability, and a firm that can't be sold out from under them.
Whether there are enough of them is an empirical question. [The pilot](pilot-spec.md) publishes the founders' backgrounds, including their previous earnings where the data allows, so that if the strongest founders stay away, it shows. If the portfolio fails, the first question will be "was it co-ops, or was it who applied?" [Co-ops or Their Founders?](who-founds.md) sets out how the pilot answers it: founders on both sides are measured from government records, and the verdict has to say how much their backgrounds explain. It doesn't change pass or fail. If the lock keeps strong founders away, that's part of what the lock does.
@@ -0,0 +1,102 @@
---
title: Does Sharing the Profit Mean Less Effort?
section: The Co-op
order: 21
summary: In a big co-op, your extra effort barely moves your own profit share, so the model leans on what the evidence points to (small teams, open books, team bonuses the members design, trust) and sets a fair, fast process for the rare member who won't do the work, with protections so it can't be turned on the wrong people.
---
# Does Sharing the Profit Mean Less Effort?
Here's the oldest objection to shared ownership. In a co-op of 1,000 members, if you work hard enough to add $1,000 to the profit, your own share goes up by about a dollar, less after the reserve. Everyone else's goes up by a dollar too, including anyone who didn't bother. So why work harder? Economists call it the free-rider problem, or the 1/N problem: with N members, you get 1/N of what you add.
The founding plan's answer is culture and structure: co-ops hold members to their share of the work, and groups of co-ops keep each unit small enough that people notice. This page checks that against the evidence and turns it into rules.
## What the evidence says
**Workers can tell who isn't pulling their weight, and many act on it.** A large study surveyed workers at over 300 worksites in 14 companies with group incentive pay, like profit sharing or employee stock, and added questions to a national survey. These were conventional companies, not co-ops. Most workers said they could tell when a co-worker was shirking. Many said they would talk to that person or report it, and many said they had. Acting on it was more common where workers had a real stake in the results, in smaller workplaces, and where relations with management were good. Workplaces where more workers acted also reported that co-workers worked harder and the workplace worked better ([Freeman, Kruse and Blasi, 2008](https://www.nber.org/papers/w14227)). That's a link, not proof of cause: better-run workplaces may simply produce both.
Three details matter for this model:
- **Group incentives went with other things:** teams, training, varied work, light supervision and decent base pay. On their own, they did less.
- **Size mattered, and so did trust.** The link was strongest in the smallest workplaces and weaker in the largest. But it held in larger companies where workers trusted management. The authors' reading: workers act to raise output only when they're confident they'll share in the gains.
- **A small stake can backfire.** Where the profit-sharing bonus was small, eligible workers were *less* likely than others to act against a shirker. The authors think a shared bonus makes people fear co-workers will resent them for speaking up, and a small bonus isn't worth that. In a co-op of 1,000, everyone's slice of a single person's effort is small.
**Ownership doesn't seem to hurt performance.** A review of 102 studies covering nearly 57,000 firms found employee ownership had a positive but very small link with firm performance (a correlation of 0.04), with no clear difference by firm size ([O'Boyle, Patel and Gonzalez-Mulé, 2016](https://doi.org/10.1111/1748-8583.12115), as summarized by [LEP](https://www.economicpossibility.org/sources/employee-ownership-and-firm-performance-a-meta-analysis)). That covers employee ownership in general, not co-ops specifically. In France, worker co-ops were as productive as conventional firms or more so ([Fakhfakh, Pérotin and Gago, 2012](https://ideas.repec.org/a/sae/ilrrev/v65y2012i4p847-879.html)), though that's co-ops that survived, under French law.
**What the evidence can't settle.** None of it is an experiment, none of it studied co-ops of thousands of members governed one member, one vote, and there's no direct evidence on the dismissal process below. So these rules are a starting point, and the pilot measures whether they work.
## Required, and up to each co-op
Governance is up to each co-op ([Worker Ownership](worker-ownership.md)). So this page separates two things:
- **Required** in every locked co-op's charter: the protections in the dismissal process, protections for disability, pregnancy and leave, the rules on bonuses, and the reporting.
- **Standard bylaws,** which a co-op can change by vote: how teams are organized, how often the books are shared, and who hears a dismissal first.
## Small teams, open books
People notice what their co-workers do in teams, not in firms of thousands. The standard bylaws organize work in teams small enough that everyone knows what everyone else does, and give each team its own numbers: what it produced, what it cost, how much work had to be redone.
Where a line of business gets big enough to be its own firm, it can become one, as Mondragon's co-ops did ([When Growing Means Sharing](growth.md)).
The standard bylaws share results with every member every quarter, or every month once a co-op has more than 50 workers, and offer training in reading them. The [federation](federation.md) supplies templates and training, so small co-ops don't have to build their own.
## Team bonuses the members design
The profit share is split equally per hour worked, and that doesn't change. But pay can include bonuses ([Who Sets the Pay](pay.md)), and a co-op can pay team bonuses tied to results the team can see and affect. The study found that the bigger the gain-sharing bonus, the more workers acted against shirking.
The required rules keep bonuses from becoming a way to reward friends or push people too hard:
- **Members approve the formula before the year starts,** in writing. Managers apply it. They don't pick who gets what.
- **A team's bonus is split among its members by hours worked,** not by a manager's choice.
- **The total is capped** at a share of profit set in the bylaws, and it's reported in the co-op's pay-gap figures by gender and race.
- **Measure quality and safety, not just speed:** work that had to be redone, waste, injuries. Customer ratings can't be used unless they're checked every year for bias by race, gender, age and disability, because customers can rate some workers lower for the same work.
- **Measured per hour, with protected leave left out.** A teammate's parental leave, medical leave or accommodated schedule can't lower the team's numbers.
- **How work and costs are split between teams is published,** and a team can challenge its numbers.
Bonuses still stay within the approved pay bands and count toward the pay ratio. Bonuses that push pay above the going rate give the reserve its share ([Investing for the Long Run](the-long-run.md)).
Managers need a reason to deal with problems too. The board can tie part of managers' pay, within their bands, to how their units do, and the board, not the teams they manage, reviews them.
## When a member won't do the work
Holding members to their share of the work has to mean something specific. Otherwise it means nothing, or it means removing whoever a majority dislikes. So every co-op's charter includes this process.
**Help first.** Before any warning about performance, the co-op checks for a cause it has to accommodate, like a disability, pregnancy or caregiving leave, and offers a reasonable change. Performance is judged against the accommodated schedule. Then comes a written plan to improve, with training or a move to other work if that fits. These protections apply however small the co-op is, even below the size where federal discrimination law starts.
**Then a fair, fast decision.**
- **Written charges and the evidence,** at least 10 days before the hearing.
- **A hearing** before a panel of members drawn by lot, not including the person's own team, or, in a co-op of fewer than 15 members, before all the members except the person and whoever raised the complaint. The person can bring a co-worker or union representative.
- **Just cause, proved by the co-op.** The co-op has to show it's more likely than not.
- **A written decision within 30 days** of the charges.
- **An outside arbitrator on request,** from a list the federation keeps, on appeal. If the dismissal is overturned, the person gets their job back with back pay.
- **Serious misconduct,** like theft or violence, allows suspension with pay while the hearing happens.
Co-ops that hire managers can let them dismiss members directly. The member can still take it to the hearing and the arbitrator, and gets their job back with back pay if it's overturned.
**Protections that can't be waived.**
- **No retaliation.** If a member is dismissed within 12 months of organizing, filing a complaint, reporting a safety problem, running for the board or leading opposition in a vote, the co-op has to prove the dismissal had nothing to do with it.
- **Quitting under pressure counts.** A member who quits within 60 days after a transfer, a cut in hours or pay, or being left out of a bonus can ask for a hearing, and the quit is treated as a dismissal. That's the same test [When People Don't Stay Long](high-turnover.md#no-pushing-people-out-before-theyd-join) uses for workers on probation.
- **Probation isn't a loophole.** Workers on probation get a shorter version of the same hearing. Dismissing them for performance still counts in the test against pushing people out before they'd join. Only the narrow reasons listed there, like a client's safety, are excluded.
- **The savings are safe.** A dismissed member's [capital account](capital-accounts.md) is fixed on the day they leave, they get a share of that year's profit for the hours they worked, and it's paid on the normal schedule. Nothing can be taken out of it without a court or arbitrator's ruling.
**Published.** Every year the co-op reports warnings, hearings and dismissals by gender, race, age and disability, along with the results of an anonymous survey on workload and bullying.
## Where the share is small anyway
In low-margin co-ops, like home care, a full profit share is worth a couple of thousand dollars a year, on top of the pay, schedules and say in the work that members get. There, the study's warning applies: a small shared stake may not motivate anyone. So those co-ops can't count on the profit share to do the work. Pay, schedules, training and a real say in how the work is done matter more.
## Measuring it
The pilot publishes value added per hour worked (what a firm's work adds, after the cost of materials and services it buys, before wages and profit are split), compared with matched conventional firms ([The Pilot, in Numbers](pilot-spec.md#what-gets-measured)). It follows each co-op over time as it grows, in size bands set in advance, and accounts for how much equipment each firm uses per hour. Spin-offs are counted with their parent, and co-ops that close stay in the results. Injuries and overtime are published alongside, so output bought with worn-out workers doesn't count as success.
If co-ops fall behind as they grow, that's evidence the 1/N problem is real, and it gets reported that way.
## What this doesn't solve
- **Big co-ops are still harder.** People hold each other to account best in small places. Teams and spin-offs help. A co-op of 10,000 will still feel more distant than one of 30.
- **Everyday pressure doesn't reach a hearing.** Co-workers who hold each other to account can also pick on people who are different. The protections, the published figures and the anonymous survey make that visible. They can't stop all of it.
- **The process takes time.** A month from charges to decision is fast for a fair process and slow for a small business with a problem. That's the trade.
- **Team bonuses can pit teams against each other.** Members choose what to reward, and some will choose badly.
- **The evidence is thin.** The best study asks workers what they'd do and what they've done, in conventional firms. The pilot is the first real test of these rules in co-ops.
+121
View File
@@ -0,0 +1,121 @@
---
title: When Growing Means Sharing
section: The Co-op
order: 29
summary: Members who split profit equally have a reason to turn down growth that earns less per worker than they already do, so the model lets co-ops grow by starting new co-ops they invest in, under rules that stop that becoming a way to push lower-paid work out, and measures whether they grow as fast as other firms.
---
# When Growing Means Sharing
A conventional owner takes on a new line of business if it earns more than the money it ties up would earn elsewhere. How many people it employs doesn't matter.
A co-op's members decide differently. After the [ramp](worker-ownership.md), they split profit equally. So a new line has to earn at least as much per worker as the co-op already does, or it lowers everyone's share. **A line that makes money can still make each member poorer, unless it's set up as a co-op of its own, which this model makes the standard path.** Economists have known about the problem since Benjamin Ward described it in 1958, and it's a real weakness.
## A worked example
A 40-member co-op has a members' profit pool, the part left after the reserve and the federation fund ([Member Capital Accounts](capital-accounts.md#where-each-years-profit-goes)), of $800,000 a year. That's $20,000 per member.
It has a chance to open a new line. The line needs 20 more workers, pays them normal salaries, and adds $200,000 a year to the pool: $10,000 per new worker. Suppose that's comfortably more than the money it ties up would earn anywhere else. A conventional owner would take it.
For the members, it depends on the ramp. New members start at a fifth of a full share and reach a full share after five years. The founding plan allows up to ten.
| Year | Each full share, 5-year ramp | Each full share, 10-year ramp |
| --- | --- | --- |
| No new line | $20,000 | $20,000 |
| 1 | $22,700 | $23,800 |
| 3 | $19,200 | $21,700 |
| 5 | $16,700 | $20,000 |
| 10 and after | $16,700 | $16,700 |
| **Total over 10 years** | **$181,000** (vs. $200,000) | **$199,000** (vs. $200,000) |
(The 10-year ramp here starts at a tenth of a share and adds a tenth each year.)
**Tax law changes this table.** It assumes the part of a share new members haven't reached yet goes to the other members. Under the model's default tax rules, it goes to the co-op's reserve instead ([How a Locked Co-op Is Taxed](taxes.md#the-tenure-ramp)). Then each full share drops to about $16,700 from the first year, and in year 1 the reserve gets about $267,000 extra with the five-year ramp. The table holds only for a co-op that pays the difference as wages for years of service, at the cost of payroll tax. Either way, that makes the spin-off route below more important.
A member near retirement comes out ahead either way. A member with twenty years ahead loses about $50,000 over that time with a five-year ramp, and about $35,000 with a ten-year one. Counting money later as worth less than money now (at 7% a year), those losses are about $23,000 and $10,000. The gains come first and the losses later, so members who expect to leave soon and members who expect to stay long will vote differently. The ramp softens the problem. It doesn't remove it. In the end, **the new line has to earn the co-op's average, not just a profit, before the members come out even.**
**Where it bites, and where it doesn't.** The gap only matters when profit per member is high. In home care, a full share is worth a couple of thousand dollars a year ([When People Don't Stay Long](high-turnover.md#the-ramp-when-people-leave-early)), so a new line that earns less costs each member a few hundred dollars at most. In a firm making tens of thousands of dollars per worker, the kind described in [What If Conservice Had Been a Co-op?](conservice.md), it's real money. And the most profitable co-ops are the ones whose growth matters most.
## What the evidence says
**Co-ops are steadier, and slower to hire.** A study of Italian firms from 1982 to 1994, in the market economy with the most worker co-ops, found co-ops paid about 14% less on average. Their pay moved more with good and bad times, and their employment moved less, in both directions. When business picked up, co-ops added fewer workers than conventional firms. The co-ops in that data were larger on average than conventional firms, not smaller, though that comparison doesn't account for which industries they're in ([Pencavel, Pistaferri and Schivardi, 2006](https://docs.iza.org/dp2188.pdf)). A study of every co-op and conventional firm in Uruguay's social security records from 1996 to 2005 found the same pattern: co-op pay responds more to shocks, and employment less ([Burdín and Dean, 2009](https://doi.org/10.1016/j.jce.2009.08.001), as summarized by [Pencavel, 2012](https://docs.iza.org/dp6932.pdf)).
In France, a comparison across many industries found worker co-ops grew as fast as conventional firms or faster ([Fakhfakh, Pérotin and Gago, 2012](https://ideas.repec.org/a/sae/ilrrev/v65y2012i4p847-879.html)).
Ward's starkest prediction, that co-ops would *cut* jobs when prices rise, doesn't show up. What shows up is slower hiring and steadier jobs. The steadiness that protects members in bad years is the same thing that makes them slow to hire in good ones.
One caution: co-ops in Italy and Uruguay can hire non-members. Co-ops under this model mostly can't (below). So that evidence probably understates the problem here.
**When hiring members is costly, co-ops hire non-members instead.** The Olympia Veneer Company, the first of the Pacific Northwest plywood co-ops, started in 1921 with 125 worker-members. As founders left, the co-op bought back their shares and hired ordinary employees in their place. By 1952 fewer than 50 members were left, and in 1954 they sold the company for a handsome return. Mondragon's industrial co-ops saw non-members reach 29% of their workforce in 2000, before new rules brought it down to 12% by 2008. Counting the ordinary companies Mondragon owns in Spain and abroad, non-members went from 10% of its workforce in 1990 to 70% in 2007 ([Pencavel, 2012](https://docs.iza.org/dp6932.pdf), citing Arando and others, 2011).
That route is closed here. [The lock](the-lock.md) requires members to work at least three-quarters of the hours, and any company a co-op controls is locked too. That stops a co-op from turning into a small club of owners with employees under them. But it also means a co-op can't get around dilution by hiring outsiders. So dilution bites *harder* here than in co-ops that allow hired labor. The model accepts that, because the alternative is the road the plywood co-ops took out of being co-ops.
## The model's answers
### 1. Grow by starting new co-ops
Mondragon found a way through early. When its first factory, Ulgor, grew new lines of business, it turned them into co-ops of their own. Its electronics division became a new co-op, Fagor Electrónica, in 1965, "continuing with the plans for creating spin-offs from Ulgor" ([Mondragon, *The Mondragón Co-operative Experience, 1956–2012*](https://laboress-afrique.org/boostsess/public/uploads/pdfdocuments/files/mondragon_1956_2012.pdf)).
But spin-offs weren't the whole system. Ulgor and its spin-offs formed a group, Ularco, that began partly pooling its co-ops' profits in the late 1960s and pooled them completely from 1976. When Mondragon reorganized in 1992, it planned to pool between 15% and 40% of profits across each of its new sectors. Workers moved between co-ops when one shrank. From 1979 to 1983, when Ulgor had to cut 1,300 of its 3,500 jobs, 145 members split off to form a new co-op and others moved within the group, and every move was voluntary ([Morris, *The Mondragón System*, 1992](https://www.ilsr.org/wp-content/uploads/files/images/mondragon.pdf)).
The model makes spin-offs the standard path for a new line that earns less per worker than the parent:
- **The new line becomes its own locked co-op,** with its own members, its own profit split, and a [lock share](the-lock.md#how-the-lock-is-made-to-hold) held by an independent lock-holder from the day it's founded.
- **The parent invests, but doesn't control.** It can fund the new co-op from its reserve, on the terms any investor gets ([Capital, After](capital.md)): repayment plus a share of profits, for at most 15 years, with no vote. If the new co-op borrows from the bank, the parent's claim ranks behind the bank and needs the bank's approval, like any other.
- **Reserve money stays reserve money.** When the new co-op repays the money, it goes back into the parent's reserve. Only the profit share counts as profit, and the reserve takes its cut of that too ([Investing for the Long Run](the-long-run.md)). No single spin-off can take more than a quarter of the parent's reserve.
- **The spin-off is still a co-op.** Its own workers become members, and it's in the same federation as the parent, so a failure in one doesn't leave the other's workers with nothing ([The Federation](federation.md)).
In the example, the parent's members keep their $20,000 and earn a return on what they put in. The 20 new workers build full shares of their own co-op's profit instead of partial shares of someone else's. Without the spin-off, the members would most likely have turned the line down, and the jobs wouldn't exist.
This matters most for exactly the case the founding plan worried about: **expansion that takes a lot of capital, into lower-margin work.** The parent's reserve can pay for it without anyone's share shrinking.
**Yes, this makes richer and poorer co-ops.** The spin-off's members earn a smaller profit share than the parent's. That's the honest cost. The model's line is that inside one firm, where people share a vote and a set of books, shares are equal. Between firms, they aren't, as they aren't between any two companies. What stops that becoming a way to push lower-paid workers out is the next set of rules.
### 2. Rules so spin-offs can't be abused
- **New lines only, unless the workers choose to go.** A co-op can't spin off work its members already do unless a majority of the workers whose jobs would move vote for it. Anyone who doesn't want to move keeps their place in the parent. A spin-off that sells mostly to the parent is treated as part of the parent for the membership rule.
- **Deals between parent and spin-off are insider deals.** Prices, fees, rent and contracts between them must match what outsiders would pay, and they're checked every year, like any [insider deal](pilot-safeguards.md#the-hidden-boss). The lock's limit on exclusive contracts applies: no contract gives one party a claim on most of what the other produces for longer than five years ([The Lock](the-lock.md#closing-the-back-doors)).
- **Assets move at an independent appraisal.** Equipment, customers or buildings moved to the spin-off count as a sale of part of the business, which goes through the lock-holder. The parent keeps any debts and obligations from before the split. So a spin-off can't be used to park a failing line or old liabilities.
- **Those who move don't vote on the money.** Parent members who plan to join the spin-off can't vote on the parent's investment in it. They keep their tenure on the ramp only if the spin-off's founding papers agree to it, so senior members can't move over and dilute the new members in their turn.
- **A second opinion on big investments.** Before a parent puts more than a tenth of its reserve into a spin-off, the federation gives a written opinion on the plan to the parent's members. It's advice, not a veto. The investment is the members' call.
### 3. Sharing across the group, if the co-ops choose
Parent and spin-off can agree to pool part of their profits and split it per member across both, as Mondragon's groups did. That narrows the gap between them. It also brings back part of the dilution the spin-off avoided, which is why it's up to them. A pool of 15% to 40%, the range Mondragon settled on, is a reasonable place to start. The federation offers a standard agreement.
### 4. Where a line can't be split off
Some growth can't be spun off. More customers for the same service, or a cheaper product sold by the same team, can't easily become a separate company. There, the dilution stays. What softens it:
- **The ramp.** A co-op can choose the long end, ten years. That comes at a cost to newer members, who are more often younger or outsiders, and to workers who don't stay long ([When People Don't Stay Long](high-turnover.md#the-ramp-when-people-leave-early)).
- **The founding share is a fixed slice.** Founders' extra reward is a set share of the pool, not extra weight per head, so founders gain nothing by keeping the co-op small ([The Founder Problem](founder-problem.md)).
- **Growth that earns the average is free.** Adding workers to work that's as profitable as the rest doesn't dilute anyone. The problem is only lower-margin growth.
- **Someone else can take it.** A line the members turn down can be offered to the federation, or to a group of workers, to start as a new co-op.
### 5. Measuring it
The pilot already publishes how many jobs its co-ops create ([The Pilot, in Numbers](pilot-spec.md#what-gets-measured)). It also publishes, with the comparisons filed in advance:
- **Job growth compared with matched conventional firms,** in full-time-equivalent jobs, counted both with and without spin-offs, and broken out by thirds of value added per worker (a way to compare how productive firms are that doesn't depend on how a co-op splits pay and profit). The gap should show up most in the most productive co-ops.
- **Expansions voted down.** Each co-op reports major expansion proposals and how members voted on them.
- **Spin-offs:** how many, how they do, and the gap in pay and profit share per member between each parent and its spin-offs.
- **Who gets hired.** Applicants and hires by gender and race, compared with the matched firms, since members who share profit have a reason to hire few people, and people like themselves.
Spin-offs can borrow from the bank. Because they start with an established co-op behind them, they're reported separately and don't count in the pilot's main test, the same way the pilot handles businesses that aren't really new ([Guarding the Pilot](pilot-safeguards.md#relabeling-an-old-business-as-new)). That keeps them from flattering the survival numbers, and keeps a struggling low-margin spin-off from counting against the test.
If the most productive co-ops grow much more slowly than similar conventional firms, that's evidence the problem is real where it matters most, and it gets reported that way.
## What wasn't done
- **Profit shares by division.** Paying each member partly from their own division's profit would remove most of the problem inside one firm. But the founding plan says that after the ramp, everyone's share is identical. Spin-offs do create differences, but between separate firms with separate votes, not between people who govern the same firm together.
- **Profit shares in proportion to pay.** Splitting profit by pay instead of equally would make lower-paid new hires cost the others less. That also breaks the equal share.
- **More room for non-members.** It would make growth easier, and it's the road the plywood co-ops took out of being co-ops.
## What this doesn't solve
- **Not every line can be spun off.** Where growth has to happen inside one firm, members still have a reason to turn down work that earns less than their average. Reporting the expansions they vote down makes that visible. It doesn't stop it.
- **Spin-offs cost something.** Separate books, a separate board and separate legal costs. For a small line, that may be more than it's worth. And splitting a line off a firm that shares its customers and managers can make both work less well.
- **Reserve money can be wasted.** Money that can't be paid out is tempting to spend. Fagor, Mondragon's appliance maker, overexpanded and went bankrupt in 2013 ([Investing for the Long Run](the-long-run.md#whats-still-open)). The quarter cap and the federation's opinion are limits, not a test of whether an investment is wise.
- **The parent profits from people it doesn't work with.** For up to 15 years, the parent collects a share of the new co-op's profit. It's finite, it carries no vote, and dealings between them are checked at outside prices. That's the model's line between renting capital and owning people's work ([Capital, After](capital.md)). Some will say it's still the wrong side of that line.
- **Richer and poorer co-ops.** Spin-offs will often be lower-paid than their parents, and lower-margin work is disproportionately done by women and workers of color. Voluntary pooling and published gaps are the answer offered. Neither closes the gap.
- **Steady employment cuts both ways.** Co-ops keep people on in bad years and hire slowly in good ones. Where that means fewer jobs than the business could support, the people who don't get hired pay for it, and they don't get a vote.
@@ -0,0 +1,105 @@
---
title: When People Don't Stay Long
section: The Co-op
order: 28
summary: In jobs like home care, most workers leave within a year or two, so the model makes membership automatic after a short probation, keeps the buy-in small and out of people's paychecks, and guards against co-ops that push people out before they'd join.
---
# When People Don't Stay Long
This model's co-ops are run by their members, and it takes membership seriously. After a trial period (probation) of at most 12 months, workers become members, and members must do at least three-quarters of the work hours ([The Lock](the-lock.md)). That rule stops a co-op from turning into a small club of owners employing everyone else.
But in some of the jobs where co-ops matter most, people don't stay long. In those jobs, the rule as first written could fail a co-op that was doing everything right.
## The problem
**Home care loses most of its workers every year.** Home care aides help older and disabled people at home: bathing, meals, medication. An industry benchmarking survey published in 2024 put home care worker turnover at about 79% a year ([Home Health Care News, 2024](https://homehealthcarenews.com/2024/07/home-cares-industry-wide-turnover-rate-reaches-nearly-80/)). Turnover here means the number of people who left in a year, divided by the average number of workers.
**Co-ops do better, but still lose a lot of people.** In 2017 there were 14 home care co-ops in nine states, employing about 2,400 caregivers. One account puts their turnover at about 38%, against 82% for the industry at its peak ([OnLabor, 2022](https://onlabor.org/worker-retention-in-homecare-could-cooperatives-be-the-solution/)). That's a small, uncontrolled comparison, but it points one way.
**With that much turnover, many workers never get past a 12-month probation.** If people leave at a steady rate through the year, here's roughly what share of the workforce at any moment has been there long enough to join:
| Turnover per year | Past 3 months | Past 6 months | Past 12 months |
| --- | --- | --- | --- |
| 20% | 95% | 90% | 82% |
| 38% (home care co-ops) | 91% | 83% | 68% |
| 65% | 85% | 72% | 52% |
| 80% (home care industry) | 82% | 67% | 45% |
This is a simple model, and it counts people, while the rule counts hours. Both of those make the real picture somewhat better: many people leave in their first few weeks, and people who stay tend to work more hours. But the point holds. **With a 12-month probation, a home care co-op can fail the three-quarters rule even if every eligible worker joins.**
**The biggest real example shows the other half of the problem.** Cooperative Home Care Associates in the Bronx was started in 1985 by 12 home health aides and is one of the largest worker co-ops in the country. It's also unionized, with 1199SEIU ([CHCA](https://www.chcany.org/about)). It offers ownership after just three months. But workers have to choose to buy a $1,000 share, paid over time from their paychecks. In 2005, about 70% of its aides were owners, and 95% to 97% of those owners worked full time, so by hours it may well have met this model's rule. At a smaller sister co-op in Philadelphia, about 40% of aides were owners ([PHI, 2005](https://www.phinational.org/wp-content/uploads/legacy/clearinghouse/homecarecooperatives_workerownership.pdf)). By 2021, one account put CHCA at over 1,000 owners among 2,600 employees ([OnLabor](https://onlabor.org/worker-retention-in-homecare-could-cooperatives-be-the-solution/)). That count may include office staff, and it's by head, not by hours. But it suggests that when membership has to be chosen and paid for, many workers don't take it up.
To be fair to CHCA: it has stayed a real, worker-governed co-op for about 40 years with many workers who weren't owners. A union contract covering everyone helps explain why. This model still sets a membership rule, because not every co-op will have CHCA's history or its union.
## Two bad answers
- **Loosen the three-quarters rule.** That lets a co-op run on non-members again, which is exactly the slide the rule exists to stop. In a high-turnover business, "most of the workers aren't members" can easily become permanent.
- **Exempt care work.** That would exempt the co-ops whose workers are least able to protect themselves.
## The answer: let people in sooner, by default, at no cost up front
### Membership is automatic
When probation ends, a worker becomes a member **unless they say no**. They don't have to apply, and nobody has to approve them.
This matters more than it sounds. When one large company started signing new workers up for its retirement plan automatically, 86% of new hires took part, compared with 37% of workers hired just before, at the same length of time on the job. The biggest gains were among the lowest-paid, whose participation went from 13% to 80% ([Madrian and Shea, 2000](https://www.nber.org/papers/w7682)). People mostly stay with whatever happens by default. So the default should be membership.
A retirement plan and a co-op aren't the same, and the same study found that people signed up by default mostly stayed passive about their choices. A member signed up by default may not act like an owner at first. The model's bet is that having the vote is the precondition for using it.
- **Saying no is easy and goes through someone independent.** A worker can opt out by phone, in person or in writing, in their own language, with the co-op's [lock-holder](the-lock.md#how-the-lock-is-made-to-hold), the independent body that guards each co-op's lock, rather than with their manager. So managers can't quietly talk people out of joining. Anyone who opts out can change their mind within 30 days, or join later at any time.
- **Members and non-members get the same pay and schedules.** A co-op can't pay non-members more, or give them better shifts, to keep them from joining.
- **People who freely opt out don't sink the co-op.** Hours worked by people who opted out through the lock-holder count toward the three-quarters rule, up to one-tenth of all hours. Beyond that, they count as non-member hours. So a co-op isn't punished for a few workers' free choices, but it can't run on them either.
### Joining costs nothing up front
Existing home care co-ops charge refundable fees of $25 to $1,000, often taken from paychecks a little at a time (PHI, 2005). That's a real cost for someone earning an aide's wage, and in many states a deduction from pay needs the worker's written permission.
So in this model, **the buy-in comes out of the member's own profit shares, not their paycheck.** It's capped at $1,000. Until it's covered, part of each profit share goes toward it instead of being paid out in cash, though never the cash a member needs to pay tax on their share. A member can choose to pay it faster from their pay if they ask in writing. Either way, it sits in their own [capital account](capital-accounts.md) and is paid back to them on the account's normal schedule, whether or not they stay.
### A short probation
Probation still can't be longer than 12 months. But a co-op can make it shorter, and the model's standard bylaws use three months, as Cooperative Home Care Associates does. The table shows why a high-turnover co-op will want that: at 38% turnover, a three-month probation puts about 91% of the workforce past it.
**Why letting people in early is affordable.** It's the [profit share ramp](worker-ownership.md). A new member starts with a small slice of the profit that grows over the years. So a new member takes only a small slice of the profit at first. The rest of what their hours earn goes to the co-op's reserve, not to the other members, because of how federal tax law treats profit shares ([How a Locked Co-op Is Taxed](taxes.md#the-tenure-ramp)). What the new member gets right away is the vote. Under this model, that doesn't wait. Workers on probation get a profit share too, in cash, at the rate of a member just starting out.
### No pushing people out before they'd join
If membership comes at three months, a co-op that wanted to stay a small club could let people go at two and a half, or cut their hours until they quit. So:
- **Every hour counts, whoever signs the paycheck.** Hours worked on the co-op's business by staffing-agency workers, contractors or a related company's staff count as non-member hours. A co-op can't get around the rule by putting its workers on someone else's payroll.
- **Probation counts across rehiring.** Time on probation adds up across every stint at the same co-op within three years. Letting someone go and rehiring them doesn't restart the clock.
- **Pushing people out counts as letting them go.** If someone on probation quits within 60 days after their hours were cut by a quarter or more, or after they lost their regular clients, it counts as the co-op's decision, not theirs.
- **The test looks at results.** Each year the co-op reports how many people it hired, how many it let go (counted as above) before they could join, and why. The lock-holder compares that with the same co-op's record after people become members, and with similar co-ops. It looks only once there are enough cases to mean something, at least five over three years. Dismissals for a documented reason, like a client's safety or a failed background check, don't count. A worker can also complain directly to the lock-holder.
- **A finding has to be earned.** Before a pattern counts against the co-op, the co-op sees the evidence and can answer it. A confirmed pattern counts as failing the membership rule for that year.
### A start-up period
A new co-op that's hiring fast will always have many people on probation. So the rule phases in. There's no hours test in the first year, members must do at least half the hours in the second, and three-quarters from the third on. Automatic membership and the 12-month limit apply from day one.
### Unions stay
Becoming a member doesn't end a union contract. A union can keep representing workers, members and non-members alike, as 1199SEIU does at CHCA. Federal labor law may not protect members' own organizing, though, so the charter does ([Owners Are Still Workers](labor-law.md#union-rights-the-biggest-gap)). Members are also paid for time spent on the co-op's committees and board, as CHCA and its sister co-op already do (PHI, 2005), so taking part in governance doesn't cost an aide a shift.
## The ramp, when people leave early
The founding plan sets the profit share ramp at five to ten years. In a job where many people stay two or three, most will leave before they ever reach a full share. At 38% turnover with a three-month probation and a five-year ramp, only about one worker in seven has a full share at any moment. That's a real cost of the ramp, and it's worth saying plainly.
Some things make it less harsh than it sounds:
- **The vote doesn't ramp.** A member who stays one year has the same vote as one who stays thirty.
- **Time on the ramp adds up.** Someone who leaves and comes back within a few years picks up where they left off, and protected leave, like parental or medical leave, counts as time worked ([Worker Ownership](worker-ownership.md)). That matters in care work, where many workers, most of them women, step out for a while to care for their own families.
- **Whatever they built goes with them.** A member's account keeps paying out on its normal schedule after they leave, and small balances are paid all at once ([Member Capital Accounts](capital-accounts.md#how-its-paid-out)).
- **In care work, the profit is small anyway.** At home care's thin margins, a full share is worth a couple of thousand dollars a year, and a typical member's is less ([Member Capital Accounts](capital-accounts.md)). For these workers, most of what matters is pay, hours and a say in how the work is done.
A co-op can choose the short end of the ramp. This page doesn't shorten it further, because the founding plan sets that range on purpose. A longer ramp was meant to let a co-op hire without every new member cutting into everyone else's share right away. Under tax law, it only does that if the co-op pays the difference as wages for years of service ([How a Locked Co-op Is Taxed](taxes.md#the-tenure-ramp)). Whether low-margin, high-turnover co-ops should be allowed a much shorter ramp is an open question ([Open Problems](open-problems.md)).
## What this doesn't solve
- **Ownership doesn't fix the pay.** Home care wages are low largely because much of the work is paid for by public programs like Medicaid, at rates the agencies don't set. A co-op can't pay more than it's paid. It can offer steadier hours, training and a say, and co-ops seem to keep people longer. But the pay problem needs better public rates, rules that send more of those rates to workers, and unions bargaining for them, along with [the floor](the-floor.md).
- **The numbers are rough.** Turnover varies a lot between agencies, the home care industry figure comes from a survey of agencies that chose to take part, and the co-ops' 38% comes from a small count. The pilot will measure this directly and report [results by industry](pilot-spec.md#what-gets-measured).
- **Automatic membership hasn't been tried at a large care co-op.** It should raise membership a lot. How much, and whether default members take part in governance, is unknown.
- **A short probation means less time to judge fit.** A co-op admits people it has known for three months, and they vote. That's the trade-off the model accepts. The ramp limits what a poor fit costs in money. It doesn't limit their vote.
- **The stable minority can still run things.** Office staff and coordinators often stay while aides come and go. One member, one vote and paid meeting time help. They don't guarantee aides a real voice.
- **Some things need legal work.** How membership interacts with tax filing for members without Social Security numbers, with visa status, and with the asset limits of public benefits needs answers before care co-ops sign people up automatically. Federal labor law probably doesn't protect union activity by members who own the firm. The charter protects it instead, and fixing the law takes a separate bill ([Owners Are Still Workers](labor-law.md#union-rights-the-biggest-gap)).
- **Opting out can still be pressured, and pushing people out can still be hidden.** An independent lock-holder and results-based checks make both harder. They can't make them impossible.
+40
View File
@@ -0,0 +1,40 @@
---
title: Free-Market Democratic Socialism
summary: A working model for building an economy where the people who do the work own and govern the companies they work for. Nobody's company gets taken, and markets, wages, investment and personal property all stay.
---
# Free-Market Democratic Socialism
## The idea in one sentence
**Your house, car, savings and tools are yours, and nobody touches them. But a permanent, tradeable claim on the work of thousands of strangers is a different kind of thing, so there should be companies where that claim never forms, and nothing in law or finance should stop people from building them.** [Read the thesis](thesis.md).
## Nobody is forced
This model doesn't buy, seize or convert anyone's company. Instead it builds new worker-owned companies alongside existing ones, then lets customers and workers choose between them.
- **Workers own their company.** Every member gets one vote. A share of the profits grows with tenure until long-time members split them equally, per hour worked, which is how federal tax law lets co-ops pay tax on it only once. Salaries still exist, and hard jobs still pay more. Part of each year's profit goes into a personal account that pays it back a few years later on a fixed schedule, whether or not you've left, which is designed to keep it outside federal pension law, and part goes into a permanent reserve that keeps the business investing. Members can't drain it through high pay or let the equipment quietly wear out, and big bets get an outside opinion first. An insurance pool the co-ops pay for protects workers' last paychecks and part of their savings if the co-op fails. A fair, fast process deals with members who won't do the work, with protections so it can't be turned on the wrong people. Members are the co-op's employees, so they get the minimum wage, overtime and injury insurance like anyone else. Where the law leaves owners out, of unemployment insurance in some states or of union protection, the co-op's charter fills what it can. Membership comes automatically after a short probation, so co-ops in high-turnover work like home care don't end up run by the few who stay. Equal shares give members a reason to turn down growth into lower-paying work, so co-ops can grow by starting new co-ops they invest in instead. [Worker Ownership](worker-ownership.md), [Does Sharing the Profit Mean Less Effort?](free-riding.md), [When People Don't Stay Long](high-turnover.md), [Owners Are Still Workers](labor-law.md), [When Growing Means Sharing](growth.md), [Member Capital Accounts](capital-accounts.md), [How a Locked Co-op Is Taxed](taxes.md), [Protecting Members' Savings](savings-guarantee.md) and [Investing for the Long Run](the-long-run.md)
- **A small public bank lends to worker co-ops.** It charges market rates, so it only succeeds if co-ops can really compete. Its whole job is to be willing to lend to a kind of company other banks won't. Spain, Italy and the US have all tried versions of this, and the pilot is built around what went right and wrong. [The Bank](the-bank.md) and [Has This Been Tried?](precedents.md)
- **Owners who want to sell to their workers can.** The bank lends for these sales on a separate track, with its own $100 million and its own test. Only healthy firms qualify, there's no new tax break, the price is capped at an independent appraisal, the seller shares the risk, and the whole workforce votes. [When an Owner Wants to Sell to the Workers](buyouts.md)
- **Co-ops funded this way stay co-ops forever.** They can't be sold to conventional owners, and members can't vote to cash out what earlier members built. An independent lock-holder can veto any attempt, including selling the business off piece by piece. If a co-op goes bankrupt, only another co-op can buy it whole, and its workers get a real chance to be that buyer. That needs an amendment to federal bankruptcy law, drafted here. [The Lock](the-lock.md), [When a Locked Co-op Goes Bankrupt](bankruptcy.md)
- **It runs as an honest experiment.** A one-time $350 million pilot aims to fund about 400 new co-ops. The success measures are published before the first loan. If, after twelve years, co-ops stay open about as well as similar conventional firms while paying their members fairly, and the bank earned at least what Treasury bonds would have, Congress votes on growing it. If not, it shuts down automatically. It's built so that a hostile government can't quietly freeze it or rig the result, though Congress can always repeal it. It also tests, by lottery, whether serious help before launch gets more co-ops started. Part of the loan money is set aside for co-ops in lower-paid work, so the bank can't drift toward only the safest co-ops, and the verdict has to say which kinds of co-ops it covers. The pilot can't show whether co-ops lift pay at other employers. Even after a pass, a sector big enough for that is decades away nationally. So growth comes in capped stages, and that effect gets its own test in a few local markets. [The Test](the-test.md), [The Pilot, in Numbers](pilot-spec.md), [Help Before the First Loan](before-launch.md), [Who the Bank Lends To](who-gets-loans.md), [After a Pass: How Big, How Fast](scale.md) and [Making It Last](durability.md)
- **Co-ops back each other up.** They pool part of their profits into a shared fund. If one fails, its workers are moved into others instead of being left with nothing. [The Federation](federation.md)
- **Nobody is left behind by low-margin work.** Owning a company doesn't help much when there's barely any profit to own, so a strong floor covers the rest. A fixed formula, not lobbyists, decides which low-paid work gets public help. [Ownership and the Floor](the-floor.md) and [Who Decides What's Essential](essential-work.md)
## What stays the same
| Stays | Why |
| --- | --- |
| Existing companies | Nobody's company is bought or taken. Conventional firms keep conventional ownership. |
| Markets and competition | Co-ops and conventional firms compete for the same customers and workers. |
| Salaries | Hard and in-demand jobs still pay more. A surgeon still earns a surgeon's salary. Pay bands are open, and high pay needs the lower-paid half to agree. [Who Sets the Pay](pay.md) |
| Personal property | Homes, cars, savings and tools are untouchable. |
| Investment and big returns | Investors can still fund co-ops and make 10x or 30x. Their claim just runs out and carries no vote. [Capital, After](capital.md) |
## Why it matters
This isn't mainly about money. **People deserve a say in governing the place where they spend most of their waking lives.** Today most workers are ruled by distant owners who answer to nobody who works there. Co-ops are rare, and one likely reason is that nobody will lend to them. This model fixes the financing and lets people choose. [Making the Case](the-argument.md) and, for one real company worked through, [What If Conservice Had Been a Co-op?](conservice.md)
## Honest about what's unsolved
This is a working model, not a finished one. The biggest open question is whether strong founders will start companies they can never sell. In the success case, a co-op founder ends up with a fraction of what a conventional owner would, because the rest goes to the people who work there ([The Founder Problem](founder-problem.md)). The pilot measures founders on both sides, so if it fails, the verdict has to say how much of that was down to who applied ([Co-ops or Their Founders?](who-founds.md)). The full list of what isn't solved yet is kept in the open. [Open Problems](open-problems.md)
@@ -0,0 +1,15 @@
---
title: Intellectual Property
section: Beyond Co-ops
order: 100
summary: Targeted patent reform would help new firms, but it's kept separate from the bank so the achievable part doesn't wait on the hard part.
---
# Intellectual Property
Patent terms are too long for how fast many industries move, and some patents should never have been granted. Loosening IP would lower the barrier for any new firm, co-ops included. It belongs in the model, but it is **decoupled from the bank** for two reasons:
- **It's sector-dependent.** In software, patents mostly protect incumbents and trolls against entrants; weakening them helps new firms. In pharma and hardware, a patent is often the *only* thing keeping a small entrant from being copied to death by a competitor with better manufacturing and distribution. Blanket loosening can hand the advantage to scale, which is the opposite of the goal.
- **It's politically far heavier.** A small bank is achievable. Patent reform means fighting every industry that lives on patents. Coupling them means the achievable thing waits on the hard thing.
So the IP reform is targeted: shorter terms where innovation cycles are short, a much stricter bar for software and business-method patents, and aggressive action against patent trolls. Where patents genuinely protect small inventors, leave them alone.
@@ -0,0 +1,117 @@
---
title: Owners Are Still Workers
section: The Co-op
order: 24
summary: Labor laws were written for workers with a boss, so a co-op member can fall through the gaps, and the model closes the ones it can with the co-op's own rules and names the ones that need a change in the law.
---
# Owners Are Still Workers
A co-op member is two things at once: an owner of the business and someone who works there for pay. America's labor laws were mostly written for people who are one or the other. Most of them ask some version of "does this person have a boss?" In a co-op, the answer is "yes, and they also help elect the boss."
The model puts members on the payroll as employees ([Who can borrow](pilot-spec.md#who-can-borrow)). That gets them most of the protections any other worker has, but not all, and not in every state. This page goes law by law.
## Wages: members are employees
**Federal wage law covers members.** In 1961 the Supreme Court looked at a Maine co-op of women who knitted at home and were paid by the piece. It held that they were the co-op's employees, entitled to the minimum wage, even though they owned it. "There is nothing inherently inconsistent between the coexistence of a proprietary and an employment relationship," the Court wrote. What mattered was the reality: the co-op's managers set the rates and could expel members for bad work ([*Goldberg v. Whitaker House Cooperative*, 366 U.S. 28](https://www.law.cornell.edu/supremecourt/text/366/28)). Three justices dissented, saying a genuine co-op doesn't employ its own members, but that's not the law.
So members are owed at least the minimum wage and overtime pay for every hour, like anyone else. **A profit share doesn't count toward the minimum wage.** It's paid once a year, after the books close ([How a Locked Co-op Is Taxed](taxes.md)), and a wage has to be paid when it's earned.
### Founders' unpaid work
This catches one part of the model. Founders can put in their stake partly as unpaid work, recorded as back pay the co-op owes ([Help Before the First Loan](before-launch.md#should-founders-put-in-money-of-their-own), [The Founder Problem](founder-problem.md#the-tools)). Once the co-op exists and founders are its employees, working below the minimum wage breaks the law, even by agreement. Workers can't sign away the minimum wage.
There is an exception in federal law for an owner of at least 20% of a business who actively manages it ([29 CFR 541.101](https://www.law.cornell.edu/cfr/text/29/541.101)). The model doesn't rely on it. A locked share can't be sold, so it may not count as a real ownership stake. The exception doesn't reach teams of six or more. And it's federal only, so state wage laws may still apply. So the rule is:
- **Before the co-op is formed, work can be unpaid.** Founders who are working on their own venture don't yet have an employer. They sign the bank's short standard founding agreement as equal partners, so it's clear nobody is working for anyone else. Their logged hours are turned into back pay when the co-op forms.
- **Once the co-op is formed, everyone is paid at least the minimum wage for every hour,** founders included, at the state or local minimum where that's higher. Only the difference between the minimum wage and the local median pay for the job can be recorded as back pay and counted toward the stake. The first loan can pay those wages.
- **Nobody works for free who isn't a founder.** Anyone who works on the venture after the application goes in is either a named founder or paid.
- **The bank asks the Department of Labor for an opinion letter** on this setup before lending starts, and follows state law where it's stricter.
That makes the stake a bit harder to earn in work, since hours after the co-op forms count for less. It's still much easier than a cash buy-in.
### Overtime and the profit share
Overtime is paid at one and a half times a worker's "regular rate," and a bonus that workers are promised in advance usually has to be added into that rate ([29 CFR 778.211](https://www.law.cornell.edu/cfr/text/29/778.211)). Both the profit share and the [team bonuses](free-riding.md#team-bonuses-the-members-design) are promised in advance.
There's a way out for the profit share. A written profit-sharing plan that pays out of the whole business's profits, by a set formula such as hours worked, isn't added into the regular rate. But the plan has to be open at least to every worker covered by the wage law, not just members, though it can require a minimum length of service ([29 CFR 549.1](https://www.law.cornell.edu/cfr/text/29/549.1)). It has other conditions too:
- **The model's profit share probably doesn't meet it.** The part credited to members' accounts isn't paid "within a reasonable period" or put in a trust, as the rule requires. And a share "may not be diminished because of any other remuneration," but the model's pay rule takes part of above-market pay out of the profit share ([Investing for the Long Run](the-long-run.md#the-rules)).
- **So the whole profit share is added into the overtime rate.** Every year, once the profit share is set, the standard payroll service recalculates overtime for every worker who had any and pays the difference. Overtime is a small share of hours in most co-ops, so the cost is small.
- **Team bonuses are always added into the overtime rate.** The standard payroll service does the math.
### Meetings are work
Time at a required meeting counts as working time under federal law ([29 CFR 785.27](https://www.law.cornell.edu/cfr/text/29/785.27)). So members are paid for general meetings they're expected to attend and for time on the board and committees, as [When People Don't Stay Long](high-turnover.md#unions-stay) already requires. That also keeps governance from costing low-paid members a shift.
## Unemployment insurance: covered in most states, not all
Federal unemployment tax treats a corporation's officers as employees. But each state runs its own unemployment insurance, and several write owners or officers out of it ([US Department of Labor, *Comparison of State Unemployment Laws*, 2021, table 1-7](https://oui.doleta.gov/unemploy/pdf/uilawcompar/2021/coverage.pdf)). Examples:
- **New Jersey** excludes corporate officers and anyone who owns at least 5% of the business. In a co-op with fewer than 20 members, every member owns more than 5%.
- **Minnesota and Wisconsin** limit benefits for someone who owns 25% or more. In a co-op of three or four founders, every founder does.
- **Washington and South Carolina** leave corporate officers out unless the corporation chooses to cover them. Members often serve as officers.
For a member who loses their job when the co-op fails, that's the worst time to find out they're not covered.
**Washington has partly fixed it.** Since 2024, officers of worker co-ops there no longer lose benefits for owning 10% or more of the company, and serving only as an officer doesn't count as working ([Laws of 2023, chapter 92](https://lawfilesext.leg.wa.gov/biennium/2023-24/Pdf/Bills/Session%20Laws/Senate/5176-S.SL.pdf)). The co-op still has to choose to cover its officers ([RCW 50.04.165](https://app.leg.wa.gov/RCW/default.aspx?cite=50.04.165)).
**The model's rules:**
- **Always cover members.** Where a state lets a co-op choose whether to cover its officers or owners, a locked co-op chooses to cover them. That's a condition of the bank's loan and part of the charter.
- **Members are told in writing** before they join if their state leaves them out, the same notice Washington already requires for officers who aren't covered.
- **A model state law copies Washington and goes further.** It's a separate short law a state can adopt on its own, apart from [the lock's state law](durability.md#federal-test-state-copies): members of a locked co-op are covered like any other worker, and their membership share doesn't count as ownership when they claim.
- **Owners can't lay themselves off in turns.** The worry behind the state exclusions is real: owners can arrange their own layoffs. So under the model law, a member laid off in a rotation isn't counted as unemployed, except through the state's work-sharing program where it has one. Layoffs follow the co-op's written layoff order, and like any employer, a co-op whose members claim benefits pays a higher tax rate.
- **Where a state still excludes members,** co-ops in that state can band together through the [federation](federation.md) for their own unemployment cover. Mondragon did this. When Spain excluded co-op members from its social security system in 1958, the co-ops built their own mutual insurer, Lagun Aro, and later added an unemployment benefit ([MONDRAGON, *History*](https://www.mondragon-corporation.com/en/history/)). In America, a shared fund like that is insurance, so it has to be licensed by the state or set up as a special trust the tax law allows for unemployment pay. It's optional.
- **The pilot counts the gap.** It publishes how many members, by state, had no unemployment cover, and what happened to them when they lost their jobs.
## Workers' compensation: covered, unless someone opts out
Injury insurance usually covers corporate officers and directors who work for pay, but many states let owners opt out. California, for example, covers officers and board members "while rendering actual service for the corporations for pay" ([Cal. Labor Code § 3351(c)](https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=LAB&sectionNum=3351)). But an officer or director who owns at least 10% of the company and has health insurance can sign a waiver ([§ 3352(a)(16)](https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=LAB&sectionNum=3352)). In a co-op of ten or fewer members, every member owns that much, and the board is made of members.
**The model's rule: nobody opts out.** Every member, officer and board member who works for the co-op is covered, as a condition of the loan. Opting out saves a little on premiums and leaves an injured worker with far less.
The same goes for state paid sick leave and paid family leave where a state lets owners or officers opt out. The charter's own leave protections ([Does Sharing the Profit Mean Less Effort?](free-riding.md#when-a-member-wont-do-the-work)) cover small co-ops that federal family leave law, which starts at 50 workers, doesn't reach.
## Anti-discrimination law: probably covered, and the charter covers the rest
Federal anti-discrimination laws protect employees, not owners. In 2003 the Supreme Court said the question is control: does the person "act independently and participate in managing the organization," or are they "subject to the organization's control"? It listed six factors, including whether the organization can fire them, whether they report to someone, and whether the parties intended them to be an employee. It also said a job title or a written agreement doesn't settle it ([*Clackamas Gastroenterology Associates v. Wells*, 538 U.S. 440](https://www.law.cornell.edu/supct/html/01-1435.ZO.html)).
A co-op member who reports to a manager and can be dismissed looks like an employee on most of those factors. **The standard membership agreement says in writing that members are employees of the co-op for every employment law.** That's one factor, not a guarantee, but it's the one the co-op controls.
The main federal laws don't apply to employers with fewer than 15 workers (20 for age discrimination), and many co-ops will be that small. Many state laws reach smaller employers. California's, for example, starts at five ([Cal. Gov. Code § 12926(d)](https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=GOV&sectionNum=12926)). Below that, the charter does the work. The dismissal process bars discrimination and retaliation for every member, whatever the co-op's size ([Does Sharing the Profit Mean Less Effort?](free-riding.md#when-a-member-wont-do-the-work)). The charter extends the same ban to every job decision: hiring onto probation, pay, schedules and harassment. None of it replaces a worker's right to go to a government agency or a court. The charter says so, and it bars any agreement that would make a worker give that up.
## Union rights: the biggest gap
**The federal labor relations law (the NLRA) probably doesn't protect members' own union activity.** It protects employees, and the labor board has long excluded worker-owners who, as a group, control the company. In a 2002 case about a taxi company owned by its drivers, the board found the drivers weren't protected employees, because together they could elect and remove the board, officers and committees, repeal work rules, and change the company's constitution (*Citywide Corporate Transportation*, 338 NLRB 444). They also worked alongside employees who weren't owners, whose interests could differ. That's a close description of a locked co-op.
The board has gone the other way when *everyone* who works there is an owner and the owners' interest as workers is "at least as great" as their interest as owners, as in a 1953 case about a plywood co-op (*Everett Plywood & Door*, 105 NLRB 17). But the model allows up to a quarter of hours to be worked by people who aren't members yet. So members of a locked co-op would most likely fall on the unprotected side. The cases are collected in [Levinson, "Union Co-ops and the Revival of Labor Law," *Cardozo Journal of Conflict Resolution* vol. 19, 2018](https://static1.squarespace.com/static/60a5863870f56068b0f097cd/t/60a5e69e188d6e641ff01621/1621485214238/Union-Co-ops-and-the-Revival-of-Labor-Law.pdf).
**What that means in practice:**
- **Workers who aren't members are fully protected.** People on probation, and anyone else who isn't a member, can organize, and the co-op can't punish them for it. Members wouldn't be in their bargaining unit.
- **A co-op can still recognize a union voluntarily.** Nothing stops a co-op from signing a contract with a union that covers everyone, members included, as Cooperative Home Care Associates in the Bronx has done with 1199SEIU ([When People Don't Stay Long](high-turnover.md#unions-stay)). The difference is that if a co-op fired a member for union activity, the labor board probably couldn't step in.
**The model's rules:**
- **The charter protects members who organize,** and members can't vote it away. It already bars dismissing a member within 12 months of organizing unless the co-op proves the reason was something else ([Does Sharing the Profit Mean Less Effort?](free-riding.md#when-a-member-wont-do-the-work)). When a member says they were dismissed for organizing, the case goes straight to an outside arbitrator from the federation's list, paid for by the bank, not to a hearing of the same members they were organizing against. If the dismissal is overturned, the member gets their job back with back pay.
- **Recognition sticks.** Once a co-op recognizes a union, it can withdraw recognition only if a majority of the workers the union covers, members and non-members together, vote for that.
- **A short change in federal law, as its own bill.** It amends the NLRA's definition of "employee" to say that membership in a worker co-op, and the powers members hold together, don't by themselves take a worker outside the law. Members who actually supervise others are still excluded, like supervisors anywhere. It's kept out of the bank's law, so a fight over labor law can't sink the pilot.
**The change needs one more piece.** The NLRA forbids an employer from dominating or funding any "employee representation committee or plan" that deals with it over pay or working conditions ([29 U.S.C. 152(5)](https://www.law.cornell.edu/uscode/text/29/152), [158(a)(2)](https://www.law.cornell.edu/uscode/text/29/158)). That rule exists to stop company unions. But once members count as employees, a co-op's member meetings and elected committees, which deal with pay and working conditions all the time, could look like exactly that. So the same bill says a worker co-op's own governance isn't a company union, and it protects members only from being punished for organizing. It doesn't force a co-op to bargain with a union of members alone.
## Other laws that apply
- **Workplace safety law covers members,** including its protection against being punished for reporting a hazard. The charter's retaliation rule covers safety complaints too.
- **Health insurance.** The federal rule requiring employers to offer health coverage starts at 50 full-time workers, so it won't reach most co-ops. Members can use the public marketplace, and the federation can arrange a group plan.
- **The buy-in doesn't come out of pay** unless a member asks in writing. It comes out of profit shares ([When People Don't Stay Long](high-turnover.md)), which keeps co-ops clear of state laws that limit deductions from wages.
- **Workers without work authorization can't be members,** as [How a Locked Co-op Is Taxed](taxes.md) explains. Every co-op still owes every worker the minimum wage and a safe job, and the charter bars threats about anyone's immigration status.
## What this doesn't solve
- **The charter is weaker than law.** A member fired for organizing gets an outside arbitrator, not the labor board. Until the NLRA is changed, that's the protection.
- **The NLRA change is new federal law,** and it may never pass. Opponents will say it's odd for owners to organize against themselves. The answer is that a member has one vote among many, and the managers they elect can still treat them badly.
- **State unemployment law changes slowly.** Until states adopt the model law, members in some states have no unemployment cover unless their co-ops set up their own. Making that cover required would raise costs only for co-ops in those states. The pilot leaves it optional and counts who went without.
- **The founding setup hasn't been tested.** Whether unpaid work before the co-op is formed is really outside wage law is a question for the Labor Department's opinion letter, and whether members' accounts stay outside pension law is another ([Member Capital Accounts](capital-accounts.md#why-not-pay-it-out-when-you-leave)). Until it answers, the bank's lawyers decide, and they could be wrong.
- **Workers without papers are still left out** of membership, and so out of the profit share.
- **This is a map, not legal advice.** State rules differ and change, and the Department of Labor's table is from 2021. Instead of every co-op paying a lawyer, the bank keeps a checked set of rules for each state and runs a shared payroll service that handles minimum wage, overtime and insurance.
@@ -0,0 +1,42 @@
---
title: Open Problems
section: Guardrails
order: 140
summary: An honest list of what isn't solved yet.
---
# Open Problems
An honest list of what the model hasn't solved yet, most dangerous first.
1. **Adverse selection on founders.** *(The most dangerous.)* The lock removes the exit, and profits are shared with every member. In the success case a co-op founder ends up with somewhere between a fifth and a twentieth of what a conventional owner of the same firm would ([The Founder Problem](founder-problem.md)). For founders without wealth the gap in what the bet is *worth* is smaller, but at the top it's decisive. If the strongest founders go conventional, the bank's portfolio is the rejected pool, and the test fails for reasons that say nothing about co-ops. The pilot can at least tell how much of a result is down to founders: [Co-ops or Their Founders?](who-founds.md) measures founders on both sides from government records, and the verdict has to say how much their backgrounds explain. That doesn't change pass or fail, because if the lock keeps strong founders away, that's part of what the lock does. Still open: nothing yet makes the deal better for the founders the model most needs, and with about 400 co-ops, "too few to tell" is a likely answer.
2. **The horizon problem.** *(Mostly answered.)* Members near retirement have little reason to vote for investments whose payoff arrives after they leave. The evidence from France and Italy finds no underinvestment where co-ops must keep a reserve, and [Investing for the Long Run](the-long-run.md) closes the main routes around this model's reserve: pay above the going rate, sales of assets, development spending, high pay that turns into losses, and worn-out equipment still in use. A warning light shows when a co-op stops replacing its equipment, and big bets get an independent opinion before members vote. Still open: nothing forces a co-op to maintain what it has, a bad bet that fails after its backers leave falls on the people who came after, and it's unsettled whether 30% is enough and whether the account rate is right. Members' accounts now pay back within three to seven years ([Member Capital Accounts](capital-accounts.md#why-not-pay-it-out-when-you-leave)), so they help less with long-lived investments than accounts held until members leave. Letting members sell their places to incoming workers was rejected, because that is how the plywood co-ops ended up sold.
3. **Members' savings sit inside their employer.** *(Partly answered.)* [Member capital accounts](capital-accounts.md) are capped at a year's pay, protected by a solvency test, and now insured by a pool the co-ops pay for ([Protecting Members' Savings](savings-guarantee.md)). It covers final wages, former members' payouts and a protected slice of current accounts. Premiums are now estimated from Europe's wage guarantee funds and US closure and default rates: at least 0.25% of payroll for final wages, and probably 2% to 3% a year of covered savings, which takes nearly all the interest on a protected slice. The reserve target is sized for a five-year wave of failures like 2008–2012, and cover for current members' savings waits until the pool has its own track record. Still open: the pool has no government backing, so a worse recession could outrun it; the estimates rest on rough data, not co-ops; the reserve takes about fifteen years to build; and it needs a federal charter to avoid fifty states' insurance laws. Also open: an account paid out when a member leaves would probably be a pension plan under federal law, which would forbid the co-op from using the money. So accounts now pay each year's credit back on a fixed schedule of three to seven years, whether or not the member has left. That's designed to stay outside pension law, but it isn't settled until the Labor Department answers. It gives up the trust, disclosure and fiduciary rules that pension law would bring, which the model only partly replaces, and it leaves the co-op less of its members' money to invest for the long run.
4. **Is the lock too rigid?** A co-op that should be sold (a better operator exists, the members want out) can only go to another co-op. If the co-op sector is small, that buyer pool is tiny, and viable firms may be liquidated instead of rescued. The federation as buyer of last resort is a partial answer. Critics on the right add a sharper version: a lock that outlasts a repaid loan by generations is a permanent restriction on selling property, bought with one loan, and it binds members who never agreed to it. The lock is now built to hold in layers, including an independent lock share and rules against selling the business piece by piece ([The Lock](the-lock.md#how-the-lock-is-made-to-hold)). Its biggest legal gap is bankruptcy: federal bankruptcy law generally overrides limits on selling a bankrupt company's property, so the lock only holds in bankruptcy if Congress amends that law. [When a Locked Co-op Goes Bankrupt](bankruptcy.md) drafts that amendment. It's modeled on how bankruptcy law already treats charities. Only co-ops can buy a failed co-op whole, its workers can keep it running and bid with the wages they're owed, and creditors who never chose to lend are paid the lock's cost from the federation fund. It still needs Congress, a later Congress can repeal it, and while the co-op sector is small, some viable businesses will be broken up for lack of a co-op buyer. Italy offers a softer model: a co-op there can convert into an ordinary company, but its accumulated reserves go to the co-op development fund, not to the members ([Has This Been Tried?](precedents.md#italys-co-op-funds-coopfond-and-the-marcora-law)). That would keep the part of the lock that stops members cashing out what earlier generations built, and drop the part that stops a firm from ever changing hands.
5. **Scale.** *(Partly answered.)* Even a total success at pilot scale leaves most workers in conventional firms. The labor-market effect needs a sector large enough to move wages, and nobody knows how long growth to that size takes, if it happens at all. That's the price of not forcing it. [After a Pass: How Big, How Fast](scale.md) works through the numbers: even growing at twice the recent pace every year, co-ops would take about 19 years to reach 1% of today's private jobs. Growth after a pass comes in capped stages sized to demand the reviewers certify, and the labor-market effect gets its own test, by lottery, in local markets where co-ops could become a large share of one kind of work. Still open: the closest evidence cuts both ways (big retailers paying more didn't lift other employers' pay; hospitals hiring nurses did), the local test can only find fairly large effects, and co-ops may never become a big enough share anywhere to find out.
6. **Political durability.** *(Partly answered.)* Gradual construction invites gradual repeal. [Making It Last](durability.md) builds the bank to survive a hostile government: the lending standards and the test are fixed in law, board vacancies can't freeze lending, the verdict says "not tested" rather than "failed" if the bank turns away applicants who meet its own standards, and repeal can't unlock the co-ops. Still open: after the Supreme Court's 2026 ruling any president can replace the bank's board; money not yet lent can be taken back in a budget bill; the lock's bankruptcy protection depends on a federal law that can be repealed; and at pilot scale the sector has no real constituency.
7. **Growth and dilution.** *(Partly answered.)* Softened by the tenure ramp, not eliminated, and federal tax law softens it less than the founding plan expected: by default, the part of a share new members haven't reached goes to the reserve, not to other members ([How a Locked Co-op Is Taxed](taxes.md#the-tenure-ramp)). In steady state, an equal split per head still means a marginal hire must generate the average surplus to break even for incumbents. Capital-intensive expansion into lower-margin lines will face internal resistance conventional firms don't have. [When Growing Means Sharing](growth.md) makes Mondragon's answer the standard path: a lower-margin line becomes its own locked co-op, funded from the parent's reserve as a finite, voteless investment, under rules that stop it being used to push existing workers out or move money through insider deals. Still open: growth that can't be split off still dilutes, spin-offs create richer and poorer co-ops (the groups can pool profits, but don't have to), and the evidence comes from co-ops that could hire non-members, so it probably understates the problem under the lock.
8. **Who decides which sectors are "essential"?** *(Partly answered.)* Subsidy authority is price-setting power over a large chunk of the labor market, and every low-margin industry will suddenly discover it is Essential. [Who Decides What's Essential](essential-work.md) takes the choice away from people. A fixed formula decides: work qualifies only if public programs already pay for most of it and it pays badly. The money goes to workers by the hour, measured from records like Medicaid's visit logs, and states whose rates fall behind inflation lose federal Medicaid money in step. The data sources are now named: federal payment records for public money, and Census revenue, with new Census questions on a deadline and rules so status can't flip on noise. Still open: counted properly, hospitals are near the line, which would add their low-paid jobs and a great deal of cost; child care's status is undetermined until the new data exists; other work mostly paid for by families doesn't qualify; employers and owners may still capture some of the money; and it plausibly costs tens of billions of dollars a year.
9. **The 1/N problem.** *(Mostly answered.)* Peer monitoring works at 30 people; at 10,000 your extra effort raises the pool by a rounding error. The answer is culture and structure: co-ops really do push out free riders, and the federation model keeps units small enough for monitoring to bite while pooling risk above them. [Does Sharing the Profit Mean Less Effort?](free-riding.md) turns that into rules: small teams with their own numbers, team bonuses whose formula members set in advance, and a fair, fast dismissal process with protections against retaliation and discrimination. Still open: the evidence comes from conventional firms with profit sharing, not co-ops, and a small shared stake may even make people less willing to speak up; the pilot tracks value added per hour as co-ops grow to find out.
10. **The pilot's own weak points.** [The pilot](pilot-spec.md) puts the burden of proof on the co-ops, which means roughly a 1-in-10 chance of shutting down a pilot that was actually working. It tests individual co-ops, not the [federation](federation.md) support network Mondragon relied on. Its lotteries test help after the loan and, since the pre-launch lottery was added, help before it ([Help Before the First Loan](before-launch.md)), but only among groups that come forward, not outreach to workers who never think of starting a co-op. Founders now put in a small stake they can pay in unpaid work, but founders who can't afford months of unpaid work still face a barrier. Its comparison group (small businesses with government-backed loans) is biased in directions that partly cancel out, by amounts nobody knows. And market-rate lending based on collateral may mostly reach founders who already have some wealth. Approval rates by race, gender and wealth are published so that shows up, but publishing a gap doesn't close it.
14. **Drift toward the safest co-ops.** *(Partly answered.)* A bank that lends only to co-ops can still lend mostly to the safe ones, like consultancies started by people with savings, as America's co-op bank did. [Who the Bank Lends To](who-gets-loans.md) sets aside $90 million of the loan money for co-ops in lower-paid work, which goes back to the general pool only if the reviewers certify the bank handled those applications fairly. Results are reported for that group and for first-time owners without much wealth, and the verdict has to say which kinds of co-ops it covers. Still open: the set-aside makes money available but can't make groups apply; it can be used mostly on work just under the average wage; first-time owners without wealth get reporting, not money; and in work whose prices are set by Medicaid or contractors, a failure may say more about the price than about co-ops.
15. **Taxes.** *(Partly answered.)* A locked co-op is a corporation that pays tax once on members' profit shares, under the part of the tax code written for co-ops. [How a Locked Co-op Is Taxed](taxes.md) fits the model to it: profit is shared per hour worked, workers who aren't members get a share too, wages reach the going rate before any profit is shared, and charges come out of the current year's share before older, already-taxed balances. Still open: nobody has ruled that a co-op with an outside lock-holder and a reserve that goes to the federation counts as a co-op for tax purposes; whether profit shares owe payroll tax is unsettled; the ramp no longer shields senior members from new hires unless it's paid as wages; workers without work authorization can't be members; and the kept reserve pays 21% even though nobody owns it.
11. **Federal or state?** *(Mostly answered.)* The test stays federal, because one bank with one plan makes one clean experiment. Any state can copy the bank under the same published rules, reported separately, and a model state law protects the lock ([Making It Last](durability.md#federal-test-state-copies)). Still open: whether a few state lenders sharing one plan would have been the stronger experiment after all, since the most durable public bank in America, North Dakota's, is a state bank.
12. **Voluntary buyouts: in or out?** *(Answered.)* In, on a separate track: $100 million of its own, healthy firms only, a willing seller, no new tax break, a capped price the seller shares the risk on, a vote of the whole workforce, and its own test of money and pay, so it can't flatter the startup result ([When an Owner Wants to Sell to the Workers](buyouts.md)). Still open: whether a price set by the market is fair to workers who pay it out of their own future work, and whether about 60 deals can show more than that the loans get repaid.
13. **Work where people don't stay long.** *(Partly answered.)* In home care, most workers leave within a year or two. Membership is now automatic after a short probation, the buy-in comes out of profit shares rather than pay, and pushing people out before they'd join counts against the co-op ([When People Don't Stay Long](high-turnover.md)). Still open: with a five-to-ten-year profit share ramp, only a small minority of care workers ever reach a full share, so the people who stay collect much of the profit the others helped make. A much shorter ramp for low-margin, high-turnover co-ops would fix that, but it would go against the founding plan's range and weaken the ramp's protection against under-hiring. Also open: whether default members take part in governance, and the legal questions around tax IDs, visas and benefit asset limits. How labor law treats worker-owners now has its own page ([Owners Are Still Workers](labor-law.md)). Federal labor law probably doesn't protect members' own organizing, so the charter does, and fixing the law takes a separate bill that may never pass. Some states leave members out of unemployment insurance, and whether founders' unpaid work before the co-op forms is outside wage law still needs the Labor Department's answer.
*Not problems here, because nobody's company is forcibly bought:* valuing every firm in the economy, owners stripping value before a takeover, foreign capital flight, and years of payouts to former owners. The contractor boundary isn't an economy-wide problem either. Its co-op version, hiring non-members to dodge membership, is handled by [the lock](the-lock.md)'s membership requirement.
+94
View File
@@ -0,0 +1,94 @@
---
title: Who Sets the Pay
section: The Co-op
order: 22
summary: Salaries in a co-op are set by its members, so the model guards against two opposite failures, a majority paying itself a bigger slice and pay squeezed so flat that higher-paid people leave, with open pay bands, a pay ratio each co-op chooses, and a second vote that protects the lower-paid.
---
# Who Sets the Pay
In this model, base salaries still exist, and the co-op sets them. Hard jobs and in-demand jobs pay more ([Worker Ownership](worker-ownership.md)). There's no legal cap on pay.
That makes pay the one big lever the profit split doesn't control. Profit is divided by fixed rules: 30% to the reserve, then shares by hours worked, times each member's share weight, which grows with tenure until it's full ([Member Capital Accounts](capital-accounts.md)). Salaries are whatever the members decide. So a co-op's pay decisions can quietly undo the rest of the design, in two opposite ways.
## Two ways pay goes wrong
**Too much at the top.** A group of members who together make up a majority, say managers and skilled staff, can vote themselves higher pay. That money comes out of profit that would otherwise have been shared among all members. The model already takes part of it back. The reserve gets 30% of any pay above the going rate for the job, meaning the typical local pay for that kind of work ([Investing for the Long Run](the-long-run.md#the-rules)). But the other 70% still goes to whoever voted for it.
**Too flat, so higher-paid people leave.** Gabriel Burdín followed a random sample of Uruguayan workers through social security records from 1997 to 2010 ([Burdín, *Economic Journal*, 2016](https://gwern.net/doc/economics/2016-burdin.pdf)). He found:
- **Worker-run firms paid more at the bottom and less at the top.** Among the lower-paid (the 20th percentile), working in a co-op was linked with about 18% higher pay than in a conventional firm. Among the higher-paid (the 80th percentile), about 4% lower. That's a comparison across people, not proof that the co-op caused the difference.
- **Their higher-paid members quit more.** Members paid above their own co-op's median were 3.7 times as likely to quit as those paid below it.
- **Less squeezing meant fewer quits.** The effect was stronger in co-ops that squeezed pay more, and weaker among founding members.
That's one study, in one country. It measured only one of the two failures, since insiders quietly overpaying themselves is much harder to see in the data. So it doesn't show which failure is bigger. But it does show that the second one is real.
**Mondragon keeps a hard cap.** It started with top pay at three times the lowest, raised that to four and a half in the 1970s when it set up its own welfare system, and to six in 1988 in response to tax reforms. The head of its outreach program describes the cap as a balance between avoiding a brain drain and keeping a sense of fairness ([Co-operative News, 2025](https://www.thenews.coop/co-operative-approaches-to-pay/)).
## The rules
The model doesn't set anyone's pay. It makes sure pay is decided in the open, in the right order, and that the lower-paid can't simply be outvoted.
### Pay bands, not personal votes
- **Every job has a pay band**, a range from lowest to highest. Members approve the bands, as part of the pay policy.
- **Managers place people within the bands**, and can give raises and bonuses there, the way any employer does. Nobody's personal salary goes to a vote of the whole co-op.
- **Offers can run for up to three years.** A salary agreed within an approved band holds for the length of the offer, even if the band changes later. So a co-op can hire someone with a firm offer.
### Everyone sees the bands
- **Every worker, member or not, sees every band** next to the going rate for that job. Where a job has fewer than three people in it, only the band is shown, not what each person earns. Managers' actual pay is always shown.
- **Job ads list the pay band.** Several states already require this.
- **Workers can always talk about their pay.** Federal labor law already protects that for most workers, and a co-op can't ask anyone to keep their pay secret.
- **The public sees the totals.** Each year the co-op publishes its pay ratio, how much of its total pay was above the going rate, and pay by job, gender and race in summary form.
### Each co-op chooses its ratio
- **The bylaws must set a highest-to-lowest pay ratio.** There's no ready-made number. The founders have to pick one, and the founding documents show Mondragon's six to one for reference.
- **The ratio counts everything and everyone.** It's measured on total pay per full-time hour, including bonuses and benefits valued at cost. It includes people working through agencies, contractors and related companies. Moving the cleaning to an agency doesn't raise the bottom of the scale on paper.
### Two majorities, not one
A majority vote can't protect a minority from the majority. So some pay decisions need **two majorities**: a majority of all members, and a majority of the members in the lower-paid half. They are:
- changing the pay ratio;
- any band whose top is above the 90th percentile of local pay for that job, or above three times the co-op's own median pay;
- any change that raises bands for jobs held by more than half the members.
These votes are by secret ballot, with at least 30 days' notice. That makes both kinds of grab harder: a skilled majority can't vote itself a raise over the heads of the lower-paid, and a scarce specialist can still be paid what it takes, if both groups agree.
### Nobody votes alone on their own pay
- **The board approves managers' bands and the chief executive's pay.** Managers who sit on the board step out of those votes.
- **No two people can set each other's pay.**
- **Board pay, if any, is set by the members.**
- **Founders' pay** follows the existing rule: the founding bylaws can set it for at most five years, then the members take over ([The Founder Problem](founder-problem.md#the-tools)).
- **Pay is added up per person.** Pay from more than one role at the co-op, fees paid to a company the person owns, and pay from a related company all count together.
### Unions fit in
A union can bargain for any group of workers, members or not. For a minority that thinks it's being underpaid, collective bargaining is the standard protection, and it works inside a co-op too ([When People Don't Stay Long](high-turnover.md#unions-stay)).
## Why not a fixed cap
A legal cap on the ratio, like Mondragon's six to one, would be simpler. The model doesn't impose one, for two reasons:
- **Different businesses need different ratios.** A cleaning co-op and an engineering co-op don't hire in the same job market, and a cap that suits one would strangle the other.
- **Pay squeezed too flat has a real cost.** The large study above found higher-paid members leaving co-ops that squeezed pay hardest. A national cap would make every co-op take that risk.
What the rules do instead is make every co-op choose a ratio, in the open, and make high pay something the lower-paid have to agree to.
## What the pilot measures
The [pilot](pilot-spec.md#what-gets-measured) publishes, alongside the gap between top and bottom pay:
- **the share of pay above the going rate**, so any drift toward insiders paying themselves shows;
- **how often higher-paid members leave**, compared with the matched conventional firms, so a brain drain shows too;
- **pay gaps by gender and race within the same job.**
## What this doesn't solve
- **The going rate is a rough measure.** A government wage survey can't tell a strong engineer from an average one, and it carries the same pay gaps as the rest of the economy. It's a yardstick for when to ask questions, not a measure of what work is worth.
- **Two majorities are harder to reach.** Some fair raises will stall. That's the price of making sure the lower-paid can't be outvoted on pay.
- **Open bands still reveal a lot in a small co-op.** Some people will find that uncomfortable, and some good hires may not want it.
- **Nothing here makes a co-op pay its higher-paid people enough.** The rules make it easy to see when it doesn't. Whether members vote to pay what it takes to keep them is exactly what the pilot will show.
@@ -0,0 +1,107 @@
---
title: Guarding the Pilot
section: Growing the Sector
order: 66
summary: The rules that stop people from gaming the pilot with sham co-ops, hidden owners, split-up borrowers, zombie firms, or a rigged review.
---
# Guarding the Pilot
Any program that hands out money gets gamed. Venezuela's co-op boom produced tens of thousands of co-ops that existed mainly on paper ([YES! Magazine, 2007](https://www.yesmagazine.org/issue/latin-america/2007/05/12/venezuelas-co-op-boom)). Uruguay's FONDES sent nearly half its money to three politically connected firms ([El Observador, 2015](https://www.elobservador.com.uy/nota/mitad-de-dinero-del-fondes-fue-para-empresas-companeras--201572500)).
This page lists the ways someone could cheat [the pilot](pilot-spec.md), or sabotage it, and the rule that blocks each one. Most of these tricks don't require breaking any law. They only need a loophole, which makes them the likeliest threats. So the rules are written to close the loopholes.
## The hidden boss
**The trick.** A founder keeps the building, equipment, brand and software in a separate company they own. They sign up three friendly "members" and give the co-op a management contract or franchise deal with that company. The co-op borrows $3 million, and the money flows back to the founder as rent, fees and royalties. On paper it's a co-op. In practice it's the founder's business, funded by the public.
**The rules:**
- **Every deal with an insider is disclosed.** Any contract with a founder, manager, member's family, or a company they own is reported to the bank and checked each year to confirm it's priced like a deal between strangers. Overpayments are treated as a breach of the loan.
- **Members can fire management.** Any management or franchise contract can be ended by a members' vote on short notice. A contract the members can't get out of is a boss.
- **The business owns what it needs to operate, or can buy it.** If key assets are leased from an insider, the bank gets a claim on them or the co-op gets an option to buy them at a fair price. That way the co-op can't be held hostage, and the bank's collateral is real.
- **Membership is real.** Probation lasts at most 12 months, counted across rehiring, and membership is automatic when it ends unless the worker opts out through the lock-holder. Members must do at least half the work hours in the co-op's second year and three-quarters from its third, counting hours worked through agencies or contractors. Pushing people out before they'd join is tracked and counts against the co-op ([When People Don't Stay Long](high-turnover.md)). All of this is checked every year. A co-op that falls short counts as failed in the [pilot's survival measure](pilot-spec.md#what-gets-measured).
- **Lying costs something.** False statements on a loan application mean immediate repayment, and the people who made them are personally liable.
## Draining it through "investors"
**The trick.** [Outside investors in a co-op](capital.md) get no vote and a claim that expires. But nothing so far limits how big or long that claim can be. A friendly investor could take 90% of the profits for 40 years, and the co-op would be a shell.
**The rules, for pilot borrowers:**
- **The bank gets paid first.** Any outside profit-sharing claim ranks behind the bank's loan.
- **The bank must approve new claims.** Claims last at most 15 years.
- **Insiders' claims count as insider deals.** An investment by a founder or their family is checked the same way as any other insider contract.
## Splitting one borrower into many
**The trick.** The limit is $3 million per co-op. So a promoter, franchisor or landlord sets up 30 "separate" co-ops, each run by a relative or employee, and collects $90 million: a quarter of the fund, controlled by one person.
**The rules:**
- **"Related" means economically tied, not just sharing names.** Co-ops count as related, and share one $3 million limit, if they share a founder or manager, a franchisor, a landlord, a major supplier, an outside investor, or a paid promoter. Family members count as the same person, following the same kind of rules the tax code already uses for family ownership.
- **Caps on middlemen.** No single promoter, consultant or referral source can bring in loans totalling more than 3% of the fund.
- **Money arrives in stages**, as the business hits milestones, so a sham can't take the full amount and disappear.
## Founders paying themselves first
**The trick.** A founder puts in $300,000 as a "loan" and books two years of "unpaid work" at an inflated salary. When the bank's loan arrives, the co-op pays the founder back first, with generous interest. The founder's money is out and safe, and public money has replaced it.
**The rules** (details in [The Founder Problem](founder-problem.md#the-tools)):
- **Founders' loans and back pay rank behind the bank.** They can't be repaid from the bank's loan money, or while any payment to the bank is late.
- **Back pay is valued at no more than the co-op's own pay for that job** once it has revenue. Founder loans earn at most the Treasury rate plus three points.
- **Salaries can't be used to skip the profit split.** Founders can set their own pay in the founding bylaws for at most five years. After that, the members set all salaries. Pay for every role is disclosed to the bank each year, and the gap between the highest and lowest pay is published. Pay bands are open to every worker, nobody votes alone on their own pay, and high pay needs the agreement of the lower-paid half of the members as well as a majority of all of them ([Who Sets the Pay](pay.md)).
- **Payouts from members' accounts pass a solvency test**, so insiders who see trouble coming can't cash out first (see [Member Capital Accounts](capital-accounts.md#protecting-the-co-op-and-the-people-who-stay)).
## Relabeling an old business as "new"
**The trick.** A failing conventional business re-registers as a co-op to get a loan. That brings back the FONDES rescue problem through the side door. It would also flatter the survival numbers, because the business has already made it through its riskiest early years.
**The rule.** "New" describes the business, not the paperwork. If a co-op takes over an existing firm's assets, customers, location or most of its staff, it isn't new. It's either refused as a rescue or scored with the existing co-ops.
## Keeping zombies alive
**The trick.** If the test fails, the bank shuts down. So the bank's own staff have the strongest reason of anyone to make the numbers look good. They can stretch out loans, let unpaid interest pile up, and keep dead firms technically open. A healthy co-op can also absorb a dying one, so the closure never shows up.
**The rules:**
- **"Open" means actually operating:** at least three members and sales above a minimum level. An empty shell counts as closed.
- **Loans are valued honestly.** At review, outstanding loans are valued at what a buyer would pay. A loan whose terms were eased to avoid default is marked down. Unpaid interest added to the balance doesn't count as income.
- **Mergers are treated the same on both sides.** A co-op absorbed by another co-op, and a conventional firm bought by another firm, are both counted as neither success nor failure. A second analysis counts them both ways.
- **A merger that hides a failure counts as a failure.** If a co-op was behind on its loan payments, or below the minimum sales level, at any point in the year before it merged, the merger counts as a closure. Nobody working for the bank, including [the advisers](pilot-spec.md#the-support-lottery), may arrange or suggest a merger.
## Sabotage
**The trick.** An opponent recruits 25 or 30 plausible applicants, has them draw down their loans, strip the money and default. That pushes losses past the emergency stop and freezes the pilot before it reaches a verdict.
**The rules:**
- **Fraud doesn't end the experiment.** Under the [emergency stop](pilot-spec.md#the-decision-rule), lending restarts once the reviewers confirm the specific gaps are closed. Losses that are later recovered are credited back.
- **The staged payouts and middleman caps above** limit how much any ring can take before it's noticed.
## Rigging the review
**The trick.** The headline thresholds are fixed, but a lot of smaller choices aren't: which firms count as matches, how closures are detected in the data, which years are included. Lobbyists on both sides would fight over each one, and whoever picks the reviewers picks the answer.
**The rules:**
- **The analysis code is filed in advance**, not just the plan, along with a digital fingerprint that shows it hasn't changed.
- **The academic reviewers are nominated by the National Academies of Sciences**, and nobody with ties to the bank or to co-op advocacy groups can serve.
- **Any change from the filed plan must be signed, explained and published**, and the result under the original plan is always reported next to it.
## Rigging the price
**The trick.** A friendly bank chief could quietly undercharge co-ops, which is a hidden subsidy. A hostile one could overcharge them into failure.
**The rule.** The [annual rate audit](pilot-spec.md#what-market-rate-means) works in both directions. Undercharging is counted as a loss against the bank's results. Overcharging gets the affected loans repriced.
## Political favoritism
**The trick.** This is what FONDES actually did: politicians steer loans to allies.
**The rules:**
- **Every loan is public:** the borrower, the amount, the rate and the terms.
- **Every contact from an elected official or their staff about a specific loan is logged and published.**
- **The board answers to elected officials. Individual loans don't.** The President can appoint and remove the bank's board at will. But by law no official can direct a decision on a particular loan, loan officers are bankers, not appointees, and a new board can't change the filed lending standards without the results being reported both ways (see [Making It Last](durability.md#what-a-new-board-cant-change)).
@@ -0,0 +1,210 @@
---
title: The Pilot, in Numbers
section: Growing the Sector
order: 65
summary: The concrete pilot: how much money, how many co-ops, what gets measured, against whom, and the exact results that grow the bank or shut it down.
---
# The Pilot, in Numbers
[The Test](the-test.md) says the bank runs as an experiment. You can't fault an experiment that has no numbers, so this page supplies them. Every number here is a first draft and open to argument. What matters is that they're written down *before* the first loan, so nobody (me included) can pick them after seeing the results.
The rules that stop people from gaming the pilot (sham co-ops, hidden owners, rigged comparisons) have their own page: [Guarding the Pilot](pilot-safeguards.md).
## The short version
| | |
| --- | --- |
| **Money** | $350 million, once, plus a separate $100 million for [worker buyouts](buyouts.md). |
| **Lending window** | Years 1–5 (up to year 8 if good applicants are scarce). |
| **Target** | About 400 new worker co-ops. |
| **Loan size** | Up to $3 million per co-op, including all later loans. |
| **Price** | Market rate: what a similar conventional business would pay. No personal guarantees from members. |
| **Compared against** | Similar new conventional firms that got similar-sized loans, plus a lottery among borderline applicants. |
| **Final review** | Year 12, by an independent reviewer. |
| **Passes if** | Co-ops stay open about as well as the comparison firms *while paying their members fairly*, and the bank earns at least what the money would have earned in Treasury bonds. |
| **Shuts down if** | Either of those fails. |
The rest of this page explains each line.
## Two warnings from real life
Two public efforts to fund co-ops went badly. The pilot is built to avoid repeating them.
**Uruguay's FONDES.** In 2011 Uruguay set up a public development fund to lend to worker-run firms. By 2016 it had funded 22 firms, and almost 90% of its loans (US$63 million of US$69 million) had been written down as uncollectable. Eight firms got 93% of the money. Three with close ties to the governing party got nearly half. The biggest single loan, US$15 million, went to Alas Uruguay, an airline formed by the workers of the failed state airline. It stopped flying ten months after launch. The original bill capped any one firm at 3% of the fund, but lawmakers raised the cap to 15% before passing it. ([El Observador, 2017](https://www.elobservador.com.uy/nota/-en-que-estan-los-principales-emprendimientos-financiados-por-el-fondes--20176201600); [El Observador, 2015](https://www.elobservador.com.uy/nota/mitad-de-dinero-del-fondes-fue-para-empresas-companeras--201572500))
What went wrong: most of the money went to *rescuing firms that had already failed* under other owners, a few borrowers got most of it, and politics chose them. Uruguay reformed the fund in 2015. It added a 10% cap per business group and a limit of two loans per firm in five years ([la diaria, 2016](https://ladiaria.com.uy/articulo/2016/6/arrancando-de-cero/)).
**Venezuela's co-op boom.** After 2001, Venezuela gave co-ops cheap state loans, first pick of government contracts, and tax breaks. Registered co-ops went from under 800 to over 100,000 in a few years. A 2006 government census found only about 50,000 actually operating. Many had been set up to collect the benefits, not to run a business ([YES! Magazine, 2007](https://www.yesmagazine.org/issue/latin-america/2007/05/12/venezuelas-co-op-boom); [Grassroots Economic Organizing](https://geo.coop/blog/some-counter-intuitive-effects-government-support-co-ops-venezuela)).
What went wrong: cheap money attracts people who want the money, not the co-op. That's the main reason [the bank](the-bank.md) charges market rates.
## Why a new bank, not existing programs
Fair question. Congress already made co-ops eligible for the Small Business Administration's (SBA's) main loan program in 2018, through the Main Street Employee Ownership Act. It has barely been used. The SBA kept requiring personal guarantees on those loans, meaning individual members pledge their own homes and savings if the business fails. That doesn't work for a firm owned by dozens of workers ([House Committee report, 2018](https://www.congress.gov/committee-report/115th-congress/house-report/645/1); [NCEO, 2022](https://www.nceo.org/employee-ownership-blog/house-committee-urges-sba-fully-implement-main-street-eo-act)).
Private co-op lenders exist and do good work, but they're small. Shared Capital Cooperative, lending to co-ops since 1978, had about $20 million in assets in 2021 ([offering statement](https://www.sec.gov/Archives/edgar/data/1722839/000121390021050617/ea148092-1a_sharedcapital.htm)). The pilot's question is what happens when financing on normal commercial terms is available at real scale. The existing channels can't answer that.
## How much money, and why
**$350 million, one time.** In the pilot, the bank lends only its own capital. It doesn't borrow on top of it. That makes the worst case easy to state: taxpayers can lose at most $350 million, minus whatever is repaid. A separate $100 million for owners who choose to sell to their workers has its own rules and its own test ([When an Owner Wants to Sell to the Workers](buyouts.md)), which makes the total worst case $450 million.
It breaks down like this:
- **About $290 million for loans.** Around 400 co-ops with a typical first loan of about $500,000 is roughly $200 million. The rest is for follow-on loans to co-ops that grow. For comparison, Shared Capital's average loan is about $230,000. The pilot's loans are larger because it's meant to fund real businesses with real payrolls, not only very small ones.
- **$30 million, capped, for business help:** $20 million after the first loan (bookkeeping, governance training, legal setup, and more intensive help for half the co-ops, chosen by lottery; see [the support lottery](#the-support-lottery)), and $10 million before it, taken from the loan budget, for a lottery testing whether serious help makes more groups succeed in starting a co-op that lasts ([Help Before the First Loan](before-launch.md)). Venezuela's census blamed many of its failures on the lack of exactly this. This money is a public cost, stated openly and capped, and it's counted in the pilot's total cost to taxpayers. It isn't allowed to hide inside loan pricing.
- **The remainder** is a cash cushion and running costs.
## Why about 400 co-ops
This is the number that lets the test tell a real answer apart from luck.
New businesses close a lot. Of all US business locations opened in 2013, only about 35% were still open ten years later ([Bureau of Labor Statistics](https://www.bls.gov/bdm/us_age_naics_00_table7.txt)). With closures that common, a small pilot is noise: 30 co-ops could look great or terrible by chance.
To be confident that co-ops do "at least about as well" as conventional firms, you need to see several hundred closures across both groups. Standard survival statistics say that about 400 co-ops, each compared with five similar conventional firms, give roughly a 90% chance of a clear verdict if co-ops really are exactly as durable. If they're *more* durable, as the best existing study suggests (below), a clear verdict is easier to reach.
That also means a roughly 10% chance of shutting down a pilot that was actually working. That's the cost of putting the burden of proof on the co-ops, and it's stated openly.
**The honest problem with 400.** The whole US worker co-op sector was about 751 firms in 2023, roughly triple what it was a decade earlier ([NCEO summary of the Democracy at Work Institute census](https://www.nceo.org/employee-ownership-blog/democracy-at-work-institute-releases-its-2023-worker-cooperative-state-of-the-sector-report)). That's net growth of about 50 co-ops a year, after counting the ones that closed. Reaching 400 in five years means about 80 a year, roughly 1.6 times that pace, on top of whatever else the sector adds.
That creates exactly the pressure that sank Venezuela: an institution with a target lowers its standards to hit it. So:
- **No lending targets.** Nobody is paid, promoted or judged on loan volume. Part of the loan money is set aside for co-ops in lower-paid work, but that's money made available, not a target ([Who the Bank Lends To](who-gets-loans.md)).
- **If good applications fall short, the lending window stretches** (up to year 8) instead of standards dropping. The final review moves back by the same amount. That rule is fixed now.
- **A shortfall is itself a result.** If good co-op applicants don't show up even when financing is available, that's evidence for the "co-ops are rare because they're hard, not because they're unfunded" explanation, and it gets reported that way.
## Who can borrow
- **New worker co-ops only, for the main test.** "New" means the *business* is new, not just the legal paperwork. A conventional firm that reincorporates as a co-op isn't new (see [Guarding the Pilot](pilot-safeguards.md)).
- **Existing co-ops may borrow too, but they're scored separately.** Firms that have already survived for years would make the results look better than they are.
- **No rescues of failed conventional firms.** That's where FONDES lost its money.
- **Healthy firms whose owners choose to sell to their workers** can borrow on a separate track, with its own money and its own test, so they can't flatter the main result ([When an Owner Wants to Sell to the Workers](buyouts.md)).
- **At least three worker-members at the first loan**, meeting the [co-op definition](worker-ownership.md) and accepting [the lock](the-lock.md).
- **Members are on the payroll as employees.** That gives them minimum wage and workers' compensation like any other worker, and unemployment insurance in most states ([Owners Are Still Workers](labor-law.md)). It also means the co-op shows up in the government business records the test relies on.
- **Any legal industry.** Loan officers judge business plans, not industries. But no single industry may take more than 25% of the money lent, and no single county more than 10%, so one bad sector or one local market can't sink the whole test.
## Loan terms
- **$3 million per co-op, lifetime.** That's under 1% of the fund. FONDES let one airline take over a fifth of its money. That can't happen here. Related co-ops share one limit (see [Guarding the Pilot](pilot-safeguards.md)).
- **Paid out in stages** as the business hits agreed milestones, not in one lump.
- **No personal guarantees from members.** The risk that the business fails is exactly the risk the bank exists to price and carry. Loans can be secured by the business's own assets, like any business loan.
- **Underwriting can rely on cash flow**, not only on collateral. Collateral-heavy lending mostly measures how much wealth the founders already had, which shuts out the workers co-ops tend to serve.
## What "market rate" means
The bank prices each loan the way a normal lender would price a conventional business with the same numbers: same revenue, same industry, same track record, same collateral. Because there are no personal guarantees, the rate is higher than it would be with them. That's the honest price of the risk.
Two checks keep the price honest:
- **An annual audit**, in both directions. Each year an outside auditor compares a sample of the bank's loans with rates on comparable conventional small-business loans. It also checks the things that don't show up in the rate: who gets approved, how much collateral is required, and how often troubled loans get softer terms. If loans are systematically too cheap, the shortfall is counted as a loss against the bank's results. If they're too expensive, the affected loans are repriced.
- **A private market check.** Every loan is offered to private and community lenders, who can buy up to 20% of it on the same terms. How often they say yes is published. If nobody ever wants a piece, that suggests the price is too low for the risk.
## What gets measured
All of this is written down and filed in a public study registry before the first loan, the way serious medical trials and a few careful social-policy studies already do. One example is the [Oregon Health Insurance Experiment](https://www.nber.org/programs-projects/projects-and-centers/oregon-health-insurance-experiment/oregon-health-insurance-experiment-background). The actual analysis code is filed too, so the method can't quietly change later.
**The two measures that decide the outcome:**
1. **Fair survival.** Does the co-op stay open and keep treating its members fairly? A co-op counts as *failed* if any of these happens:
- it closes, or falls below three members or a minimum level of sales;
- for two years running, its members' median pay per hour (wages plus profit share) falls below 85% of the matched conventional firms' median;
- it stops being a real co-op: from its third year, fewer than three-quarters of its work hours are done by members, or its workers are kept on probation past the limit, or it pushes people out before they'd become members ([When People Don't Stay Long](high-turnover.md)).
The pay rule matters. Co-ops can survive a bad stretch by cutting their own pay instead of cutting jobs, and the best existing study couldn't rule that out. A co-op that survives by paying its people far less than the business down the street hasn't passed.
2. **The bank's money.** At the end, after every loss, write-off and running cost, and every dollar spent on business help, has the loan book earned at least what the same money would have earned in Treasury bonds over the same years? Anything less means taxpayers lost money compared with just holding bonds. Loans still outstanding are valued at what a buyer would pay for them. Loans that were restructured to avoid default are marked down, so going easy on struggling borrowers can't hide losses.
A useful yardstick: among the SBA's mature loan groups from 1992 to 2004, loans making up about 6% to 10% of the money lent eventually defaulted, and about 37% of the defaulted amount was later recovered ([GAO-20-618](https://www.gao.gov/products/gao-20-618)). A lender charging market rates with losses in that range covers them from interest.
**Measures that are published but don't decide:** jobs created (with growth compared with matched firms, spin-offs, and expansions members voted down; see [When Growing Means Sharing](growth.md#5-measuring-it)), wages at the bottom and top and the gap between them, the share of pay above the going rate, how often higher-paid members leave, pay gaps by gender and race within the same job ([Who Sets the Pay](pay.md)), revenue per worker (a rough measure of productivity) and value added per hour worked, followed as co-ops grow ([Does Sharing the Profit Mean Less Effort?](free-riding.md#measuring-it)), how long members stay, injury rates, and the bank's default and recovery rates. Also:
- **Who got loans.** Approval rates and loan terms by the founders' race, gender and personal wealth. If collateral-based lending quietly shuts out the people co-ops mostly serve, it should show. Results are also reported for co-ops in lower-paid work and for first-time owners without much wealth ([Who the Bank Lends To](who-gets-loans.md)).
- **Who gets the profits inside each co-op.** Profit shares, founding shares and membership approvals by race and gender, so that an in-group of early members can't quietly take most of the pool.
- **Who founded them.** Founders' previous earnings and work histories, using linked government records where they're available, compared with the founders of the matched firms. If the strongest founders stay away ([The Founder Problem](founder-problem.md)), this is where it shows. How it's measured, and how the verdict has to say how much founders explain, is in [Co-ops or Their Founders?](who-founds.md)
- **Investment.** Spending on equipment, buildings and development per worker, how much of each year's profit is kept in the business, and the return those investments earn, compared with the matched firms. If co-ops starve themselves of capital, as economists have long predicted ([Investing for the Long Run](the-long-run.md)), this is where it shows.
- **Local effects.** Business openings and closures in the counties where co-ops were funded, so any damage to existing local businesses is visible.
- **Results by industry**, including care work like home health and cleaning, where most US co-op members work today.
Why only two deciding measures: the more measures you allow to count, the easier it is to find *one* that looks good. Two, fixed in advance, can't be gamed that way.
## Who co-ops are compared against
This is the hardest part of any test like this. Co-ops that got a loan aren't like the average new business. They had a plan good enough to get a loan. Comparing them with *all* new businesses would flatter them.
**Main comparison: new conventional firms that got a similar-sized loan through the SBA's main program.** They're matched on industry, region, start year, starting size, legal form, and the number of founders. Businesses with more founders tend to survive longer, and co-ops always have at least three. This group is imperfect in both directions. SBA borrowers had to show they couldn't get credit elsewhere, which makes them a weaker comparison. But their loans carry a government guarantee that makes credit cheaper for them, which makes them a stronger one. The reviewer reports both biases openly. Researchers have already matched SBA borrowers with similar firms in government business records ([Brown and Earle, *Journal of Finance*, 2017](https://papers.ssrn.com/abstract=2641184)), so the data work is feasible.
**Second check: a lottery at the margin.** Among applicants whose scores land just around the approval line, the bank decides by lottery who gets a loan. Then it follows both groups. This is the cleanest test of the core claim: *does access to financing make co-ops that would otherwise not exist, or not survive?* The lottery group will be small, so it supports the main result rather than replacing it.
**Third check:** co-ops against *all* similar new firms.
**Sales count the same way on both sides.** A conventional firm that gets bought hasn't necessarily failed, and a co-op can't be bought at all because of [the lock](the-lock.md). So acquisitions and mergers, on either side, are counted as neither success nor failure in the main analysis, with a second analysis counting them both ways.
**What existing evidence predicts.** The strongest study so far followed every firm in Uruguay from 1997 to 2009: about 29,000 firms, 223 of them worker-run. After leaving out very small businesses and adjusting for tax differences, worker-run firms were about 29% *less* likely to close in any given period than conventional ones ([Burdín, *ILR Review*, 2014](https://journals.sagepub.com/doi/10.1177/001979391406700108)). The advantage was clearest in services. In manufacturing and transport there was no clear difference. That's encouraging, but it's one country, and it's why pay is built into the survival measure above.
## The support lottery
Mondragon's bank gave its co-ops far more than loans. It gave them a salaried manager while they planned, specialist departments for marketing and production, a staff member who sat in on board meetings for the first year, and monthly check-ins on the numbers ([Has This Been Tried?](precedents.md#mondragons-bank-caja-laboral)). Nobody knows whether the loans or the help did the work. The pilot can test part of that cheaply, so it does.
**How it works:**
- **Every co-op gets standard help:** setting up the books, legal setup, and training in how to run a co-op meeting and a co-op budget. That's about $15,000 per co-op, around $6 million in all.
- **Half also get intensive help for two years, chosen by lottery.** That means an experienced adviser who attends board meetings without a vote, a monthly review of the numbers, and access to specialists in marketing, operations and pricing. It costs about $70,000 more per co-op, around $14 million in all.
- **The draw is made by the reviewers, not the bank**, after each co-op's first loan payment, using a random method filed in advance. It's balanced by industry and by the founders' wealth. Every co-op is analyzed in the group it was drawn into, whether or not it uses the help, so a co-op can't improve the numbers by opting in or out.
- **Each co-op's help budget is set aside when it's drawn.** If money runs short, the intensive group gets smaller, but no co-op's help gets thinner.
- **Co-ops agree to the lottery when they apply**, and the design is approved by an independent research ethics board. Co-ops in the standard group can ask for intensive help after year four, once the main comparison has been measured.
**Advisers are walled off from the bank's interests.**
- **They're hired through outside nonprofit providers on fixed fees.** They aren't bank staff, and nothing they're paid depends on whether their co-ops survive.
- **They advise; they don't run anything.** All advice is written and non-binding. They can't vote, sign anything, or take part in hiring or firing, and the members can replace their adviser at any time.
- **They can't arrange rescues.** Advisers can't arrange or suggest mergers, loan changes or sales of the business. A co-op's merger counts as a closure if it was behind on payments or below the minimum sales level in the year before (see [Keeping zombies alive](pilot-safeguards.md#keeping-zombies-alive)). Merger and restructuring counts are published for each group.
- **They're treated as insiders.** An adviser and their firm can't sell paid services to a co-op they advised for three years afterward. Someone who referred a co-op for a loan can't be its adviser.
- **Their one reporting duty is fraud.** They report suspected fraud to the reviewers, and nothing about business performance goes to the lending side.
**The help is a subsidy, and it's counted as one.** Intensive help is worth something like a sixth of a typical first loan. So:
- **Every dollar of business help counts against the bank** in the [money test](#the-decision-rule), just like a bad loan.
- **The survival result for the standard-help group alone is published next to the main result.** If co-ops only hold their own with intensive help, readers will see that.
- **Comparison firms' use of free government business counseling**, such as the SBA's Small Business Development Centers, is recorded where the data allows.
**What it can and can't show.** Assume about 200 co-ops in each group and closures in line with new businesses generally. Then the pilot can reliably detect only a large effect, something like 30 to 40% fewer closures in the intensive group. So its main outcome is survival and fair pay at year four, when the help's effect should be clearest, with year twelve reported as well. The result is reported as a range, with a cost per closure avoided, and it counts as exploratory. **A result that isn't statistically significant doesn't prove the help is useless.** It means only that a large effect has been ruled out.
**What it doesn't test.** Mondragon's help started *before* a co-op existed: studying the idea, paying the manager, lending to cover early losses. This lottery starts after the loan, so a second lottery, among groups that want to start a co-op, tests help before launch ([Help Before the First Loan](before-launch.md)).
**If intensive help works,** that's an argument for building it into [the federation](federation.md), paid for by the co-ops out of their shared fund the way Italy's Coopfond is, not for a permanent federal advisory office.
## The timeline
- **Year 0.** Charter the bank. File the full analysis plan, code, benchmarks and thresholds. Name the reviewers.
- **Years 1–5.** Lending window, stretching to year 8 if applications fall short.
- **Year 6.** Public interim report. It can't trigger growth. It can only trigger the emergency stop.
- **Year 12.** Final review. Every co-op funded has been open for at least seven years. That covers most of the danger period for new businesses and, almost certainly, at least one recession.
**The reviewers:** the Government Accountability Office (GAO), Congress's own auditor, which already reviews SBA lending, working with academic economists nominated by the National Academies of Sciences. They must have no ties to the bank or to co-op advocacy groups. The bank's staff don't grade their own work. The reviewers can depart from the filed plan only in a signed, published explanation, and the filed plan's result is always reported alongside.
## The decision rule
At year 12, both measures must pass:
1. **Fair survival.** We can be 95% confident that co-ops' chance of failing in a given year, as defined above, is no more than 25% higher than the comparison group's. If co-ops are exactly as durable or more, this should pass. If they're clearly worse, it fails.
Why allow up to 25% rather than demanding exact equality? No test can prove two things are *exactly* equal. You can only rule out differences bigger than some margin, and a tighter margin takes more co-ops. Ruling out a 10% gap would take somewhere around 1,500 to 2,000 co-ops, more than twice the entire US co-op sector. The 25% margin is also a value judgment, stated openly: a society might reasonably accept firms that are a little less durable in exchange for workplaces people govern themselves. Readers who think that price is too high should say so. Burdín's result suggests the margin won't matter, because co-ops may well come out ahead.
2. **The bank's money** earned at least the Treasury rate, counting every loss, every running cost, and every dollar of business help.
**If both pass:** the charter triggers a fast-track, up-or-down vote in Congress on a second capitalization, with no amendments and no committee bottleneck. It can't be automatic, because no Congress can bind a later one's spending. A fast-track vote is the most that can honestly be promised. If the bank is later allowed to borrow, its debts carry **no federal guarantee**, written into the charter. Fannie Mae and Freddie Mac needed a taxpayer rescue in 2008 partly because markets had always assumed the government stood behind them. This bank shouldn't be able to create that assumption.
**If either fails, the shutdown happens by itself, with no further vote needed.** No new loans. The remaining loans are offered at fair value to co-op and community lenders, who keep the same repayment terms. Whatever they don't buy is collected by the Treasury on the original terms until paid off. None of it goes to distressed-debt buyers. The money goes back to the Treasury. The co-ops stay co-ops, because [the lock](the-lock.md) is in their own charters, but they're on their own.
**There is no "inconclusive, keep going" option.** If the evidence doesn't clearly show co-ops holding their own, that counts as a fail. The burden of proof is on the co-ops, which is where a skeptic would put it.
**The emergency stop.** If net losses ever pass 20% of the capital ($70 million), new lending freezes (the [buyout track](buyouts.md) has its own stop, at $20 million), and the reviewers report within six months on the cause:
- **A recession:** lending restarts only if the SBA's own loans from the same years lost money at a similar rate. That's a fixed comparison, not a judgment call.
- **Fraud:** lending restarts once the reviewers certify the specific gaps are closed and recovery has been pursued. Otherwise one organized fraud ring could end the experiment on purpose (see [Guarding the Pilot](pilot-safeguards.md)).
- **Bad lending decisions:** no restart. That's a result.
## What this pilot can't tell you
- **Whether the labor market changes.** 400 co-ops won't move anyone's wages. How big the sector would need to get, how long that could take, and how the effect could be tested locally after a pass are in [After a Pass: How Big, How Fast](scale.md).
- **Whether the best founders come.** The test measures the co-ops that *did* apply. If strong founders stay away ([The Founder Problem](founder-problem.md)), a pass means "the co-ops that exist can compete," not "co-ops would win with everyone's best people." The pilot can measure how much founders' backgrounds explain the result, but it can't see the founders who never applied ([Co-ops or Their Founders?](who-founds.md)).
- **Whether co-ops do better with a full support network.** The pilot funds individual co-ops. It doesn't build [the federation](federation.md) that Mondragon relied on, so co-ops that join a federation are reported separately. The [support lottery](#the-support-lottery) tests one piece of that network, hands-on help, but not the rest: shared funds, moving workers between co-ops, joint buying and selling.
- **Whether it survives politics.** Congress can refuse the second capitalization, or repeal the whole thing. A hostile board could also try to rig the result, which is why the verdict can be "not tested" rather than "failed" if the bank turned away applicants who met its own standards. See [Making It Last](durability.md).
@@ -0,0 +1,88 @@
---
title: Has This Been Tried?
section: Growing the Sector
order: 55
summary: Three real institutions that financed co-ops (Mondragon's bank, America's co-op bank, and Italy's co-op funds), what they got right, how they went wrong, and what the pilot takes from each.
---
# Has This Been Tried?
A bank that lends to co-ops isn't a new idea. At least three serious versions exist, and each teaches something different. One built a whole economy of worker co-ops, and then quietly became an ordinary bank. One was created by Congress, and ended up lending to expensive New York apartment buildings. One is funded by co-ops themselves and still holds most of what it has collected over thirty years.
This page goes through what each one actually did, with the failures left in, and what [the bank](the-bank.md) and [the pilot](pilot-spec.md) take from them.
## Mondragon's bank: Caja Laboral
**What it was.** In 1959 the young Mondragon co-ops in Spain's Basque Country founded their own savings bank, the Caja Laboral Popular ("the Working People's Bank"). Local people deposited their savings there, and every member co-op was required to bank there too. Until the 1980s it lent almost only to co-ops. Apart from some government job-creation money, it was their only source of capital from outside their own members, and its loans made up about half of the co-ops' capital ([Morris, *The Mondragón System*, 1992](https://www.ilsr.org/wp-content/uploads/2013/03/mondragon-3.pdf)).
**What made it unusual wasn't the money. It was the help.** In 1969 the bank set up a separate Entrepreneurial Division, which worked more like a business incubator than a loan desk (Morris, 1992):
- When a group of workers came with an idea, the bank picked one of them to be manager, **paid them a salary**, and put them on a team that studied whether the business could work. That often meant visiting factories elsewhere in Europe to learn how the best ones did it.
- The bank had departments for marketing, exports, production planning, factory design, legal setup and accounting, and the new co-op could use all of them.
- The bank put up 60% of the start-up money. The government's job-creation program put up 20%, and the founders themselves 20%. The bank also covered the new co-op's operating losses for its first two years, as a loan to be repaid later.
- One bank staff member was assigned to each new co-op as its "godfather" and sat in on its board meetings for the first year.
- Every co-op sent the bank monthly financial reports and got a full audit every four years.
**The famous number, and why it needs care.** Of the 103 co-ops created between 1956 and 1986, only three were shut down ([Whyte and Whyte, *Making Mondragon*, 1988, as summarized by Participedia](https://participedia.net/case/82)). That counts co-ops the bank rescued, restructured or merged as survivors, and five things sit behind it:
- **The money was cheap.** One new co-op, a maker of seed drills, borrowed at 8% when the going market rate was 15%. The bank could do this partly because every co-op had to do its banking there, which kept its cost of money low, and partly because its managers were paid co-op wages (Morris, 1992).
- **The help was subsidized.** The economist David Ellerman estimated in the early 1980s that what new co-ops repaid for their start-up support covered less than half of what it actually cost the bank (Morris, 1992).
- **Survival often meant rescue.** In Spain's recession of the late 1970s and early 1980s, the bank's business division switched from starting co-ops to saving them. In 1983 alone, 34 of about 100 co-ops needed its intervention department. The bank replaced managers, changed product lines, merged co-ops, and moved workers between them, and it wrote off about $160 million in loans (Morris, 1992). Many of the co-ops that "survived" did so because the bank restructured them. That's a real achievement, but it isn't the same as surviving on their own.
- **The market was protected.** Mondragon's biggest industry was household appliances. In 1980 Spain protected them from imports with a 35% tariff, and that protection only fell away (to 5% in 1991) as Spain joined the European market (Morris, 1992).
- **Then the bank moved on.** Once a change in Basque co-op law allowed it, in the early 1980s, the bank started offering mortgages and car loans to the public, and its deposits grew far beyond what the co-ops needed. By the early 1990s only about 15% of its loans went to co-ops. In 1991 the Entrepreneurial Division was spun off into a separate consulting co-op, LKS (Morris, 1992).
- **And the flagship later failed.** Fagor, the appliance maker at the heart of the group, expanded across Europe and China largely on borrowed money, lost its main market in Spain's housing crash, and went bankrupt in 2013 with about €800 million (about $1.1 billion) of debt ([The Local, 2013](https://www.thelocal.es/20131114/major-manufacturer-fagor-files-for-bankruptcy)). Full group support didn't save it.
**What the pilot takes from it:**
1. **Maybe the help mattered more than the money.** Mondragon combined cheap loans, a protected home market and intensive hands-on support, and nobody can tell which one did the work. The pilot tests one piece of that directly (see [the support lottery](pilot-spec.md#the-support-lottery)).
2. **Count rescues honestly.** A bank that can quietly restructure its borrowers can make almost any survival rate look good. That's why the pilot counts a co-op that's been restructured to avoid default as a loss, not a survivor (see [Keeping zombies alive](pilot-safeguards.md#keeping-zombies-alive)).
3. **A co-op bank drifts toward ordinary lending once co-op demand runs out.** The pilot's bank can lend only to worker co-ops, and the bank can't change its own charter (see [The Bank](the-bank.md)). That doesn't stop a legislature from changing it, which is how Caja Laboral's drift began.
4. **The pilot doesn't copy the cheap money.** It lends at market rates, for the reasons on [the bank's page](the-bank.md). The help it gives *is* a subsidy, so it's counted against the bank's results and tested separately (see [the support lottery](pilot-spec.md#the-support-lottery)).
## America's co-op bank: the National Consumer Cooperative Bank
**What it was.** In 1978 Congress created the National Consumer Cooperative Bank to lend to co-ops that couldn't get credit from ordinary banks. It came with a separate office to give new co-ops technical help and start-up money. The law authorized up to $300 million of federal capital over five years ([Public Law 95-351](https://www.congress.gov/95/statute/STATUTE-92/STATUTE-92-Pg499.pdf)). According to one of its later board members, the public investment that actually went in was about $100 million, to be repaid over 40 years ([Barnes, board member 1982–86](https://peter-barnes.org/blog/what-ever-happened-to-the-national-cooperative-bank/)).
**Why it never became a worker-co-op lender.** The law was written for co-ops whose members are *consumers*: food co-ops, housing co-ops, co-ops that buy things together for their members. To qualify, a co-op has to serve members "who are ultimate consumers" of what it provides. Co-ops of *producers* can borrow, but loans to them are capped at 10% of the bank's assets ([12 U.S.C. § 3015](https://www.law.cornell.edu/uscode/text/12/3015)). A worker co-op, whose members supply their work rather than buy its products, fits that law awkwardly at best. Worker co-ops were never what it was built for.
**What happened to it.** The Reagan administration tried to shut it down in 1981. Congress resisted, and the compromise was to privatize it. On December 31, 1981 it became a co-op owned by its borrowers, and by 1985 it was fully private and renamed the National Cooperative Bank ([Next City, 2019](https://nextcity.org/urbanist-news/the-weirdest-bank-youve-never-heard-of); [Encyclopedia.com](https://www.encyclopedia.com/law/encyclopedias-almanacs-transcripts-and-maps/national-cooperative-bank)). It survived, and it had about $2.6 billion in assets in 2019. Its technical-help office was later spun off as a separate nonprofit ([12 U.S.C. § 3051](https://www.law.cornell.edu/uscode/text/12/3051)). But in the words of one of its former directors, it became "a big lender to fancy New York City housing coops, large grocery wholesalers" and other "healthy businesses, few of whom need help getting credit" (Barnes).
**What the pilot takes from it:**
1. **The charter decides who gets served.** A bank "for co-ops" will lend to whichever co-ops are safest. The pilot's bank is chartered for worker co-ops only, and only those that accept [the lock](the-lock.md).
2. **A target nobody enforces is not a target.** The 1978 law told the board to use its "best efforts" to keep at least 35% of its loans going to co-ops of low-income people or facilities they use (Public Law 95-351). Missing that target had no consequence, and the bank drifted toward borrowers that could have borrowed anywhere. The pilot has fixed measures, published in advance, and a date when it has to answer for them (see [The Test](the-test.md)).
3. **Politics can change what a bank is.** It survived an attempt to close it by becoming something else. A charter can't protect against the legislature that wrote it. The pilot is built so that a failed test shuts it down cleanly, rather than letting it drift into a different mission (see [The Constitution](constitution.md)).
## Italy's co-op funds: Coopfond and the Marcora Law
Italy has two different kinds of co-op fund, and both are useful.
**Coopfond: money from the co-ops themselves.** Since 1992, Italian law has required every co-op to pay 3% of its annual profit into a co-op development fund, usually the one run by its federation ([Lomuscio and Tortia, 2023](https://pdfs.semanticscholar.org/a7eb/90b586cb2adb63c321f1ca616afefb27eb70.pdf)). Coopfond is the fund of Legacoop, the largest federation. It also receives whatever is left of a co-op's assets when it's wound up, or when it converts into an ordinary company. It invests in co-ops through loans and temporary, time-limited stakes, and recycles the money as it's repaid. From 1993 to 2023 it collected about €596 million. After three decades of losses and grants, about €499 million, or 91% of what it collected, is still in the fund. Around 2,400 co-ops pay in, about €18–20 million a year ([Coopfond, 2022–23 sustainability report](https://www.coopfond.it/wp-content/uploads/bilancio24_compressed.pdf)).
Two cautions. Those are euros as collected, not adjusted for thirty years of inflation, so the fund's real value has shrunk more than the 91% suggests. And it isn't a one-time endowment: new money arrives every year, and Italian co-ops pay the 3% alongside generous tax breaks on their locked reserves. Still, it's the closest real-world model for the [federation's](federation.md) shared fund: the movement financing its own growth without spending its capital away.
**The Marcora Law: public money matched to workers' own.** Under a 1985 law named after the industry minister who proposed it, Giovanni Marcora, the Italian state helps workers buy out firms that are closing. It invests alongside the workers through CFI, a specialist co-op investor whose members include the industry ministry, Italy's business development agency Invitalia, and about 270 co-ops. Before investing, CFI studies whether the business can work and advises the workers, much like Mondragon's Entrepreneurial Division. Workers put in their own money too, often from their severance and unemployment benefits ([Vieta, Euricse working paper, 2015](https://base.socioeco.org/docs/wp-78_15_vieta.pdf)).
The survival record is good. Of the 82 buyouts started between 2003 and 2014, 71 (87%) were still operating at the end of 2014 (Vieta, 2015). That's a young group, though, and buyouts are chosen with care: workers only try one when they're confident the business can work.
These are rescues of existing businesses, which [the pilot](pilot-spec.md#who-can-borrow) keeps out entirely because of Uruguay's experience. It does lend for sales of *healthy* firms whose owners choose to sell to their workers, on a separate track ([When an Owner Wants to Sell to the Workers](buyouts.md)). The difference is in how they're chosen. Marcora buyouts need the workers' own money and a specialist's study of whether the business can work. Uruguay's went mostly to politically connected firms that had already failed.
**The warning in the Marcora story.** The original law let the state put in up to three euros for every euro the workers invested. After Italy's main employers' association intervened, the European Union ruled that this broke its competition rules by giving worker buyouts an unfair advantage, and the program was suspended. A 2001 reform cut the state's share to one euro per worker euro, to be repaid over 7 to 10 years. From 2000 to 2006, while the rules were being rewritten, CFI made no new investments, and only a handful of buyouts happened at all (Vieta, 2015).
**What the pilot takes from it:**
1. **A movement fund can last.** Coopfond shows that a small cut of co-op profits, lent and invested carefully, can keep its value for decades. The federation fund is modeled on it.
2. **Workers' own money in, alongside the public's.** Both Mondragon (20% from founders) and Marcora (one to one) required workers to put money in. The pilot now asks founders for a small stake, at least 5% of the first loan, which they can pay in unpaid work instead of cash, because a cash buy-in shuts out workers with no savings ([Help Before the First Loan](before-launch.md)).
3. **Subsidies get challenged.** Marcora's generous matching was the part that got struck down, and the program lost six years. The US has no European-style rule against state aid, but the political version of the same attack ("unfair competition with taxpayer money") works anywhere. A bank that lends at market rates is much harder to attack that way. That's one more reason the pilot's bank doesn't subsidize its loans, and why the help it does give is costed openly.
4. **Italy locks the reserves, not the firm.** An Italian co-op *can* turn itself into an ordinary company, but its accumulated reserves go to the co-op development fund, not to the members. That's a softer version of [the lock](the-lock.md), and it's weighed in [Open Problems](open-problems.md).
## The pattern
| | Mondragon's bank | US co-op bank | Coopfond | Marcora / CFI |
| --- | --- | --- | --- | --- |
| **Money came from** | Local savers and the co-ops' own deposits | Federal capital | 3% of co-op profits | The state, matched to workers' money |
| **Hands-on help** | Very intensive | A separate office, later spun off | Some | Feasibility study and advice |
| **Who it served** | Worker co-ops | Mostly housing and consumer co-ops | Co-ops of all kinds | Worker buyouts |
| **What went wrong** | Rescues flattered its record; it drifted into ordinary banking; its flagship failed | Drifted to safe borrowers after privatization | Its real value shrank with inflation | Its subsidy was ruled unfair and the program suspended |
Four cases can't prove a rule. But two patterns stand out. The two that actually built worker co-ops, Mondragon and Marcora, both paired money with serious hands-on help before and after launch. And both banks drifted once their mission became optional, one because the law changed and one because nobody enforced its target. The pilot takes both lessons: it tests whether the help matters, and its mission comes with a deadline and a verdict.
@@ -0,0 +1,20 @@
---
title: Prison Labor
section: Beyond Co-ops
order: 120
summary: Only with full pay parity, and only if no budget ever benefits from more people being locked up.
---
# Prison Labor
Incarcerated people work **the same jobs, at the same pay, side by side with free temps.**
Pay parity is not a detail. It is the entire moral load-bearing wall, and it is the first thing a critic will assume was left out, so it goes in the first sentence, always.
Pay parity is what separates this from convict leasing. The historical horror ran on a *cost advantage*: after the Civil War, Southern prisons became labor suppliers and arrests rose to meet demand. The Thirteenth Amendment abolished slavery *"except as a punishment for crime,"* and that clause is why it worked, and it is still in the Constitution. Remove the cost advantage and the state gains nothing by locking more people up.
**The test, applied forever:** *if automation arrived tomorrow and every prison job vanished, would any budget line get harder to balance?* If yes, the trap is live and the design has failed.
Required guardrails: pay parity, genuinely optional participation, no quotas, no budget line that improves with a larger prison population.
**The goal is reform, not punishment.** People should leave prison with savings, a work history, and a reference. Employment after release is one of the strongest predictors of not going back.
@@ -0,0 +1,144 @@
---
title: Protecting Members' Savings
section: The Co-op
order: 26
summary: If a co-op fails, its workers shouldn't lose their last paychecks and their savings along with their jobs. How an insurance pool paid for by the co-ops protects them, and what it can't promise.
---
# Protecting Members' Savings
Every member of a co-op builds up savings in a [capital account](capital-accounts.md). That money is real, but it sits inside the same business that pays the member's wages. If the co-op fails, its lenders get paid first, and members' accounts are paid only from whatever is left. A worker can lose their job and their savings on the same day. Their last few paychecks may go unpaid too.
The model already limits how much can pile up (accounts are capped at a year's pay) and stops insiders cashing out first (every payout has to pass a solvency test). This page adds the missing piece: insurance.
## What goes wrong in real life
- **Fagor, 2013.** When Mondragon's appliance maker went bankrupt, more than 900 former workers of Fagor and a related co-op sued Mondragon for €47.8 million of capital contributions they had lost. The courts turned them down, ruling in 2018 that the claim had been filed too late ([Europa Press, via Infobae, 2023](https://www.infobae.com/espana/agencias/2023/11/12/diez-anos-de-la-caida-de-fagor-electrodomesticos-cronologia-de-una-quiebra/)). Some savers had also bought Fagor's subordinated bonds believing they were ordinary deposits ([elDiario.es, 2023](https://www.eldiario.es/euskadi/diez-anos-quiebra-fagor-electrodomesticos-tambaleo-esencia-cooperativismo-vasco_1_10612926.html)). Mondragon did protect most of the *jobs*. Of 1,736 members in the Basque plants, 577 became members of other co-ops, 320 were moved temporarily, and many more retired early or were compensated, paid for partly by doubling contributions to Mondragon's own social insurance fund (elDiario.es). It had no equivalent protection for their savings.
- **Italy's consumer co-ops, 2014–15.** Italian consumer co-ops let their members keep savings with them, as a "social loan" to the co-op. When two co-ops in Italy's northeast failed, thousands of members' savings were frozen. At one of them, Coop Operaie di Trieste, 17,662 members held social-loan savings, and a rush of withdrawals set off the collapse ([Wikipedia, in Italian](https://it.wikipedia.org/wiki/Cooperative_Operaie_di_Trieste_Istria_e_Friuli)). At the other, CoopCa, about 3,000 members had around €27 million at stake. Under the rescue plan they could expect three-quarters back at best, and not before 2018. Neighboring co-ops voluntarily paid them up to half of their money, €13 million in all ([Il Fatto Quotidiano, 2015](https://www.ilfattoquotidiano.it/2015/08/05/crac-coopca-la-guerra-dei-soci-che-spaventa-le-altre-cooperative-del-nordest/1919384/)).
- **Enron, 2001.** Not a co-op, but the same trap: about 63% of the money in its workers' 401(k) retirement plan was in Enron's own stock when it collapsed ([GAO-02-480T](https://www.gao.gov/assets/gao-02-480t.pdf)).
Three lessons come out of these. When members sense trouble, they rush to get their money out, and the rush itself can sink the firm. Savings get sold as safer than they are. And help after the fact is voluntary, partial and slow.
## How similar protection works elsewhere
- **Credit unions.** America's credit unions are member-owned, like co-ops, and they insure their members' savings through a fund they pay for themselves. Each credit union keeps a deposit worth 1% of its insured savings with the fund, and the fund aims to hold reserves of 1.2% to 1.5% of insured savings ([12 U.S.C. § 1782](https://www.law.cornell.edu/uscode/text/12/1782)). Unlike this model, that fund is backed by the full faith and credit of the United States ([NCUA](https://www.ncua.gov/support-services/share-insurance-fund)).
- **Bank deposits.** Deposit insurance is paid for by the banks, through premiums that by law must rise with each bank's risk. The fund's legal minimum reserve is 1.35% of insured deposits, and its long-term goal is 2% ([FDIC](https://www.fdic.gov/resources/deposit-insurance/deposit-insurance-fund/); [12 U.S.C. § 1817](https://www.law.cornell.edu/uscode/text/12/1817)). It too has government backing.
- **Unpaid wages in Europe.** Every EU country has to run a guarantee institution that pays workers' unpaid wages when their employer goes insolvent: at least three months' pay (or a set minimum over a longer reference period), up to a ceiling. Employers help pay for it unless the government covers it all, and its money has to be kept out of reach of any employer's insolvency ([EU Directive 2008/94/EC](https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:32008L0094)).
- **A warning: America's pension insurance.** The federal agency that insures private pensions has charged premiums only loosely tied to each plan's risk. The Congressional Research Service put them at "about half of the market price for the insurance." Companies in trouble let their pension plans fall further behind before going bankrupt and handing them over. By 2004 the program's deficit had reached $23 billion ([Congressional Research Service, 2004](https://www.everycrsreport.com/reports/RL32702.html)). Insurance that doesn't charge for risk invites risk.
## The insurance pool
Every locked co-op belongs to a **members' savings insurance pool**. It protects three things, in this order.
**1. Final wages.** If a co-op fails owing its workers pay, the pool pays up to eight weeks of each worker's unpaid wages and earned time off. For a low-paid worker, this is usually the biggest loss, and US bankruptcy law only partly protects it: unpaid wages get priority, but only up to $17,150 per worker, and only after secured lenders are paid ([11 U.S.C. § 507(a)(4)](https://www.law.cornell.edu/uscode/text/11/507)).
**2. Former members' payouts.** When someone leaves, their account becomes a fixed amount the co-op owes them, paid on the account's normal schedule, three to seven years after each credit was made (see [Member Capital Accounts](capital-accounts.md#how-its-paid-out)). They no longer work there and have no say in how it's run, so this is the clearest case for protection. The pool covers these unpaid payouts up to $50,000 per person.
**3. A protected slice of current members' accounts.** A current member's account is different. It's their share of the risk of owning the business, and they help run it. So only a slice is protected: 80% of the first $10,000. That protected slice earns the plain Treasury rate. Only the part of the account that's still at risk earns the extra two percentage points (see [Member Capital Accounts](capital-accounts.md#how-the-account-grows)). Nobody gets paid a premium for risk they don't carry, and every member keeps some skin in the game.
**The limits are per person**, across every co-op they've ever belonged to. An account split in a divorce or passed to an estate shares the original person's limit.
## How it's paid for
**By the co-ops, not taxpayers.** The pool has **no government backing**, for the same reason [the bank's](pilot-spec.md#the-decision-rule) debts don't. A public guarantee would be a hidden subsidy, and markets would come to assume it. That's a real difference from credit unions and banks, and it's why the pool has to be run more cautiously than they are. The charter also bars the bank from lending to the pool, buying its assets or paying its claims, so the bank can't become a backstop by the back door. No law can stop a future Congress from bailing the pool out. The best this model can do is keep the promises small enough, and the cut in a bad year plain enough, that a bailout isn't needed.
- **A deposit to join.** Like a credit union, each co-op keeps a refundable deposit with the pool, worth 1% of what's covered.
- **Yearly premiums, priced as if nothing will be recovered from members' savings.** Members' accounts rank behind the co-op's lenders, so when a co-op fails, there's usually little or nothing left for them. Premiums assume exactly that. They're set by an independent actuary from a published formula, and audited every year in both directions, like [the bank's rates](pilot-spec.md#what-market-rate-means).
- **Final-wage cover is one flat rate for everyone,** as in Germany and France. Charging the riskiest co-ops more would charge care workers most for the cover that matters most to them.
- **Savings cover is priced on things a co-op controls,** like the size of its reserve, how much it owes, and whether its accounts are on time, measured from its audited year-end accounts. Margins set by public payment rates, like Medicaid's, aren't counted against it. The premium can rise by at most one percentage point a year, unless audited accounts show the co-op getting worse.
- **Deferring is limited.** A struggling co-op can defer up to one year's premium, as a debt to the pool. While it does, new additions to its members' accounts aren't covered, and the pool watches its accounts monthly. Deferred premiums count as nothing in the pool's reserve, and if the co-op fails, they're paid ahead of its members' accounts.
- **Who pays.** Current members pay for cover of their own slices, out of their account interest, in proportion to each person's protected balance. Members who take all their profit share in cash, as low-income members can, don't pay for cover they don't have. The co-op as a whole pays for final-wage cover, since everyone is covered, and for former members' payouts. Those are debts the co-op owes, and charging the premium to former members would take most of the interest on money they can't touch.
- **Other ways to cover former members.** A co-op can instead put former members' unpaid payouts in a fully funded escrow account, or back them with a surety bond from an approved insurer. The pool is the default. If private options come in cheaper, that's a useful check on the pool's prices.
- **The reserve is kept safe.** It's held in Treasury securities by an independent custodian, out of reach of any co-op's creditors, as Europe's rules require of wage funds. It never holds the bank's loans or anything issued by co-ops, which would fail at the same time as the co-ops it insures.
## What it should cost
Nobody has priced insurance quite like this, but the pieces can be estimated from what's known.
**Final wages: at least 0.25% of payroll.** Europe's wage guarantee funds are the closest match. Both are paid for by employers as a share of payroll:
- **Germany** pays up to three months of unpaid wages when an employer goes insolvent. Employers paid 0.06% of payroll in 2023 and 2024, and pay 0.15% in 2025 and 2026. After the financial crisis the rate jumped to 0.41% in 2010 ([lohn-info.de](https://www.lohn-info.de/insolvenzgeldumlage.html); [Haufe](https://www.haufe.de/id/beitrag/insolvenzgeldumlage-fuer-2026-HI17106952.html)).
- **France** covers wages and other amounts owed to workers, including severance, up to a ceiling. Employers pay 0.25% of each worker's pay, up to about €16,000 a month, in 2026 ([Service Public](https://entreprendre.service-public.gouv.fr/actualites/A17906)). In 2009 the rate rose from 0.10% to 0.20%, 0.30% and then 0.40% within a single year ([Eurojuris](https://www.eurojuris.fr/articles/augmentation-du-taux-de-cotisation-ags-au-1er-octobre-2009-10360.htm)).
Both are run or required by the state and spread across every employer in the country, so their rates are a floor, not a benchmark. This pool covers less, eight weeks of wages, but it covers young, small firms in a few industries, with nothing behind it. So it starts at 0.25% of payroll, plus a charge to build its reserve, and the actuary adjusts from there.
**Members' savings: probably 2% to 3% of covered amounts a year, and possibly much more.** When a co-op fails, members' accounts and unpaid payouts rank behind its lenders, so the pool should expect to pay almost the whole covered amount. What matters is how often covered co-ops fail owing more than they have. Three rough numbers bracket it:
- **Closures.** Of US business locations opened in 2013, about 61% were still open after three years, when account cover starts. After that, roughly 7% to 10% closed each year ([Bureau of Labor Statistics](https://www.bls.gov/bdm/us_age_naics_00_table7.txt)). Those are locations, not firms, so some are branches of businesses that carried on.
- **Not every closure is a failure.** In Census Bureau data, about a third of owners of closed businesses said the business was successful when it closed ([Headd, Census Bureau, 2001](https://ideas.repec.org/p/cen/wpaper/01-01.html)). That suggests something like 5% a year close unsuccessfully. But many "successful" closures were owners retiring or selling, which a locked co-op can't do, so for co-ops the real figure could be higher.
- **Loan defaults.** Among the SBA's mature loan groups, loans making up 6% to 10% of the money lent eventually defaulted ([GAO-20-618](https://www.gao.gov/products/gao-20-618)). Spread over the life of the loans, that's roughly 1% a year. That's close to a floor: members can lose their savings in failures where the lender still gets paid.
Worker co-ops may do better. The best study found them about 29% less likely to close, in Uruguay ([Burdín, 2014](https://journals.sagepub.com/doi/10.1177/001979391406700108)). But the pool shouldn't count on that until its own data shows it. So it starts at 2% to 3%: a strong co-op, with a large reserve and little debt, nearer the bottom, a weak one higher. These are starting guesses from rough data, and the pool publishes every year how its actual losses compare.
**That's expensive.** The protected slice of a current member's account earns only the plain Treasury rate. With the charge to build the reserve, the premium takes nearly all of that interest.
## Starting small
A few hundred co-ops, all started in the same few years, can't spread this risk well. They're all at the riskiest age, and one recession hits them together. So cover comes in stages:
- **From the start: final wages and former members' payouts,** for every covered co-op. These protect people with no say in how the co-op is run, and final wages are the biggest loss for low-paid workers.
- **Current members' slices come later,** once the pool has seen about 1,000 years of co-op experience (say, 200 co-ops for five years) and its savings reserve has reached its target. Then each co-op can choose it, but only in a yearly window, based on audited accounts, with cover starting two years later. The pool can refuse a co-op that's showing warning signs. A co-op that opts out can't opt back in for five years. That stops co-ops from joining only when they're in trouble.
- **Limits follow the reserve, not the calendar.** Every year, the limits are set by a published formula from the reserve the pool actually has, compared with what a bad recession would cost. If covered amounts grow faster than the reserve, the limits wait.
## How big the reserve has to be
Failures come in waves, and the waves last. US business bankruptcy filings ran about 22,000 a year in 2018 and 2019. In the last big recession they rose to about 43,500 in 2008, 60,800 in 2009, 56,300 in 2010, 47,800 in 2011 and 40,100 in 2012 ([American Bankruptcy Institute](https://abi-org.s3.amazonaws.com/Newsroom/Bankruptcy_Statistics/Total-Business-Consumer1980-Present.pdf); [US Courts](https://www.uscourts.gov/sites/default/files/statistics_import_dir/1209_f2.pdf)). Over those five years, that's about six normal years' worth of extra failures. And most small businesses that close never file for bankruptcy at all.
If savings claims normally run 2.5% of covered amounts, six extra years' worth is about 15%. So:
- **The target for savings cover is 15% of covered amounts,** not the 1.2% to 2% that government-backed funds aim for. Nothing stands behind this pool, so its reserve has to carry a long recession on its own. It doesn't count on reinsurance, which may not exist for this kind of risk.
- **For final wages, about 10% of covered wages,** by the same reckoning.
- **Building it takes time.** Premiums carry an extra point a year for savings cover, and an extra 0.1% of payroll for wage cover, until the targets are reached. For savings cover that takes about fifteen years if the pool doesn't grow, longer if it does, which is why limits follow the reserve.
- **Surcharges wait for the recovery.** After a bad stretch, premiums can rise by up to one point of covered amounts for up to three years. But the surcharge starts only once national unemployment has stopped rising, so it doesn't hit co-ops at the bottom of a recession. Until then, if claims outrun the reserve, everyone is paid the same share of what they're owed, as below.
- **Concentration is limited by limits, not refusals.** If one industry or one region grows past a set share of what's covered, cover limits for new additions there are lowered. No co-op is refused cover because of its industry.
**What it costs, in the standard example.** A mature 40-person co-op might have $300,000 of protected account slices (at most $8,000 a member, and less for members who take their share in cash), $200,000 of former members' payouts outstanding, and about $2 million a year of payroll, with eight weeks of it, about $300,000, covered by final-wage cover.
| Cover | Premium | A year |
| --- | --- | --- |
| Final wages | 0.25% of payroll, plus 0.1% to build the reserve | about $7,000 |
| Former members' payouts | 2%–3%, plus 1 point to build the reserve | $6,000–$8,000 |
| Current members' slices, once offered, if the co-op chooses | 2%–3%, plus 1 point | $9,000–$12,000 |
| **All three** | | **$22,000–$27,000** |
That's about 4% of the $600,000 annual profit in the [capital accounts example](capital-accounts.md#where-each-years-profit-goes), or about 2% without cover for current members. A strong co-op would pay less, a weak one more. Once the reserve reaches its target, the extra charges stop. The real rates will come from the pool's actuary and its own experience.
## Who runs it, and how claims are paid
- **A federal charter.** The pool is created by the same federal law as the bank, with a named federal regulator, capital standards, and a clause saying state insurance law doesn't apply to it. Otherwise it would need a license in every state, and federal law generally leaves insurance to the states unless Congress says otherwise.
- **An independent board.** Most of its members are independent (actuaries, former insurance regulators, people with no stake in any co-op), appointed by a body separate from the bank, for fixed staggered terms, and removable only for cause. A minority are elected by covered workers. None represent the co-ops that pay premiums, so the biggest co-ops can't keep their own premiums low at everyone else's expense. Board members step aside from anything involving their own co-op. Limits on premiums are written into the charter, and a co-op can appeal its rate to the independent adjudicator.
- **Checked every year.** An outside actuary publishes an opinion on the pool's reserves, and federal auditors audit it. The pool and the bank are stress-tested together, since the same recession would hit the bank's loans and the pool's claims at once. If the reserve falls below its target, the board publishes a plan to restore it within 90 days, and limits stop rising until it does.
- **One pool, one register.** Federations can run their own pools only once each meets the national pool's reserve ratio on its own, since pools of co-ops all lose in the same recession. There's one national register of everyone covered, so the per-person limits hold across pools.
- **Automatic payment.** When a covered co-op fails, the pool pays from the co-op's own member register and payroll records. Nobody has to know to file a claim. Fagor's former workers lost their case partly because they filed too late. Disputes go to an independent adjudicator. Final-wage payments are wages, so the pool or the liquidator pays them through payroll, with the usual withholding.
- **The pool steps into the workers' shoes.** Once it pays wages, it takes over the workers' priority claims in bankruptcy ([When a Locked Co-op Goes Bankrupt](bankruptcy.md)) and under federal and state notice and wage laws, so it recovers what it can. Workers are never paid twice for the same weeks. Some states can make a company's largest owners personally liable for unpaid wages. In a co-op, those are workers, so the pool doesn't pursue ordinary members, only directors who hid losses.
- **Stepping in early.** Every covered co-op sends the pool quarterly accounts. If warning signs appear, the pool can stop covering *new* additions to accounts until the co-op recovers, which stops members from piling up covered savings in a sinking firm. That freeze also applies to anyone who leaves after it starts, so members can't escape it by quitting.
## Closing the loopholes
- **No paying out phantom profits.** Before paying, the pool's own auditor or the liquidator rechecks the last three years of accounts, and for former members, the years since they left. Losses that were hidden or booked late are charged to accounts first. A covered balance can never be more than the allocations reported to the IRS.
- **No quitting to upgrade your cover.** A current member's slice is covered up to $8,000, and a former member's payout up to $50,000. So for the first three years after someone leaves, their payout is covered only like a current member's slice. Anyone who left before a failure has their balance recalculated to share the losses of the years since they left. Someone rehired within three years goes back to current-member cover. And if an unusual share of the co-op's accounts leaves in one year, those departures get only current-member cover.
- **No stuffing accounts.** Amounts added to accounts in the year before a failure are covered only up to the co-op's normal split, meaning its average over the previous five years.
- **No inflated final wages.** Covered pay is the lower of a worker's average over the previous 12 months and what appears in the co-op's payroll tax filings. Paid time off counts only at the co-op's normal accrual. In a co-op's first year, final-wage cover is four weeks, not eight.
- **No paying insiders first.** Loans that directors or members guaranteed personally, repaid in the year before a failure while wages went unpaid, are clawed back.
- **No cover for brand-new shells.** Account cover starts after a co-op's first three years of audited accounts, and every covered co-op must pass the same checks as bank borrowers: real members doing most of the work, verified against payroll records (see [Guarding the Pilot](pilot-safeguards.md#the-hidden-boss)).
- **No trading claims.** Covered claims can't be sold or transferred, except in a divorce or to an estate.
- **No underreporting.** Payouts are based on the lower of what the co-op reported and what it actually owed. Unpaid premiums are charged to directors and to uncovered balances, not to protected ones, and directors who file false reports are personally liable.
## What the pilot records
The pool files its pricing model before the first co-op is covered, and each year publishes its predicted losses against its actual ones. For every claim, it records the covered amount at failure, what was recovered, and how long payment took, by type of cover. It also publishes which co-ops deferred premiums and which chose current-member cover, and compares wage losses with those at the matched conventional firms in [the pilot](pilot-spec.md#who-co-ops-are-compared-against). That's how the next design gets real numbers instead of these.
## No selling savings as something they're not
Both Fagor and the Italian co-ops got into trouble partly because members' money was held in things that looked like bank deposits. So:
- **A locked co-op can't take savings deposits** from its members, or sell them bonds or other investments. A member's only money in the co-op is their capital account.
- **Every member gets a plain statement each year** of how much of their account is protected, how much isn't, and what the pool can and can't promise.
## What it can't promise
- **A wave of failures could outrun the pool.** Co-ops in the same region or industry tend to fail together, in the same recession. The reserve target is sized for a recession like 2009's, but not a worse one, and it takes years to build. If claims exceed what the pool has, it pays everyone the same share of what they're owed at once, then pays the rest as money comes in. That rule is published in advance, and members are told about it every year. This model gives the pool no government backing, so it can't promise more. **It's insurance, not a guarantee.**
- **It starts small.** It will be weakest in its early years. That's why coverage phases in, current members' slices come later, and account cover starts only after three years.
- **Members pay for it, and it isn't cheap.** Premiums come out of account interest and profit. Insuring current members' savings could take most of the interest on the protected slice, which is why co-ops can choose not to. That's the honest price of making savings safer.
- **Bankruptcy law may not cooperate.** Courts can push former members' claims to the back of the line, behind other creditors, which makes the pool's recoveries even smaller. That's another reason it assumes nothing comes back.
- **The legal form needs Congress.** Without the federal charter, a pool like this is insurance under state law, needing a license in every state. Whether insured capital accounts raise questions under securities law is also unsettled. Both are listed in [Open Problems](open-problems.md).
+104
View File
@@ -0,0 +1,104 @@
---
title: "After a Pass: How Big, How Fast"
section: Growing the Sector
order: 68
summary: Even if the pilot passes, a co-op sector big enough to change how other employers treat workers is decades away nationally, so growth comes in capped stages sized to audited demand, and the labor-market effect gets its own test in a few local markets.
---
# After a Pass: How Big, How Fast
The pilot tests one thing: can worker co-ops compete, survive and repay market-rate loans ([The Test](the-test.md))? That's the case for the bank, and it stands on its own: people get to govern their own workplaces, and the public gets its money back. The bigger hope is different. If co-ops become common enough, conventional employers would have to treat workers better or lose them to co-ops. That's the **labor-market effect**. The pilot can't test it, because about 400 co-ops employing perhaps several thousand people won't move anyone's wages.
This page works through what comes after a pass: how big the sector would have to get, how long that could take, how to grow without lowering standards, and how to find out whether the effect is real without waiting decades or promising it in advance.
## How big is big enough?
Nobody knows. But three kinds of evidence give a sense of it, and they don't all point the same way.
**When big retailers raised their own pay, other employers didn't follow.** From 2014 to 2023, large retailers like Amazon, Walmart and Target set their own company-wide minimum wages. Those covered over 3 million jobs. A study using payroll records for thousands of firms found the policies raised pay and cut turnover at the companies that adopted them. But the effect on other employers' pay, even ones that hired from the same workers, was "precise" and "economically negligible." The authors' explanation: the retailers mostly kept their workers longer rather than hiring more, and other employers could still find plenty of people ([Derenoncourt and Weil, 2024](https://www.nber.org/papers/w32546)).
An earlier version of the Amazon research did find that other employers raised pay too, and it's still widely quoted. Its own authors later found the result came from a flaw in their method, a statistical effect called mean reversion, and with revised methods they found no evidence of it ([Derenoncourt and others, revised 2022](https://www.nber.org/papers/w29425)).
**When one employer hired a lot in a tight market, others did follow.** The same study describes a different case. When Veterans Affairs hospitals raised pay for registered nurses, nurses' pay at nearby hospitals rose too. The authors think the difference is that nurses are a small, concentrated market, while low-wage retail work is not. So an employer that adds real demand for scarce workers can move others' pay, and one that just keeps its own workers longer mostly doesn't. New co-ops create new jobs, which puts them closer to the nurses' case, but only if they're a big enough share of the market.
**Unions once lifted pay for non-members, at much higher densities.** In 1977, about 22% of American private-sector workers were in unions. By 2015 it was 7%. One study estimates that if unions were still as common in each occupation and region as in 1977, non-union men would earn about 6% more, around $3,200 a year, and non-union women about $900 more. That's the "threat effect": employers paid more to keep unions out. It showed up where unions were dense in a particular occupation and region, not as a national average. The study compares places and times, so it can't prove cause and effect ([Denice and Rosenfeld, 2018](https://doi.org/10.15195/v5.a23)). And co-ops don't work the same way. A union threat happens inside your current job. A co-op only helps workers who can leave for one or buy their own workplace. So this suggests a scale, not a target: an effect like that came with about one private-sector worker in five.
**The biggest co-op sectors are still small shares.** Mondragon is the largest employer in the Basque Country ([Co-operative News](https://www.thenews.coop/mondragon-worker-co-op-federation-reports-sales-of-e11-213bn/)), and about 30,660 of its 70,000 workers were there in 2024, not all of them members ([Wikipedia, citing Mondragon's 2024 annual report](https://en.wikipedia.org/wiki/Mondragon_Corporation)). That's about 3% of the roughly 995,000 people working in the Basque Country ([Eustat, via Orain](https://orain.eus/es/actualidad/economia/2025/01/21/las-personas-ocupadas-bajan-0-2-y-tasa-de-paro-se-mantiene-en-7-1-pero-con-1100-parados-menos/)), after almost 70 years. France, with one of the best-supported worker co-op sectors in the world, counted 87,699 people working in its worker co-ops and a related form of community co-op in 2024 ([ESS et société, reporting CG Scop](https://www.ess-et-societe.net/Les-Scop-et-Scic-poursuivent-leur-croissance-avec-87-699-salaries-4-et-un-CA-de-10-2-milliards-d-EUR-6)).
So the honest answer: any effect is likely to show up locally, in particular jobs in particular places where co-ops are a big share, long before it shows up in national numbers. Whether it shows up at all is what the local test below is for.
## How long could it take?
America had about 136 million private-sector jobs in August 2026 ([Bureau of Labor Statistics](https://www.bls.gov/news.release/empsit.t17.htm)). One percent of that is about 1.36 million.
Nobody knows how many people work in American worker co-ops today. The sector's census counted about 751 co-ops in 2023, triple the number a decade earlier ([NCEO summary of the Democracy at Work Institute census](https://www.nceo.org/employee-ownership-blog/democracy-at-work-institute-releases-its-2023-worker-cooperative-state-of-the-sector-report)), but only 242 firms answered its latest survey, too few to add up workers ([US Federation of Worker Cooperatives](https://www.usworker.coop/census/)). So suppose, as a round assumption, that 20,000 people work in them by the pilot's year 12. To reach 1% or 3% of today's private jobs:
| If the sector grows by | 1% of private jobs in | 3% (Mondragon's Basque share) in |
| --- | --- | --- |
| 12% a year, about the pace at which the number of co-ops tripled over the last decade | about 37 years | about 47 years |
| 25% a year, twice that pace, every year | about 19 years | about 24 years |
These are minimums. The number of jobs keeps growing, so the target keeps moving. The 12% pace comes from the number of co-ops, not the number of workers. And fast growth is hard to sustain. Starting from fewer than 20,000 workers makes every figure longer.
So nationally, even a clear success likely means decades before co-ops are common enough to matter to most workers. That's the price of not forcing it, and I'd rather say so now than have someone discover it later.
## What fast growth has looked like
**Rural electric co-ops grew fast, in conditions this model doesn't have.** In the mid-1930s, nine in ten rural American homes had no electricity. By 1953, over 90% of farms had it. The federal Rural Electrification Administration lent to co-ops of farmers who wired the countryside themselves, and two years after it started there were already 350 co-op projects in 45 states ([EH.net](https://eh.net/encyclopedia/rural-electrification-administration/); [America's Electric Cooperatives](https://www.electric.coop/our-organization/history)). Two things made that speed possible. Private utilities had refused to serve those areas, so the co-ops had no competitors. And the loans were cheap: 25 years to repay, and from 1944 an interest rate fixed at 2%, whatever the market charged. This model has neither. Co-ops compete with established firms in every industry, and they pay market rates. The electric co-ops also carry a warning. They built defenders in almost every congressional district, and critics can fairly say those defenders kept cheap loans flowing long after the original job was done ([Making It Last](durability.md)).
**Employee ownership with tax breaks grew big, in a different form.** In 2023, about 6,400 American companies had employee stock ownership plans (ESOPs), with 10.9 million active participants ([NCEO](https://www.nceo.org/research/employee-ownership-by-the-numbers)). That's equal to roughly 8% of private-sector jobs, though participants aren't the same as jobs, and a plan can own only a small slice of its company. It's the biggest American case of ownership spreading through ordinary finance, and it took 50 years and large federal tax breaks. ESOPs don't give workers one member, one vote. I haven't found any study of whether they lift pay at other employers.
## Growing in stages
If the pilot passes, the charter sends Congress a fast-track vote on more capital ([The Pilot, in Numbers](pilot-spec.md#the-decision-rule)). Here's how the growth after that is meant to work:
- **Each stage is sized to audited demand, not a target.** The bank can ask for enough capital to fund the good applications it had to turn away for lack of money, plus at most 20%. Only unique applicants count, only those that met the bank's filed lending standards, and the count is certified by the independent reviewers, not the bank ([Making It Last](durability.md)). Delays don't count. There are still no lending or job targets. An institution with a target lowers its standards to hit it, which is what sank Venezuela's co-op push ([The Pilot, in Numbers](pilot-spec.md#two-warnings-from-real-life)).
- **Capped growth.** No stage can more than double the bank's capital. A bank that suddenly has to find ten times as many borrowers will lower its standards without meaning to.
- **Demand can't be starved, either.** Applications and approval rates are published against the pilot's own. A hostile administration can't cut outreach, tighten what counts as "good," and then claim nobody wants to borrow, because the reviewers already check a sample of rejected applications every year and publish how many met the standards anyway ([Making It Last](durability.md)).
- **The same rules at every stage.** Market rates, the same underwriting, the same limits by industry and county, and no federal guarantee on anything the bank borrows.
- **Growth follows the evidence for each kind of work.** Lending to co-ops in lower-paid work is sized from that group's own result. If the pilot had too few of them to tell, that lending can't grow faster than the rest ([Who the Bank Lends To](who-gets-loans.md#results-by-group)).
- **The same measures, published for every new group of co-ops.** Each later round of capital is its own vote in Congress, so every Congress sees how the newest co-ops are doing, not just the pilot's. A passed test shows that co-ops funded one way, at one size, could compete. It doesn't show the same is true at a larger volume, when the bank has to reach further for borrowers.
- **The full public cost, every year.** The bank publishes its all-in cost to taxpayers per co-op job: losses, running costs, all business help, and what the public's capital could have earned in Treasury bonds instead.
## Testing the labor-market effect
The pilot can't test it. But once the sector grows, it can be tested locally, without waiting decades and without deciding anything in advance.
The model is a French experiment that started in 2007. Researchers wanted to know whether job counseling for young unemployed people really created jobs or just moved them from one job seeker to another. They randomly chose what share of eligible people would get counseling in each of 235 local labor markets: 0%, 25%, 50%, 75% or 100%. The counseled did find stable jobs faster. But the gains were short-lived, and partly came at the expense of people in the same markets who weren't counseled, especially in weak markets ([Crépon and others, 2013](https://www.nber.org/papers/w18597)). A test of individuals alone would have missed that.
The same idea can test co-ops.
### Where
- **A few kinds of work** where the pilot showed co-ops can compete. At least one should be work where employers really set pay. In home care, for example, pay depends heavily on what Medicaid reimburses, so employers may not be free to respond. Where that's true, changes in reimbursement are measured and controlled for.
- **Local labor markets drawn by lottery,** from a pool whose rules are written into the charter: labor markets defined by commuting patterns, a set size range small enough that co-ops could plausibly become a large share of the work, and local governments that opt in. The draw is public, and its random seed is published in advance. Members of Congress can't add or remove places.
- **Different levels of help, not just yes or no,** as in the French experiment: some markets get none, some get a moderate level, and some get a high level of outreach and [help before the first loan](before-launch.md), including outreach to owners who might sell to their workers ([When an Owner Wants to Sell to the Workers](buyouts.md)). Each chosen market gets a minimum outreach budget, so a hostile administration can't quietly starve it.
### What doesn't change
**Lending.** Loans in the chosen markets go through the same underwriting, at the same rates, under the same limits by industry and county as everywhere else. Only the outreach and help are concentrated. If the concentration the test needs would break those limits, the limits win, and the test is smaller.
### What gets measured
- **What happens to co-ops.** Their share of the work in each market, with a minimum share filed in advance that counts as "reached."
- **What happens to conventional employers** in the same kind of work: pay, turnover, hours, and working conditions where records exist, like injuries.
- **Who gains.** Results by gender, race and ethnicity, and co-op membership compared with the local workforce. Most home care and cleaning workers are women, many of them Black, Latina or immigrant workers, and the union evidence found much smaller gains for women.
- **Whether co-ops actually let people in.** Co-op job openings, how long it takes new hires to become members, and the share of work done by non-members. A large co-op sector that doesn't hire is no outside option for anyone.
- **The costs.** Total jobs in the market, conventional firms closing, and prices where they can be measured, so that higher pay bought with lost jobs or higher prices shows up.
**The data.** Government wage records, like the Census Bureau's Quarterly Workforce Indicators, which report pay, hiring and turnover by industry, place, gender, race and ethnicity. The detailed records are used only inside secure federal research centers, and nothing is published that identifies a business. Co-op borrowers agree, as a loan condition, to have their records linked. Home care and cleaning include a lot of work that wage records miss, like independent contractors and households paying workers directly, so worker surveys fill in. An independent team named in advance does the analysis, and its data access can't be revoked by the administration. Pay is tracked for at least five years, because an incumbent could raise pay briefly to stop co-ops forming and cut it once they're gone.
### Filed in advance
The markets, the kinds of work, the measures, the analysis and a power calculation are filed before the draw. The power calculation states the smallest effect the test could detect. The goal is on the order of 100 markets. If the smallest detectable effect is bigger than any effect that's plausible, the test isn't run, because an expensive test that can't find anything isn't worth running.
**This test decides nothing by itself.** It isn't part of the pass-or-fail rule, which stays about whether co-ops can compete. Its results are published for voters and Congress to weigh. If co-ops never reach the filed share in the chosen markets, the result is "not tested." If they do and conventional employers don't change, the result is "no effect," and it's reported that way.
## What this doesn't solve
- **Nationally, it's slow.** Even a clear pass likely means decades before co-ops are common enough to change most workers' options. Nothing here speeds that up. Other policies, like sector-wide wage boards and support for unions, aim at the same goal faster. This model doesn't replace them.
- **The effect is unproven.** The closest evidence cuts both ways: big retailers paying more didn't lift others' pay, and hospitals hiring nurses did. Co-ops might differ from both, because they offer ownership and a vote, not just pay. That's what the local test is for.
- **The local test may be too small.** Even with about 100 markets, only a fairly large effect will show up. Co-ops may never reach the filed share anywhere. And the union evidence behind the scale estimate comes from comparing places, not from an experiment.
- **Concentrating help will be called unfair.** Conventional employers in the chosen markets will say the government is helping their competitors. The honest answer is that lending terms don't change, the help is time-limited and published, local governments opt in, and it's the only way to learn whether the effect is real. It won't stop the attack.
- **Growth can outrun judgment.** Audited demand, capped stages and published results are the defenses. None of them is airtight.
- **Success builds a lobby.** If the sector grows, its members will defend the bank, as the electric co-ops defended theirs. That's why growth depends on the published results and a vote each time, not on how many friends the bank has.
@@ -0,0 +1,18 @@
---
title: Startups
section: Growing the Sector
order: 90
summary: Fund the science, back the boring, make the co-op a real option for founders, and don't let government pick the startups.
---
# Startups
**One line: fund the science, backstop the boring, give founders a real co-op option. Don't pick the startups.**
Government should not be a venture capitalist. VC works by being wrong 70% of the time and letting two freak winners pay for everything. No public agency survives that headline rate. It would get cautious, fund safe bets and well-connected people, and safe bets don't produce breakthroughs. The failure mode isn't corruption; it's risk-aversion under political accountability.
So:
1. **Fund the science, not the companies.** DARPA and NIH gave us the internet, GPS, touchscreens, mRNA. The state buys the ingredients; a thousand private actors race to cook.
2. **Make the co-op a real option for founders.** A garage company is founders and early employees, with almost no separation between labor and ownership. The bank, internal capital accounts, and the federation exist so that choosing co-op form isn't choosing to be underfunded. Early-stage co-op capital will still cost more than conventional VC. That's [the founder problem](founder-problem.md), and [the test](the-test.md) will show how much it matters.
3. **Backstop the boring.** Rural grocery store, local clinic, unglamorous manufacturer. That's development banking, not venture, and government is genuinely good at it. Loan guarantees, SBA-style. These are also the sectors where co-ops have historically done best.
+131
View File
@@ -0,0 +1,131 @@
---
title: How a Locked Co-op Is Taxed
section: The Co-op
order: 25
summary: Federal tax law already lets worker co-ops pay tax on members' profit shares only once, but only if profit is shared by hours worked, so this page fits the model's rules to that law, says what it costs, and lists what the IRS or Congress still needs to confirm.
---
# How a Locked Co-op Is Taxed
This model relies on an old part of the federal tax code, called Subchapter T, that lets co-ops pass profit to their members and have it taxed only once. Get the details wrong and a co-op pays tax twice on the same profit, which would sink the test for reasons that have nothing to do with whether co-ops work. This page works through those details. It describes federal law as it stands in 2026. It isn't tax advice, and each co-op's bylaws need a tax lawyer's review.
## How co-ops are taxed today
An ordinary corporation pays 21% federal tax on its profit ([26 U.S.C. § 11](https://www.law.cornell.edu/uscode/text/26/11)). When it pays what's left to shareholders as dividends, they pay tax again.
A corporation run as a co-op can skip the first tax on the profit it pays back to the people it does business with, called **patronage dividends** ([§ 1382](https://www.law.cornell.edu/uscode/text/26/1382)). They pay income tax on them instead ([§ 1385](https://www.law.cornell.edu/uscode/text/26/1385)). To count, a patronage dividend has to be ([§ 1388](https://www.law.cornell.edu/uscode/text/26/1388)):
- **Paid according to how much business each person did with the co-op.** In a worker co-op, the "business" is work, and most US worker co-ops divide profit by hours worked ([Democracy at Work Institute guide](https://institute.coop/sites/default/files/resources/Internal-Capital-Accounts.pdf)). The Tax Court accepted a plywood co-op's split by hours in 1965 (*Puget Sound Plywood v. Commissioner*, 44 T.C. 305), though that case was decided under the law before Subchapter T. Today's practice rests on the IRS accepting it since.
- **Owed under rules set before the profit was made,** like bylaws adopted in advance.
- **Not taken from other people's share.** A patronage dividend can't include money from the business of people who got nothing, or got less for the same thing ([§ 1388(a)](https://www.law.cornell.edu/uscode/text/26/1388)). The Treasury's example: if a co-op pays nothing to its non-member customers, the part of members' dividends that came from business with them isn't a patronage dividend ([26 C.F.R. § 1.1388-1(a)(2)](https://www.ecfr.gov/current/title-26/chapter-I/subchapter-A/part-1/subject-group-ECFRc609157f1a908c0/section-1.1388-1)). That part is taxed twice.
Money kept in members' [capital accounts](capital-accounts.md) counts too, if at least 20% of each dividend is paid in cash and the member has agreed to be taxed on the rest now. A bylaw can make membership count as agreement, but only if each member gets a written notice and a copy of the bylaw ([§ 1388(c)](https://www.law.cornell.edu/uscode/text/26/1388)). With automatic membership ([When People Don't Stay Long](high-turnover.md)), that notice is handed over, and signed for, when someone joins. Everything has to be paid or credited within eight and a half months after the year ends.
**The reserve is taxed.** Profit the co-op keeps for itself isn't paid to anyone, so the co-op pays the 21% on it. US worker co-ops that keep a permanent reserve already work this way ([Democracy at Work Institute guide](https://institute.coop/sites/default/files/resources/Internal-Capital-Accounts.pdf)).
**In the standard example** from [Member Capital Accounts](capital-accounts.md#where-each-years-profit-goes), a co-op makes $600,000. The $360,000 paid to members is taxed once, on their own returns. The $180,000 for the reserve is taxed at 21%, about $37,800, so about $142,000 is actually saved. If the $60,000 paid to the federation fund can't be deducted, the tax is about $50,400. Whether it can depends on how the federation is set up (see below).
## Why a locked co-op is a corporation
Many small businesses are partnerships, or LLCs taxed as partnerships, which pay no business-level tax. That doesn't work here, for two reasons:
- **Partners can't be employees.** The IRS treats partners as self-employed, not as employees of their own partnership ([IRS, Publication 541](https://www.irs.gov/publications/p541); [IRS practice unit](https://www.irs.gov/pub/fatca/int_practice_units/self-employment-tax-partners.pdf)). [The pilot](pilot-spec.md#who-can-borrow) requires members to be on the payroll, so they have minimum wage and workers' compensation, and unemployment insurance in most states ([Owners Are Still Workers](labor-law.md)).
- **Partners are taxed on all the profit, including what's kept.** Members would owe tax each year on their share of a reserve they can never take out.
So a locked co-op is a corporation, or an LLC that chooses to be taxed as one ([26 C.F.R. § 301.7701-3](https://www.law.cornell.edu/cfr/text/26/301.7701-3)), and files the co-op version of the corporate tax return.
## Does a locked co-op count as a co-op?
Subchapter T applies only to a corporation "operating on a cooperative basis" ([§ 1381](https://www.law.cornell.edu/uscode/text/26/1381)). Courts and the IRS read that as three things: members control it democratically, capital doesn't control it or take its profits, and profit goes back to the people who did business with it, according to how much they did. If a co-op fails this test, it loses Subchapter T entirely, not just one dividend.
Two features of this model could strain the test:
- **The lock-holder.** An outside organization holds a share that can veto a sale ([The Lock](the-lock.md)). But that share carries no profit and no say in running the business. It only blocks the co-op from being sold.
- **The reserve goes to the federation, not the members, if the co-op closes.** Some US worker co-ops already write this into their bylaws, sending leftover assets to groups that support co-ops ([Democracy at Work Institute guide](https://institute.coop/sites/default/files/resources/Internal-Capital-Accounts.pdf)). But no ruling covers it for this model's co-ops.
This is the first question for the IRS, and for Congress (see [Getting certainty](#getting-certainty)).
## The model's rules, fitted to the law
### What counts as an hour
Profit shares follow hours, so hours have to be defined in advance, in the bylaws:
- **Hours paid**: hours worked, as wage law counts them, plus paid leave and state paid family leave.
- **Salaried staff count 40 hours a week.** Overtime counts once, not time and a half. No one counts more than 2,080 hours a year.
- **Only hours on the payroll records** that match the co-op's quarterly payroll tax filings. Time on committees and the board counts only up to a small share of a person's hours. The co-op's auditor checks a sample of timesheets each year.
Unpaid leave, even protected leave, isn't paid hours. Crediting hours nobody worked could mean paying one person out of another's work, which the rule above forbids. So a co-op that wants leave to count has to offer paid leave. Time on leave still counts toward the ramp, as [Worker Ownership](worker-ownership.md) promises.
### Wages first
Before any profit is shared, every worker is paid at least the going rate for their job for their hours ([Investing for the Long Run](the-long-run.md#the-rules)). If the year's wages fell short, the shortfall is paid first, as wages.
Why: members are employees. If a co-op paid itself low wages and large profit shares, the IRS could say the profit shares are really wages in disguise, and owe payroll tax (Social Security and Medicare) and penalties. That's the same argument the IRS uses against small-business owners who pay themselves too little salary. Paying the going rate first makes the profit share clearly profit. It also helps low-paid members: wages count toward the Earned Income Tax Credit and Social Security, and profit shares don't.
### One rate per hour, for everyone who works
The members' profit pool is divided by hours worked. Before the ramp, every hour earns the same amount, and "everyone's share is identical" means identical **per hour**. A part-timer who got the same check as a full-timer would be collecting from the full-timer's work.
**Non-members get a share too.** The tax law's word is "patron," not "member," so people on probation, and people who chose not to join, can receive patronage dividends. In this model they get the same share per hour as a member starting the ramp, all in cash. The rest of what their hours earned goes to the reserve, on top of the 30% minimum. The reserve still benefits the co-op, which members govern, but no member's own check comes from a non-member's work. Hiring non-members can't raise members' profit shares.
### The tenure ramp
The founding plan has a new member start at a partial profit share and reach a full one over five to ten years ([Worker Ownership](worker-ownership.md)). As first written, the part a new member didn't get went to the other members. The tax law doesn't allow that: a new member and a ten-year member who work the same hours are doing the same business with the co-op, so the ten-year member can't be paid out of the new member's work without that part being taxed twice.
**So the ramp's unpaid part goes to the reserve.** A member at half a share gets half the per-hour amount. The other half goes to the reserve, on top of the 30% minimum.
**What that costs, in the standard example.** Say the 40 members are 32 at a full share and 8 new members at half a share, all working the same hours. As first written, full members got $10,000 and new members $5,000. Now the $360,000 pool is $9,000 for each member's hours. Full members get $9,000. New members get $4,500, and the other $4,500 of each, $36,000 in all, goes to the reserve, which gets $216,000 instead of $180,000. If new members work fewer hours than the others, the full members lose less. In a co-op where few members are new, they lose little.
That changes what the ramp does. The founding plan's reason for it was to stop every new hire from cutting into everyone else's share. Now it doesn't do that: a new member's hours count in dividing the pool from the start. What the ramp still does is make new members earn their way to a full share, with the difference building up the co-op's own reserve instead of senior members' checks.
**Two other ways, both with costs.** A co-op can choose either in its bylaws, before the year starts:
- **Pay the ramp as a wage for years of service.** Everyone gets the per-hour profit share at the starting rate. Senior members get the rest as a pay supplement for years of service, which employers can pay and deduct. That keeps the founding plan's shield for senior members, but it costs payroll tax, about 15% split between the co-op and the member, on most of the profit share. And wages can't be held in capital accounts, so the accounts, and the money the co-op can reinvest from them, shrink.
- **Ask the IRS to accept the old result.** The unpaid part could count toward the reserve's 30% minimum, which lets the co-op put that much more into the members' pool, so senior members get what they did before. But then senior members gain exactly what new members give up, and the IRS could fairly say it's the old formula by another route. It's only an option if the IRS rules that it's allowed.
### Capital accounts
- **The usual way.** Members agree to be taxed now on what's credited to their accounts, and at least 20% is paid in cash. The model's default is half in cash, which covers the tax for most members.
- **Members with lower incomes can choose not to be taxed until they're paid.** The law allows a second kind of credit, a "nonqualified" notice. The co-op pays the 21% now and gets it back as a deduction when the account is paid out, and the member pays tax only when they receive the money ([§ 1382(b)(2)](https://www.law.cornell.edu/uscode/text/26/1382)). That's the default for members under twice the poverty line who leave money in their accounts. They can still take their whole share in cash instead ([Member Capital Accounts](capital-accounts.md)).
- **Charges come out of this year's share first.** Money members already paid tax on can be moved back to the reserve, under [the pay rule](the-long-run.md#the-rules) or when losses are charged to accounts. That's probably taxable to the co-op a second time, and the member may be able to deduct only part of it. So those charges come out of the current year's profit share first, before it's credited or taxed, and reach older balances only when that isn't enough. Credits to accounts that are likely to be charged later can be made as nonqualified notices.
## Payroll tax on profit shares
Wages carry payroll tax, about 15% in all, split between the co-op and the worker. Whether patronage dividends paid to worker-members do too is unsettled. The likelier challenge is that profit divided by hours worked is really wages. A second theory is that it's self-employment income. The IRS has claimed the second against members of at least one worker co-op, then backed down when challenged, without a published explanation ([Gregory Wilson, co-op tax lawyer](http://www.gwilson.com/documents/Article4(PatronageDivTax).pdf)). His argument: a member paid a fair wage through payroll is like a shareholder who works for the company, and a shareholder's dividends don't carry payroll tax. The wages-first rule is built to support that argument.
If the IRS wins, members pay about 15% more on their profit shares. Low-paid members would at least earn Social Security credit and Earned Income Tax Credit on it.
## Benefits
Profit shares count as income for Medicaid, health insurance subsidies, food assistance and housing aid, even when they're kept in an account. A lump sum can push a family over a limit for a month or a year. So:
- the lower-income defaults above keep account credits from counting until they're paid;
- anyone can ask for a payout from their account in up to 12 monthly payments, so one payment doesn't push them over a limit for savings, like the one for Supplemental Security Income;
- the federation offers benefits advice to every member, along with the templates and training it already supplies ([The Federation](federation.md)).
## Getting certainty
The IRS gives binding answers on specific facts, called private letter rulings, but only to the taxpayer who asks, only about its own real transactions, and never as precedent for anyone else ([26 U.S.C. § 6110(k)(3)](https://www.law.cornell.edu/uscode/text/26/6110)). A ruling costs a fee and often takes months, and the IRS can decline to give one. So:
- **The first pilot co-ops ask, and the bank pays.** They ask whether the co-op operates on a cooperative basis with the lock and the reserve, whether the per-hour profit shares and the ramp count as patronage dividends, whether profit shares carry payroll tax when wages meet the going rate, and how charges moved back to the reserve are taxed.
- **Until then, every co-op uses the rules on this page, and gets a tax lawyer's opinion on its bylaws.** None of them depends on a ruling to work.
- **Congress settles it.** The bank's own law needs Congress anyway, and so does [the bankruptcy amendment](bankruptcy.md). That law also says a locked co-op that meets these rules operates on a cooperative basis, and that its profit shares aren't wages for members paid at least the going rate. That's a statement of how existing law applies, not a lower tax rate.
## How this compares with other businesses
A co-op isn't taxed more lightly than other small businesses, and it isn't taxed much more heavily either:
- **Profit paid to members** is taxed once, at their own rates. Owners of partnerships and S corporations (small corporations taxed like partnerships) are taxed the same way on the profit they take.
- **Profit the co-op keeps** is taxed at 21% and never again, because nobody can ever take it out. A conventional corporation's kept profit is taxed at 21% and again when it's paid out. A partnership's or S corporation's owners are taxed on it at their own rates, which can be higher or lower, and they own it.
- **The difference that matters:** a conventional owner's kept profit is still theirs. The co-op's reserve belongs to no one, and it still pays tax. That's a cost of the lock, and this model doesn't ask for a tax break to cover it.
## What this doesn't solve
- **The ramp no longer shields senior members.** Under tax law, the founding plan's reason for the ramp works only through the reserve, through payroll tax, or with the IRS's blessing. Which is best is open.
- **Workers without work authorization can't be members.** Being on the payroll requires it. Some immigrant cleaning co-ops today are partnerships partly so members with only a taxpayer number can be owners. This model can't do that without giving up payroll protections, and it leaves those workers out.
- **Unpaid leave costs profit share.** Mostly for women, unless the co-op pays for leave.
- **States differ.** Most states follow the federal treatment of co-ops. But some tax a business's gross receipts, like Ohio and Washington, where the patronage deduction doesn't help. And some states' co-op laws may send leftover assets to members when a co-op closes, which would clash with the lock. Each pilot state needs checking.
- **The federation's form matters.** If the federation fund is a charity, a co-op's payments and a closing co-op's reserve are gifts, and corporations can deduct gifts only up to a limit ([§ 170(b)(2)](https://www.law.cornell.edu/uscode/text/26/170)). If it's a trade association, payments may be deductible as a business expense. The choice isn't made.
- **Interest on accounts may not be deductible.** The formula pays extra interest only in profitable years, which could make it look like a dividend on capital. If so, it's paid from taxed profit.
- **It's a lot of paperwork.** A co-op tax return, a patronage form for every worker, written notices, consent records, hours tracking, and the ramp. The federation provides standard software and filing help at cost, and the pilot's business help pays for setting it up.
@@ -0,0 +1,24 @@
---
title: Making the Case
section: Making the Case
order: 150
summary: Lead with "nobody is forced," point to the co-op banks that already exist, argue from freedom rather than dollars, and don't overclaim.
---
# Making the Case
**Lead with "nobody is forced."** The model takes nothing from anyone. It charters a bank, asks it to lend at market rates to a kind of firm other banks won't touch, and publishes the results. If co-ops can't compete, the experiment says so and it gets wound down. That is the most market-friendly proposal on the left, and it should be argued that way.
**The second line: "we already did this."** Congress chartered a cooperative bank in 1978. Mondragon built one in the 1950s. Italy has run a co-op development fund on member profits for decades. This is an old, tested idea aimed at a new target. The record, failures included, is in [Has This Been Tried?](precedents.md).
**Germany is now a supporting point, not the lead.** Co-determination puts workers on up to half the supervisory board of large firms, by law, and Germany is still a capitalist market economy. It shows ownership and governance rules are choices societies make, not laws of nature. But it's no longer needed to defend a mandate, because there isn't one.
**Don't lead with "they extract while workers starve."** It's the weakest version of the case, and it invites "the return is compensation for capital and risk," which wins the exchange. The strong version:
> *Bargaining power is lopsided. Workers have no realistic path to a claim on the enterprise they build. Capital markets don't fund firms that workers own. Fix the financing and let people choose.*
**Don't argue it in dollars.** The dollars framing ($150k/head, hungry kids) is exactly what leaves the model vulnerable to *"then just give them cash; a big enough UBI is simpler."* If ownership is only an *instrument* for moving money, a UBI substitutes for it.
It isn't. It's the goal. **People deserve to govern the thing they spend their life inside.** The objection is *domination*, not just poverty. That claim is about human freedom, it survives the UBI, and no amount of cash dissolves it.
**And don't overclaim.** The pilot proves business viability or it doesn't. Don't promise that it will reshape the labor market. Say that's what success at scale would do, and let the data earn the rest.
@@ -0,0 +1,28 @@
---
title: The Bank
section: Growing the Sector
order: 50
summary: A small public bank that lends only to worker co-ops, at market rates, with professional underwriting and published results.
---
# The Bank
**A small public investment bank that lends only to worker cooperatives.**
Why a bank and not a mandate: co-ops are rare, and there are two competing explanations. One says they're rare because capital markets won't fund firms whose owners can't sell equity to outsiders. The other says they're rare because they're genuinely harder to run. A bank that removes the financing barrier is the experiment that tells those apart. If co-ops were starved of capital, they'll grow. If they're hard to run, the portfolio will fail, and we'll have learned that honestly and cheaply.
Mondragon's history supports the first half of the premise. For decades, apart from some government job-creation money, its own bank, Caja Laboral, was its co-ops' only source of capital from outside their own members. But that bank also gave them cheap loans and intensive hands-on help, in a market protected by tariffs, so nobody can say which one mattered (see [Has This Been Tried?](precedents.md)).
**Design:**
- **Charter:** lends only to firms that meet the [co-op definition](worker-ownership.md) and accept [the lock](the-lock.md). The bank can't widen its own charter. Mondragon's bank drifted into ordinary lending after a change in the law, and America's co-op bank drifted toward the safest co-ops once nothing held it to its target (see [Has This Been Tried?](precedents.md)). A charter can't stop Congress from changing it, but the fixed measures and deadline in [The Test](the-test.md) mean a mission change can't happen quietly. Drift inside the charter, toward the safest co-ops, is handled by money set aside for co-ops in lower-paid work and results published by group ([Who the Bank Lends To](who-gets-loans.md)).
- **Priced at market rates.** This is deliberate. The bank's edge must be *willingness* to underwrite a firm structure other lenders refuse, not *cheap money*. If it lends below market, every result is unfalsifiable: success gets dismissed as "free state money," failure gets dismissed as "hobbled by the lock." At market rates, survival and returns mean something.
- **Professional, insulated underwriting.** Loan officers are bankers, not appointees. Lending decisions are walled off from legislators the way monetary policy is walled off from Congress. A bank that funds whichever co-op has the best-connected sponsor fails the experiment and deserves to. Since 2026 the Supreme Court has made clear that any president can replace a board like this one at will, so the protection lives in what the board can't change, not in who sits on it (see [Making It Last](durability.md)).
- **Self-sustaining by design.** One-time capitalization, then it lives on its loan book. Losses are public and published. A permanent appropriation would turn it into a subsidy line, and subsidy lines get captured.
- **Full transparency.** Portfolio data (default rates, survival, wages, productivity, member retention) published annually, so anyone can check the result, including critics.
The actual numbers (how much money, how many co-ops, loan limits, and how "market rate" is checked) are in [The Pilot, in Numbers](pilot-spec.md). The rules against sham co-ops and political favoritism are in [Guarding the Pilot](pilot-safeguards.md). Loans for owners who choose to sell their company to its workers run on a separate track, described in [When an Owner Wants to Sell to the Workers](buyouts.md).
**Precedent:** this has been tried, in pieces. Congress created a National Consumer Cooperative Bank in 1978, but its law was written for consumer co-ops, and after it was privatized in 1981 it became mainly a lender to housing co-ops. Italy's Coopfond, funded by 3% of co-op profits, has lent and invested in co-ops for three decades and still holds about 91% of what it collected. Italy's Marcora Law puts public money alongside workers' own to fund worker buyouts. The details, including how each went wrong, are in [Has This Been Tried?](precedents.md).
**And a warning:** Uruguay's public fund for worker-run firms, FONDES, lost almost 90% of its loan book within five years, mostly on rescues of already-failed firms with political connections. [The pilot](pilot-spec.md#two-warnings-from-real-life) is designed around that failure.
@@ -0,0 +1,32 @@
---
title: Ownership and the Floor
section: Beyond Co-ops
order: 110
summary: Ownership ends the skim, but a floor ends the hunger. Low-margin work needs both, and the floor can't wait for the co-op sector to grow.
---
# Ownership and the Floor
This is the correction that matters most, and it took the longest to see.
**Equal ownership within firms does not produce equal outcomes across them.**
A worker-owned cleaning company still operates on cleaning-company margins, maybe $8k of profit per head. A worker-owned software firm throws off $150k per head. Both workers are owners. One of them still can't feed their kids.
Profit-per-head is set by technology and demand elasticity, forces ownership rules don't reach. Cleaning productivity has barely moved in forty years: one person, one mop. Ownership of the surplus doesn't change how much surplus *exists*.
So:
- **Ownership ends the skim.** It is the right answer to *extraction*: a large surplus flowing to people who didn't make it.
- **A floor ends the hunger.** The home health aide, the childcare worker, the line cook: their problem was never that private equity was skimming their $150k. Their problem is there is no $150k.
You need both. They do different jobs. And because the model doesn't force anyone to convert, this section matters *more*, not less: the co-op sector will grow slowly, if it grows at all, and the floor doesn't wait for it. **One in six Utah kids goes hungry, and a lot of their parents work in sectors where there is barely any surplus to own.**
The floor, in pieces:
- A strong minimum wage.
- **Targeted subsidy for essential low-margin work, paid to workers, not firms.** Somebody has to clean the nursing home for the poor. If the sector can't pay, the public does, directly, so the buyer can't absorb the subsidy by bidding less. Who decides what counts as essential, and how to keep every low-wage industry from claiming it, is in [Who Decides What's Essential](essential-work.md).
- **Funded automation research aimed at exactly those sectors.** If productivity can't rise on its own, raise it. This is explicitly a *transition* strategy: support the workers until the technology lands. Say that out loud, or you end up permanently subsidizing a job you're simultaneously trying to eliminate, and the subsidized workers start lobbying against the automation. (This is what happened with coal.)
- **A transitional public temp corps.** Companies apply for subsidized labor. The state supplies temps and funds the automation in parallel. Everyone knows going in that the job has a sunset. Voluntary, real wages, open to anyone who wants a bridge.
**The endpoint:** a society where work is not the price of being allowed to eat. Where people who don't work are taken care of, and people who work are people who want *more*: the finer things, a bigger share, the reward for doing what's hard or what nobody else will do.
@@ -0,0 +1,94 @@
---
title: The Lock
section: The Co-op
order: 30
summary: Any co-op funded by the bank stays a co-op forever. It can't be sold to conventional owners or cashed out by its members.
---
# The Lock
**Any firm funded by the bank stays a worker co-op forever. It can never be bought, sold, or converted into anything else.**
The lock is what keeps a successful experiment from being quietly bought back. Without it, the best-performing co-ops are the likeliest takeover targets, and every success becomes an acquisition. The lock exists so that wins accumulate instead of being harvested.
What it covers:
- **No sale to a non-co-op.** A co-op may merge with or be absorbed by another locked co-op. It may never be sold to conventional owners.
- **No demutualization.** Members cannot vote to dissolve the co-op and split the value. (This is the failure mode that ended many US mutual savings banks and UK building societies: a generation of members cashed out what earlier generations built.)
- **No outside voting equity.** Outside money comes in only as voteless, expiring claims (see [Capital, After](capital.md)).
- **Membership requirement.** A locked co-op cannot run on hired non-member labor. Workers become members after a defined probationary period (at most 12 months), automatically unless they opt out. In jobs where most people leave within a year or two, that takes a shorter probation ([When People Don't Stay Long](high-turnover.md)). This blocks the "degeneration" pattern, where a co-op becomes a small ownership club employing a large wage-labor underclass. Mondragon's non-member foreign subsidiaries are the warning case.
- **Indivisible reserves.** A slice of every year's profit goes to a collective reserve no member can ever withdraw. On dissolution, after debts are paid, remaining assets go to the federation fund, not to the members. Italian co-op law already works this way. It removes the incentive to kill the co-op for its assets.
What the lock does *not* do: keep failing firms alive. A locked co-op can still go bankrupt, and its assets can still be sold in liquidation. It just can't be sold as a going concern to anyone but another co-op.
## Why "forever" isn't as strange as it sounds
A permanent lock sounds extreme. But plenty of organizations already live under one, and the law already knows how to handle them.
- **Britain's community interest companies.** Since 2005, UK law has offered a kind of company whose assets are locked to its purpose. Official guidance is blunt: "once a company is a CIC it cannot become an ordinary company" ([UK government guidance](https://www.gov.uk/government/publications/community-interest-companies-how-to-form-a-cic/community-interest-companies-guidance-chapters)). A dedicated government regulator enforces the lock. That hasn't scared people off. There were more than 37,000 of them in 2025, and a record 8,376 were approved that year ([CIC Regulator annual report, 2024–25](https://assets.publishing.service.gov.uk/media/68809f4f28f29c99778a7504/cic-25-01-community-interest-companies-annual-report-2024-2025.pdf)).
- **Companies that can't be sold.** Some well-known firms, including Bosch, Zeiss and the search engine Ecosia, are "steward-owned": set up so that control can't be sold to outsiders. Bosch and Zeiss do it through foundations. Ecosia uses a simpler device: an independent body holds a single "golden share" whose only job is to veto any change that breaks those rules ([Wikipedia](https://en.wikipedia.org/wiki/Steward-ownership)).
- **American nonprofits.** Assets given to a charity stay charitable. When Blue Cross of California moved its business into a for-profit company in the 1990s, state regulators made it hand over more than $3.2 billion to two new health foundations. Its first offer had been $100 million ([Community Catalyst, 2004](https://www.communitycatalyst.org/wp-content/uploads/2022/11/conversion_and_preservation_of_charitable_assets_of_blue_cross_and_blue_shield_plans_mar04.pdf)). Notice what happened there, though: the company *did* change form. What the law protected was the value.
There's one honest difference. The founders of a community interest company, or of Ecosia, chose their lock freely. Here the lock is the condition of a public loan. Founders still choose it, since nobody has to borrow from the bank, but they choose it as part of a deal.
**And the failure the lock prevents is real.** Britain's building societies were member-owned mortgage lenders. From 1989 on, several of the biggest (Abbey National, Halifax, Northern Rock, Bradford & Bingley and others) turned themselves into banks. They won their members' votes by paying out windfalls from reserves that earlier generations had built, and speculators opened small accounts just to collect. Every converted society ended up swallowed by a bigger bank or failed ([Co-operative News, 2014](https://www.thenews.coop/big-bang-demutualisation-building-societies-failed/)). Northern Rock, a building society until 1997, suffered Britain's first bank run in well over a century in 2007 and was nationalized the next year ([Wikipedia](https://en.wikipedia.org/wiki/Northern_Rock)). Those failures had many causes, and mutuals can fail too. But the windfall votes show exactly the incentive the lock removes: current members cashing out what their predecessors built.
## How the lock is made to hold
A lock written only into a co-op's own rules can be voted away by its members. A lock written only into federal law can be repealed by Congress. So it's built in layers, and each one is meant to hold if another fails.
1. **A lock share.** Each locked co-op issues one special share to an independent **lock-holder**. The share pays nothing. It votes on only four things: a sale or merger, a change to the lock clauses, a payout on dissolution, and a sale of a large part of the business (below). On those, it has a veto. It **can't approve** a sale to a conventional buyer, because nobody can. And it **must approve** anything that follows the lock, such as a merger with another locked co-op, so it can't hold good deals hostage. A refusal can be challenged in court. State corporate law already allows restrictions of this kind. Delaware, for example, lets a company require consent before its shares change hands, or bar transfers to designated kinds of buyers, as long as the restriction isn't "manifestly unreasonable" ([Delaware Code, title 8, § 202](https://delcode.delaware.gov/title8/c001/sc06/index.html)). This is the same device Ecosia uses.
2. **The co-op's founding documents.** The lock clauses are written into the articles, and they can be changed only with the lock share's consent. (Articles that simply claim they "can never be amended" may not hold up under state law. A required consent is what actually does the work.)
3. **The law.** The bank's authorizing statute makes the lock a condition of every loan and says it outlasts repayment. It also changes federal bankruptcy law for locked co-ops (see below). Where states are willing, the lock goes into state co-op law too (see [The Constitution](constitution.md)).
4. **A backstop that follows the money.** If a court or a later Congress ever breaks the lock and a co-op converts or is sold anyway, the reserves and any sale value above members' own account balances still go to the federation fund, not to the members. Italy works this way ([Has This Been Tried?](precedents.md#italys-co-op-funds-coopfond-and-the-marcora-law)). So even if the lock on the firm's form fails, nobody gets a windfall from breaking it.
**Who holds the lock share.** Each co-op picks its lock-holder from a list of approved ones: its [federation](federation.md), or an independent nonprofit lock trust with its own endowment and a successor named in case it ever shuts down. The rules keep the lock-holder honest:
- **It can't be a buyer, or benefit from a sale, in any deal it reviews.** If the co-op's federation might absorb the co-op, or would receive its assets on dissolution, a different lock-holder reviews that deal.
- **It isn't the only one who can enforce the lock.** Any member, any other locked co-op, the bank and the state attorney general can also go to court.
- **It publishes an annual review** of the co-ops it holds shares in.
- **It also guards membership.** Beyond its four votes, it has one more job: it takes workers' decisions to opt out of membership and checks each co-op's membership records, so that the membership requirement means something ([When People Don't Stay Long](high-turnover.md)). It has no say over who is hired, fired or paid what.
**Where the legal risk really is.** Courts are wary of "restraints on alienation": conditions that stop an owner selling property, especially forever ([Cornell Legal Information Institute](https://www.law.cornell.edu/wex/restraint_on_alienation)). For company shares, reasonable restrictions are generally allowed, so that risk is manageable. **The bigger threat is bankruptcy.** Federal bankruptcy law generally overrides contract terms that restrict transferring a bankrupt company's property ([11 U.S.C. § 541(c)(1)](https://www.law.cornell.edu/uscode/text/11/541)). Without a change to that law, a court-run sale could hand a failed co-op's whole business to any bidder, lock or no lock. So the bank's statute has to amend the Bankruptcy Code for locked co-ops. That needs Congress. [When a Locked Co-op Goes Bankrupt](bankruptcy.md) lays out the amendment, modeled on how bankruptcy law already treats charities.
**What about members who never agreed to it?** Nobody is forced to work at a locked co-op. The lock is explained in writing when someone is hired and again when they become a member. The trade is the same as joining a nonprofit: you get a vote and a share of the profits in a firm you didn't found, and you can't sell it.
## When a co-op needs to change hands
The lock stops a co-op being sold to conventional owners. It doesn't freeze it in place. A co-op can still:
- **Merge with or be bought by another locked co-op**, including a much bigger one.
- **Split off part of its business** as a new locked co-op, or sell that part to one.
- **Sell things it no longer needs**, like old equipment, a building or a patent it doesn't use, to anyone at a fair price. The gain goes to the reserve (see [Investing for the Long Run](the-long-run.md#the-rules)).
- **Take outside money without giving up control**, on voteless, expiring terms (see [Capital, After](capital.md)).
- **Get help from its federation**, which can lend to a struggling co-op or help find a co-op buyer (see [The Federation](federation.md)).
## Closing the back doors
A lock on the company is worthless if the business can walk out the side door. These rules are part of the lock itself, so they last as long as it does, not only while a bank loan is outstanding.
- **Selling the business piece by piece counts as selling it.** If a co-op sells, leases or licenses more than a quarter of its assets or its revenue-producing business within any three years, counted together, that's treated as a sale of the business, and the lock share can veto it. The same goes for the brand, the core technology, or the main customer contracts, whatever their size.
- **Buying the assets and hiring the workers counts too.** If a buyer of the co-op's assets hires more than a third of its members within two years, the deal is treated as a sale of the whole business after the fact. The buyer owes the federation fund the difference between what it paid and the value of the business as a going concern. People are always free to change jobs. This rule makes a buyer pay full price for a whole business, even if it picks the business up in pieces.
- **Subsidiaries are covered.** Any company a locked co-op controls is also locked, and its workers are covered by the membership rule. Otherwise a co-op could grow through an ordinary subsidiary and then sell it. That's the pattern behind Mondragon's non-member foreign subsidiaries.
- **No outside control through the back door.** The limits in [Capital, After](capital.md) (outside claims last at most 15 years, rank behind lenders, and carry no vote) apply forever, not just while a bank loan is outstanding. No investor, lender, supplier or licensee can hold a veto over the co-op's budget, hiring or management. No contract can give one outside party an exclusive claim on most of what the co-op produces for longer than five years.
## When a co-op goes bankrupt
Argentina changed its bankruptcy law in 2011 so that when a firm fails, its workers can form a co-op, keep the business running, and buy it, counting what they're owed toward the price ([Law 26.684, articles 189, 203 bis and 205](https://www.argentina.gob.ar/normativa/nacional/ley-26684-183856/texto)). This model borrows the idea, adapted to American law. The full rules, and the amendment to federal bankruptcy law they need, are in [When a Locked Co-op Goes Bankrupt](bankruptcy.md). In short:
- **Only co-ops can buy the business whole.** Its own workers (as a new locked co-op), the federation, or any other locked co-op whose members do most of its work can bid. It's sold by open bidding among them. The minimum price is what the assets would bring sold off separately, set by an appraiser the court appoints.
- **Workers can keep it running while it's sold,** and count the unpaid wages they choose to hand over toward their bid, up to the amounts bankruptcy law already puts first.
- **No buying back your own failure cheaply.** People who managed the co-op in the two years before it failed can't get bank financing to buy it back.
- **If no co-op buys it**, the assets are sold off separately to anyone. But no single buyer and its related companies may assemble enough of the pieces to add up to the business.
- **A reorganization can't hand over control.** If a locked co-op reorganizes in bankruptcy instead of closing, the plan can't give voting shares to anyone but members. Creditors who vote against the plan still get what the business would be worth on the open market.
**This does cost creditors something.** When only co-ops can bid on the whole business, the price may be lower than an open auction would fetch, and lenders will charge for that risk. Creditors who never chose to lend, like people the co-op injured, are paid the difference from the federation fund. That's part of the lock's price (below).
## What the lock costs
The lock has real costs, and they should be stated plainly.
- **Fewer buyers means lower prices, and sometimes a breakup.** When a failing co-op finds no co-op buyer, a business that a conventional buyer would have kept running may be sold off in pieces, and its workers lose their jobs. Lenders get back less, and they'll charge for it. The pilot publishes the bank's recovery rate on failed loans (see [The Pilot, in Numbers](pilot-spec.md#what-gets-measured)), so this cost shows up in the numbers.
- **While the sector is small, so is the pool of co-op buyers.**
- **Members can't take the firm's value with them.** That's the point of the lock, and it's also the heart of [the founder problem](founder-problem.md).
- **"Forever" is a long time.** Critics on the right argue that a permanent lock, bought with one loan, binds generations who never chose it. Italy takes a softer approach: a co-op can become an ordinary company, but its accumulated reserves go to the co-op movement's fund, not to the members. Whether this model should follow Italy is an open question (see [Open Problems](open-problems.md)).
@@ -0,0 +1,145 @@
---
title: Investing for the Long Run
section: The Co-op
order: 27
summary: Why economists expected worker co-ops to underinvest, what the evidence actually shows, and the rules that keep members who are about to leave from starving the firm, running it down or wasting its reserve.
---
# Investing for the Long Run
Picture a machinist who is 60 and plans to retire in three years. Her co-op is voting on a $2 million production line that will take four years to start paying off, then earn money for fifteen. Whichever way it's paid for, part of the cost comes out of the profits she shares in while she's still there, and almost all the benefit goes to people who work there after she's gone. Why would she vote yes?
That's the **horizon problem**: a member's stake in the firm lasts only as long as their job, but many good investments last much longer. In a conventional company the problem mostly doesn't arise. A shareholder who plans to sell next year still wants the company to invest well, because the share price reflects the future profits the investment will earn. A co-op member has no share to sell. [The lock](the-lock.md) makes sure of that.
This page explains why economists worried about this, what actually happens in real co-ops, and how this model handles it.
## The worry
In 1970 the economists Eirik Furubotn and Svetozar Pejovich, studying worker-run firms in Yugoslavia, argued that such firms would pay out too much and invest too little, because members can't take the value of an investment with them when they leave. The same year Jaroslav Vanek, one of the leading academic supporters of worker management, predicted underinvestment in firms that finance themselves from collective savings (both summarized in [Novkovic, *Journal of Socio-Economics*, 2008](https://www.theholmteam.ca/Novkovic-coopDifference2008.pdf)). Taken to its extreme, the prediction is that such firms slowly starve themselves of capital.
It's a serious argument, and the design has to answer it rather than hope it away.
## What actually happens
**The predicted underinvestment mostly doesn't show up in the data.**
- **France.** The largest comparison so far used two big, representative samples of French firms across many industries, including services. Worker co-ops were as productive as conventional firms or more so, used their equipment and labor efficiently, and weren't inefficiently small. Contrary to the textbook prediction, they grew as fast as conventional firms or faster ([Fakhfakh, Pérotin and Gago, *ILR Review*, 2012](https://ideas.repec.org/a/sae/ilrrev/v65y2012i4p847-879.html)).
- **Italy.** A study of a large panel of Italian worker and social co-ops tested the prediction directly. It found the opposite of what the theory expects: the more of a co-op's capital was held in collective reserves that no member can withdraw, the *more* productive it was ([George, Fontanari and Tortia, 2019](https://ideas.repec.org/p/trn/utwprg/2019-20.html)).
**But there's a catch, and it matters.** These co-ops don't get to choose freely. Italian law requires every co-op to put at least 30% of each year's profit into a reserve that members can never take out ([Italian Civil Code, art. 2545-quater](https://www.brocardi.it/codice-civile/libro-quinto/titolo-vi/capo-i/sezione-iv/art2545quater.html); [Lomuscio and Tortia, 2023](https://pdfs.semanticscholar.org/a7eb/90b586cb2adb63c321f1ca616afefb27eb70.pdf)). Mondragon did the same thing by its own rules. So the honest reading isn't "the horizon problem is a myth." It's "**a required reserve seems to be enough to keep it in check.**"
Mondragon's history shows the pressure is real. When its co-ops set their own reserve levels, the share of profit going into reserves swung from 35–53% in the 1960s down to 11–29% in the 1970s. One co-op, Alecoop, which employs students who leave every three years, faced losing its capital as each group took its money out, so it raised its reserve share to 60%. In 1989 Mondragon raised the minimum reserve for all its co-ops from 20% to 50% of profit ([Morris, *The Mondragón System*, 1992](https://www.ilsr.org/wp-content/uploads/2013/03/mondragon-3.pdf)). Where the horizon pressure bit, the answer was a bigger required reserve.
## The fix that backfired: selling your place
There is an obvious way to solve the horizon problem completely: let members sell their membership when they leave. Then, like a shareholder, a retiring member benefits from every good investment, because it raises the price of their place.
The plywood co-ops of the Pacific Northwest did exactly this. Each member owned one share, and shares could be sold to new members. For decades they were among the most durable worker-owned firms in American manufacturing. Economists who later studied the share prices found membership had been extremely profitable ([Craig and Pencavel, *American Economic Review*, 1992](https://ideas.repec.org/a/aea/aecrev/v82y1992i5p1083-105.html)).
Then success worked against them. Workers nearing retirement found their shares had risen from $1,000–2,000 to $25,000, $50,000 or more. Young workers couldn't afford to buy in, so retiring members sold to conventional lumber companies instead ([Lindenfeld and Wynn, 1995](https://geo.coop/articles/why-some-worker-co-ops-succeed-while-others-fail)). Observers called it "the failure of success." It wasn't the only cause of their decline. The whole plywood industry moved south from the 1960s on, and many conventional mills in the Northwest closed too ([Pencavel, lecture notes on worker co-ops, 2018](https://cleo.rutgers.edu/wp-content/uploads/2020/03/John-Pencavel-Notes-for-Students_WorkerCoops2018A.pdf)). But the co-ops that were sold while still profitable were sold because their members could sell them.
The retiring plywood workers weren't doing anything wrong. They were making a free choice with property they legally owned. This model simply doesn't create that property. A tradable membership turns the co-op back into something owned and sold, which is exactly the claim [the thesis](thesis.md) says this kind of firm exists to prevent. And it prices the next generation of workers out, which is how the plywood mills ended up being sold.
## Who pays for an investment, and when
The horizon problem bites when the members voting today pay for an investment whose benefits arrive after they leave. How much it bites depends on where the money comes from.
**1. The reserve.** At least 30% of every year's profit goes into the co-op's permanent reserve (see [Member Capital Accounts](capital-accounts.md#where-each-years-profit-goes)). No current member can ever take that money home, so paying for the line this way doesn't cut our machinist's share this year. (The line's cost still counts against profit a little each year, as point 3 explains.) The reserve is money that already belongs to the co-op's future, and it gets spent on the co-op's future.
**2. Members' accounts.** Money members leave in their [capital accounts](capital-accounts.md) isn't lost when they leave. It's paid back to them with interest. So if the co-op keeps more of this year's profit in members' accounts to pay for the new line, the machinist gets her money back, by default five years later, whether or not she's still there. Her real cost is the wait, and the risk that the co-op fails. Five years is shorter than most machines last, so for long-lived investments this helps less than it would if accounts were held until members left. Federal pension law is why they aren't ([Member Capital Accounts](capital-accounts.md#why-not-pay-it-out-when-you-leave)).
**3. Spreading the cost over time.** A business doesn't count the whole price of a machine as a cost in the year it's bought. It spreads the cost over the machine's working life. A $2 million line that lasts 20 years counts as $100,000 of cost a year. This is called *depreciation*, and it's how ordinary accounting already works. The profit split is based on profit after depreciation, so the machinist isn't charged for the years after she leaves. The bank does the same with repayment: loans for buildings and long-lived equipment are paid back over the asset's working life, up to 25 years for real estate, the same limit the SBA's main loan program uses ([SBA, types of 7(a) loans](https://www.sba.gov/partners/lenders/7a-loan-program/types-7a-loans)).
**This doesn't fix everything.** Depreciation spreads the cost evenly, but the payoff isn't even. If the line takes four years to start earning, the machinist is charged for three years of it and sees none of the benefit. That's the residual horizon problem, and it's why the reserve matters so much. The investments with the slowest payoff are the ones the reserve should pay for.
## Where the gaps are
That leaves four ways the horizon problem can still get in.
- **Paying it out as salaries.** The reserve rule applies to *profit*, and members set their own salaries. A co-op could raise everyone's pay until there's no profit left, and the reserve would get nothing. Newer members have an extra reason to like this: their profit share starts small and grows with tenure, but their salary is paid in full from day one.
- **Pay that isn't salary.** Pensions promised for the future, retiree health cover, generous severance. These push costs onto whoever works there later.
- **Selling off the furniture.** The lock stops anyone selling the *firm*, but not its assets. Members about to leave could sell the building, book the gain as profit, split it, and leave their successors paying rent.
- **Development that pays off late.** Designing a new product or building software costs money now and pays later, but it's usually counted as a cost in the year it's spent. So the members voting today pay for all of it.
## The rules
**1. The reserve's share applies to pay above the going rate, too.** This isn't a cap on wages. Members can pay themselves whatever they decide. But the reserve takes its 30% of everything members receive above the going rate, whether it's called profit or pay.
- **The going rate for each job** is the higher of two numbers: the median pay for that occupation in the co-op's own metro area, from the government's wage survey (the Bureau of Labor Statistics' [Occupational Employment and Wage Statistics](https://www.bls.gov/oes/)), or the local living wage for a single adult working full time, from [MIT's Living Wage Calculator](https://livingwage.mit.edu/). Both are measured per hour, so part-time work and consultants are compared fairly. The living-wage floor matters. In home care the median wage can be close to poverty pay, and a co-op that raises its aides to a living wage shouldn't owe anything for doing it.
- **Why a single adult's living wage.** In Salt Lake City in 2026, MIT puts the living wage at $24.64 an hour for a single adult and $41.55 for one adult supporting a child. An earlier version of this rule let pay up to one and a half times the second figure, about $130,000 a year, count as "raising the bottom." That would have let a co-op pay every cleaner six figures without the reserve getting anything. The single-adult figure, about $51,000 a year there, is still well above what most aides earn. Paying more than that is allowed. The reserve just takes its 30% of the difference.
- **It's checked job by job.** Paying one group below the going rate doesn't make room to pay another group above it. An outside reviewer, the co-op's auditor or the bank's, assigns each person the occupation code for the work they spend most of their hours on, not their title, and the codes are shown to members next to each job. Where the survey has no figure for the metro area, the state figure is used, then the national one.
- **It counts total pay.** Employer retirement contributions above 6% of pay, bonuses, severance beyond two weeks' pay per year worked (up to 26 weeks), and perks like a car, housing or loans to members, valued at cost, all count as pay. So does pay to non-members and to people working through a related company, and the value of any deal with a member or their family on better than market terms, like buying from a relative's firm above the market price. Co-ops can't promise pensions or retiree benefits they don't pay for in full each year.
- **How it's settled.** Thirty percent of the excess goes to the reserve, first out of the member's profit share for that year, before it's paid or credited, and then out of their [capital account](capital-accounts.md). No new cash has to be found, because the members already took that money as pay. Taking it from this year's share first avoids a second round of tax on money already credited ([How a Locked Co-op Is Taxed](taxes.md#capital-accounts)). If that isn't enough, the rest comes out of their future account credits, or out of their payout when they leave. For pay to non-members, it comes out of that year's members' profit pool.
- **There's a precedent.** US tax law already treats salaries that aren't "reasonable" as disguised profit ([26 U.S.C. § 162(a)(1)](https://www.law.cornell.edu/uscode/text/26/162)), so owners can't dodge tax by calling profit pay. This rule stops the same disguise being used to dodge the reserve. The tax test is judged case by case. This one is a fixed formula, so it's cruder but much harder to argue about.
**2. Gains from selling assets go to the reserve.** If a co-op sells a building, equipment, a brand or a patent for more than its value on the books, the whole gain goes to the reserve, not into the profit split. A sale to an insider, or a sale-and-lease-back, is checked like any [insider deal](pilot-safeguards.md#the-hidden-boss).
**3. Development projects can be spread out, like machines.** A co-op can designate a specific development project *before* it starts spending on it: designing a new product or a better production process, or building software. For the profit split only, its cost is then spread over up to five years, the same way a machine's is.
- **Only real development qualifies.** It has to meet the test international accounting standards already use to decide when development creates a real asset: it's technically doable, the co-op intends to finish it and can, and it's likely to earn money ([IAS 38](https://www.ifrs.org/issued-standards/list-of-standards/ias-38-intangible-assets/)). Those standards specifically exclude training, advertising and start-up costs, and so does this rule. Those stay costs of the year they're spent.
- **The people who chose it carry the risk.** If a project is abandoned or written down within five years, the cost that hasn't yet been charged comes out of the capital accounts of the people who were members when it was designated, including former members whose payouts haven't been made yet. Each credit waits three to seven years before it's paid, so much of the money is usually still there. That makes the rule useless for pushing costs onto the next group of workers.
- **Limits.** The total cost still waiting to be charged can't exceed half of the co-op's average profit over the last three years, measured before any spreading. Only co-ops with an independent audit can use the rule, and the auditor checks each open project every year.
- **It only changes how costs are divided between this year's members and future ones.** It doesn't touch the co-op's tax return or its reports to the bank. The co-op publishes both numbers side by side.
**4. Accounts earn a fair rate while they're at risk.** A current member's account earns the one-year Treasury rate plus two percentage points, credited in years the co-op makes a profit. In a loss year it earns the plain Treasury rate. Money in a current member's account is at risk: it shares in the co-op's losses and ranks behind its lenders. Paying only a risk-free rate on it would push every member to take cash instead. **Once a member leaves, the balance earns the plain Treasury rate.** It no longer shares in losses, so it's no longer at risk, and nobody should collect a premium from the work of people they no longer work alongside. For comparison, Italian law lets co-ops pay members up to 2.5 points above the rate on its postal savings bonds on the capital they've put in (Lomuscio and Tortia, 2023), and Mondragon paid 6% plus an adjustment for inflation (Morris, 1992). The details are on the [capital accounts page](capital-accounts.md#how-the-account-grows).
**5. The reserve doesn't pay for high pay in a loss year.** Mondragon calls its members' pay *anticipos*, advances on profits ([Morris, 1992](https://www.ilsr.org/wp-content/uploads/2013/03/mondragon-3.pdf)). This model treats the part of pay above the going rate the same way: as an advance against profit that hasn't been earned yet.
- **If the year ends in a loss,** the part of the loss up to the pay above the going rate, in that year and the year before, is charged to the people who got that pay, in proportion to how much each got. It comes out of their [capital accounts](capital-accounts.md), and out of their payouts if they've left since. The 30% rule 1 already moved from their accounts counts toward it, so nobody pays twice. The rest of the loss is shared as usual.
- **An example.** A co-op loses $50,000. Its managers were paid $30,000 above the going rate that year, and rule 1 already moved $9,000 of that into the reserve. The managers' accounts are charged $21,000 more. The other $20,000 of the loss is shared between the reserve and all members' accounts in the usual way.
- **What their accounts can't cover** comes out of their future profit shares, above the cash they need for tax, and nobody's above-going-rate pay can rise until it's repaid. After three years, anything still owed is written off.
- **Pay above the going rate to non-members** is charged to all members in proportion to their profit shares, since members chose to pay it. Members whose household income is under twice the poverty line are left out, and so is the insured slice of anyone's account.
- **It never touches pay already earned.** Wages and bonuses already paid or earned stay paid. The charge falls on savings in the co-op, not paychecks.
Without this rule, members could vote themselves high pay, run the co-op at a loss, and let the losses eat the reserve once their own accounts ran dry. That would be withdrawing the reserve by another name, which [the lock](the-lock.md) forbids. The cost is real too. In an honest bad year, the best-paid people lose some savings, and some may leave.
## When members stop investing, or invest badly
The horizon problem has two mirror images. Members who are leaving soon might let the co-op run down. And because the reserve can never be paid out, members might spend it on expansion that doesn't pay.
### Running it down
Picture the worst case: a co-op whose members are mostly near retirement. What could they gain by letting it run down?
- **Pay can't drain the reserve** (rules 1 and 5), **and neither can selling off assets** (rule 2).
- **Closing doesn't pay.** If the co-op dissolves, members get their own account balances, which they'd get anyway, and everything else goes to the [federation fund](federation.md) ([The Lock](the-lock.md)). Nobody gets a windfall from shutting a working firm.
- **The co-op can stop hiring and shrink as people leave.** But workers it does hire join after probation, and members must do at least three-quarters of the hours ([When People Don't Stay Long](high-turnover.md)). It can't replace its members with cheaper employees.
That still leaves two real ways to run it down, and both come from how accounting works.
- **Skipped upkeep.** Repairs count as a cost in the year they're done, so skipping them raises this year's profit, and members share it.
- **Machines that are "paid for."** Accounting spreads a machine's cost over its expected life, its *depreciation*. Once that time is up, the machine stops counting as a cost, even if it's still running. If nobody replaces it, profit looks higher every year, and members split the difference. That's not savings. It's the machine wearing out.
**6. Worn-out equipment keeps being charged.** For the profit split only, buildings and equipment that are fully written off but still in use keep being charged at their last yearly rate, adjusted for inflation. That money goes to the reserve, not the split. So a co-op running on old machines builds up the money to replace them, instead of paying it out. Co-ops whose buildings and equipment are worth less than a tenth of their yearly revenue, like most service firms, can skip this.
There's no fair way to set how much every business should spend on upkeep, so this model doesn't try. Instead there's a warning light:
- **Every year, the co-op shows members how much it spent on new buildings and equipment**, including new leases, next to how much its existing ones wore out that year, adjusted for inflation.
- **If spending stays below wear for three years running, an independent inspector reports** on the condition of the main buildings and equipment. The lock-holder picks the inspector and the co-op pays. The report goes to every member, and to the bank if it's still owed money. The first five years after a buyout or conversion don't count, because buying a business makes its old equipment look like it's wearing out fast.
- **A co-op that's shrinking on purpose,** because its market is, says so in a written plan its members vote on. That's a warning light, not a spending order.
### Spending it badly
Critics worry that money members can never take home will feel free, and get wasted on expansion. Fagor, Mondragon's appliance maker, expanded across Europe and beyond, with plants in Poland, France, Morocco and China, much of it on borrowed money. Then Spain's housing crash took away its main market, and it went bankrupt in 2013.
The danger is real, but reserve money isn't free to members.
- **It earns money when it's not spent.** Reserve money that isn't used earns interest, and members share in that interest. Spending it on a project that earns less makes their share smaller. They only get about 60% of that interest, so it's cheaper to them than it should be. It isn't free.
- **Failures come partly out of members' own accounts.** When a project fails, its value on the books is cut, and that counts as a loss. Losses are shared between the reserve and members' accounts in the same proportions as profit ([Member Capital Accounts](capital-accounts.md#how-the-account-grows)). The federation's share doesn't take losses, so members pay about 60 of every 90 dollars, two thirds. That holds only while they're still there, and only up to what their accounts hold, which is capped at a year's pay.
**Fagor wasn't mainly a reserve problem.** It owed about €800 million (about $1.1 billion) when it filed for bankruptcy ([The Local, 2013](https://www.thelocal.es/20131114/major-manufacturer-fagor-files-for-bankruptcy)). A study based on 44 interviews found that Fagor kept units losing millions of euros a year open well before the 2008 crisis. Its elected board often couldn't follow the business plans managers brought it, so it approved them and fired the managers when they failed ([Basterretxea, Cornforth and Heras-Saizarbitoria, 2022](https://journals.sagepub.com/doi/abs/10.1177/0143831X19899474), summarized by the authors in [GEO](https://geo.coop/articles/failure-cooperative-governance)). The Mondragon group had already put €70 million into Fagor from its shared fund, and other co-ops had lent more. In October 2013, when Fagor asked for €170 million more, the group's council refused unanimously, saying the money "would not ensure its business future" ([GEO, 2013](https://geo.coop/story/major-mondragon-co-operative-faltering)).
The risks that remain are the ones Fagor shows: big bets that members can't judge, losses that show up after the people who voted have left, and help from other co-ops that keeps a failing business going. So:
- **A second opinion on big commitments.** Some commitments need an independent written opinion before members vote: a single investment, a purchase of another business, or borrowing, including leases and guarantees, bigger than a quarter of the co-op's own capital (its reserve plus members' accounts) or $250,000, whichever is larger. Related commitments within two years are added together. The lock-holder picks the adviser from an approved list, and the co-op pays. A lender to the project can't give the opinion. The opinion comes with a one-page plain summary, including what the plan means for members' jobs, and there's a members' meeting to ask questions before the vote. It's advice, not a veto. The co-op publishes it with the result of the vote, and three years later reports how the project did against the plan. Replacing worn-out equipment like for like, and loans the bank has already checked, don't need it. [Spin-offs](growth.md) get a similar opinion at a lower threshold.
- **Losing parts of the business are visible.** Where a co-op has separate lines of business, each one reports its own results to members every year ([Does Sharing the Profit Mean Less Effort?](free-riding.md#small-teams-open-books)). A line losing money for years can't hide inside the total.
- **A board that can follow the plans.** The federation offers training in reading the accounts, on paid time and open to every member, not just the board. The standard bylaws let members elect outside directors, from other co-ops or with business experience, but never more than a quarter of the board. The members choose them and can remove them.
- **Limits on propping up a firm, decided in advance.** The [federation's fund](federation.md) helps a co-op through a bad year. Help to keep a firm going comes with a recovery plan and a limit set before any crisis, and cuts to members' pay or jobs in that plan need the members' vote. Help for the *workers*, like finding them jobs in other co-ops if the firm closes, has no such limit. Mondragon kept supporting Fagor and then said no. It's better to agree on where the line is before anyone's firm is on the wrong side of it.
## What's still open
- **Upkeep.** Nothing forces a co-op to maintain what it has. The warning light shows when upkeep falls behind, but members can still ignore it.
- **Late failures.** A big project that fails after the members who chose it have left is paid for by the people who came after. The second opinion and the three-year report make bad bets less likely and more visible. They don't move the cost back to the people who made them.
- **Deals with insiders after the loan.** The check on deals with insiders is a condition of the bank's loan ([The Hidden Boss](pilot-safeguards.md#the-hidden-boss)). Rule 1 now counts a sweetheart deal with a member as pay, but who checks for them once the loan is repaid isn't settled.
- **Whether 30% is enough.** Italy requires 30%. Mondragon found 20% too low and moved to 50%. This model uses 30% as a minimum that each co-op can raise. [The pilot](pilot-spec.md#what-gets-measured) measures investment per worker, and the return it earns, against matched conventional firms. So if co-ops invest too little, or waste what they invest, it will show.
- **Is the account rate right?** Two points above Treasury is well below what investors expect from a small business, so members may still prefer cash. Paying the co-op's own return on its assets instead would reward good investment directly, but it would also make the account more volatile. And lower-income members, who can take their whole share as cash, don't get the two points at all. That's fair only if the two points are a fair price for the risk the account carries, not a bonus. Which is better is unsettled.
@@ -0,0 +1,18 @@
---
title: The Test
section: Growing the Sector
order: 60
summary: The bank runs as an experiment: metrics set in advance, a fixed review date, and a scale-up or wind-down rule nobody can change after seeing the data.
---
# The Test
The bank is an experiment, so it runs like one.
- **Pre-register the metrics before the first loan:** survival rates, productivity, wages, member retention, and loan performance, measured against matched conventional firms in the same sectors and regions.
- **Fixed review point.** After a set period (long enough to see business cycles), an independent review publishes the results. The pilot sets it at year 12.
- **Scale-up or wind-down, decided in advance.** If co-ops match or beat conventional firms at market-rate lending, the bank's capital is expanded. If they don't, it's wound down and the loan book is sold. Both outcomes are written into the charter from day one, so nobody can move the goalposts after seeing the data. That includes me.
[The Pilot, in Numbers](pilot-spec.md) spells all of this out: the exact measures, the comparison group, the pass and fail thresholds, and an emergency stop.
**What the test can prove, and what it can't.** At pilot scale, this tests *business viability*: can co-ops compete, survive, and repay market-rate debt? It does **not** test the labor-market effect, meaning the idea that as co-ops grow, conventional firms have to treat workers better or lose them. That effect is real in principle, but it needs a co-op sector big enough to move wages at the margin. Mondragon employs tens of thousands of people and hasn't reshaped Spanish employment practice. The labor-market effect is the long-run payoff of success, not something the pilot can claim. [After a Pass: How Big, How Fast](scale.md) works through how big the sector would need to get, and how the effect could be tested in a few local markets once it grows.
+18
View File
@@ -0,0 +1,18 @@
---
title: The Thesis
section: Foundations
order: 10
summary: Personal property is untouchable. Productive capital, a permanent claim on other people's work, is a different thing, and there should be firms where that claim never forms.
---
# The Thesis
**The word "property" protects two very different things, and only one of them deserves that protection.**
There is *personal property*: your house, your car, your savings, your tools. The things you live in and use. These are untouchable. Nobody is collectivizing your couch.
Then there is *productive capital*: a perpetual, tradeable claim on the output of a thousand people you will never meet. This is not the same kind of right. It has been wearing the same word by accident, and the accident does political work for the people who benefit from it.
The dividing line: **it stops being a tool and becomes capital when it earns you a claim on other people's labor that you don't work alongside.** The plumber's van is not extracting from anyone. TPG's stake in Conservice is. (What that company would look like as a co-op is worked through in [What If Conservice Had Been a Co-op?](conservice.md).)
What follows from the thesis is not "therefore the state may take that capital." It is something narrower and harder to argue with: **therefore there should be firms where that claim never forms in the first place, and nothing in law or finance should stop people from building them.**
@@ -0,0 +1,112 @@
---
title: Co-ops or Their Founders?
section: Growing the Sector
order: 65
summary: If the pilot fails, the first question will be whether co-ops failed or the people who started them had less going for them, so founders on both sides are measured from government records and the verdict has to say how much that explains.
---
# Co-ops or Their Founders?
[The Founder Problem](founder-problem.md) says the lock costs a successful founder most of what they'd get from selling the business. So the strongest founders may go conventional, and the bank would be lending to the ones who were left. If the pilot then fails, nobody will know whether co-ops failed or the people who started them did.
The pilot can't stop that from happening. It can make sure the answer isn't a matter of opinion. This page is how.
## Why it's worth measuring
The worry isn't just theory.
- **In equal-sharing organizations, the best-paid people leave most.** In Uruguay, co-op members paid above their co-op's median were about 3.7 times as likely to quit as members paid below it, and the gap was smaller in co-ops that paid more unequally ([Burdín, *Economic Journal*, 2016](https://doi.org/10.1111/ecoj.12272); [free copy](https://gwern.net/doc/economics/2016-burdin.pdf)). In Israel's kibbutzim, which shared income equally, members with more education and more skilled jobs were the most likely to leave during a financial crisis in the late 1980s ([Abramitzky, *Quarterly Journal of Economics*, 2008](https://doi.org/10.1162/qjec.2008.123.3.1111); [free copy](https://ranabr.people.stanford.edu/sites/g/files/sbiybj26066/files/media/file/abramitzky_qje_2008.pdf)). If talented people walk out of equal-sharing firms, some will also never walk in.
- **Founders who believe in it stay.** The same Uruguayan study found founding members were less likely to quit, and the best-paid founders much less so than other best-paid members. That's the kind of founder [the founder page](founder-problem.md#the-honest-bottom-line) is betting on.
- **A founder's background predicts how the business does.** Using Census records that link founders to their past jobs, one study found that founders with three or more years in their start-up's industry were about twice as likely to build one of the very fastest-growing firms (the top 1 in 1,000) as founders with no experience in it ([Azoulay, Jones, Kim and Miranda, NBER working paper 2018, published 2020](https://www.nber.org/papers/w24489)).
- **That background isn't handed out fairly.** A study of why Black-owned businesses did worse than white-owned ones found that a large part of the gap came from Black owners having had less chance to work in a family business, or in a business like the one they started, first ([Fairlie and Robb, 2004](https://docs.iza.org/dp1292.pdf)). So "less experience" often means "fewer chances," not "less able." That shapes how the results are worded below.
It could also go the other way. The bank screens every plan, and co-ops always have at least three founders, so co-op founders could turn out to have *more* going for them than the firms they're compared with. Then a pass would flatter co-ops. The measurement below catches either.
## Who counts as a founder
The same rule on both sides: the co-ops and the matched conventional firms that got SBA loans ([Who co-ops are compared against](pilot-spec.md#who-co-ops-are-compared-against)).
- **A founder is someone who owned part of the business at the start and did real work in it.** For a co-op, that's a founding member named in the loan application. For an SBA firm, it's an owner of 20% or more, since those are the owners the SBA names on the loan. On both sides, they count only if payroll records show them working at least a set number of hours in the first two years. Someone added to the application for their résumé, who never works there, doesn't count, and the report lists how many such people there were.
- **The two sides aren't the same in one way.** SBA owners personally guarantee the loan. Co-op members don't ([The Pilot, in Numbers](pilot-spec.md#loan-terms)). So the founders on the two sides didn't carry the same risk, and the report says so.
- **Team size is handled openly.** Co-ops have three or more founders; most SBA firms have one or two owners at 20%. So the team's average is weighted by hours worked, and the "strongest founder" is compared only between teams of the same size, because the best of five people will usually beat the best of two just by numbers. The report says how many co-ops found matches at each size. SBA firms with nobody at 20% are counted and reported, not quietly dropped. As a second version, the analysis also counts the officers who run each SBA firm.
## What gets measured
Each item comes from government records, so nobody can talk their way up or down.
- **Past pay.** Pay per quarter actually worked, averaged over the best three of the eight years before founding. Using quarters worked and the best years means a parent who took time out for caregiving isn't marked down for the years away. Missing records count as missing, not as zero. It's ranked two ways, and both are reported:
- **against everyone of the same age nationally,** which shows whether the co-ops drew people from the lower-paid end of the whole economy;
- **against people of the same age, state and industry,** which shows whether they drew the lower-paid people *within* their line of work. Co-ops in lower-paid work are part of the design ([Who the Bank Lends To](who-gets-loans.md)), and a home care founder shouldn't count as having less going for them for earning less than an engineer.
- **Experience in the industry.** Years worked in the co-op's industry before founding.
- **Owned a business before.** From tax records of past business income.
- **Age**, as a background fact, not as a score.
The report also shows the co-ops' industry mix against all new firms, since staying out of whole industries is one way strong founders could avoid co-ops without it showing in any one founder's record.
## Getting the data
**The charter sets it up before the first loan.** It directs the bank, the SBA, the IRS and the Census Bureau to set up the linking, with every approval in hand before lending starts. That matters because research on tax records needs IRS approval project by project, and the Census Bureau's researchers can't count on getting it. Past studies show it can be done: Brown and Earle linked SBA loans to Census business records ([*Journal of Finance*, 2017](https://papers.ssrn.com/abstract=2641184)), and Azoulay and his coauthors linked founders to their past jobs.
- **Co-op founders consent when they apply,** on the standard IRS form that lets the reviewers see their tax records. So the co-op side can be done even if the Census linking falls through. The records are linked after the loan decision and never go to the people deciding loans.
- **Comparison founders aren't contacted.** Their records are linked inside the Census Bureau's secure research labs, where names and Social Security numbers are replaced with internal codes. Census law allows the data to be used only for statistics, never against the people in it, including for tax or immigration enforcement. Only totals leave the lab, after the Census Bureau's own privacy review.
- **State wage records are voluntary.** Years of experience come partly from state unemployment-insurance records, and each state decides whether to share them. Coverage is reported state by state, and results are shown using only the states that took part.
- **The cost comes from the pilot's budget**, so it counts against the bank in the money test.
**If the data never arrives,** the verdict says so in one fixed sentence that names which approval was missing and who withheld it. A missing founder analysis never delays or changes the pass or fail.
## How it's used
The analysis is filed in advance, with its code, the same as the rest of [the test](pilot-spec.md#what-gets-measured). It has three steps.
**1. Who came.** The report shows how co-op founders compare with the comparison founders on each measure, with both pay rankings. It shows the same comparison with first-time owners without much wealth set aside, since the bank seeks them out on purpose. And it shows how the founder measures differ by race, gender and whether founders are recent immigrants, so that a gap caused by who gets paid less for the same work is visible as that.
**2. How much of the result do founders explain?** The survival comparison is run a second time, also matching on the founders' measures. It isn't a second pass/fail test. The report gives **how far the result moved**, with its range of uncertainty. If co-ops were 30% more likely to fail in the main comparison and 10% more likely against firms with similar founders, founders explain about two-thirds of the gap. If the range around that change is too wide to tell whether it's zero or the whole gap, the report says "too few to tell," and that's decided by a rule filed in advance, not by the reviewers' judgment.
**3. How big a hidden difference would it take?** Records don't capture everything about a founder. So the report also says how strong a difference the records *can't* see would have to be to change the verdict, using a standard method for this question (the "E-value" of [VanderWeele and Ding, *Annals of Internal Medicine*, 2017](https://doi.org/10.7326/M16-2607)), measured against the pilot's actual pass line, not against "no difference at all." What counts as fragile is fixed in advance: a result is fragile if a hidden difference no stronger than the measured ones already turned out to be would change it.
## The verdict still stands
**The founder analysis doesn't change pass or fail.** The decision still rests on the [main comparison](pilot-spec.md#the-decision-rule).
That's on purpose. If the lock keeps the strongest founders away, that's part of what the lock *does*. It isn't an excuse for the co-ops, and a bigger program would face it too. And a second, adjusted test that could turn a fail into a pass would be the easiest way to rescue a failing pilot.
What changes is what the verdict says. It has to use one of these sentences, fixed in advance, and the fast-track bill after a pass has to print the whole sentence:
| Main comparison | Once founders are matched | The verdict says |
| --- | --- | --- |
| Passes | The gap barely moves | Passed. Founders' backgrounds don't explain it. |
| Passes | Co-ops fall behind | Passed, but co-op founders had more past pay or experience than the comparison firms'. Co-ops with similar founders haven't been shown to hold their own. |
| Fails | Most of the gap closes | Failed. Under this lock, co-ops drew founders with less past pay or industry experience, and that accounts for most of the gap. That's part of the result, not an excuse for it. |
| Fails | The gap barely moves | Failed, and founders' backgrounds don't explain it. |
| Either | Too few to tell | The main result, and "whether founders explain it couldn't be told." |
**A fail is a fail in every row.** The shutdown happens by itself. Nothing on this page is grounds to restart the bank. Trying again would take a new law and a new pilot held to the same decision rule. What the wording does is keep anyone from quoting one row as another: "co-ops don't work" and "co-ops under this lock don't attract founders with experience" are different findings, and a future design would need to fix different things.
## Watching the founders who stay
Founders who leave early can also sink a co-op. The pilot already publishes how often higher-paid members leave ([Who Sets the Pay](pay.md)). It now also publishes:
- **how often founders leave** in each of the first five years, against owners of the comparison firms who leave *without* a sale, since co-op founders can't sell their way out. A founder whose hours fall below the set level counts as having left, even if still on the books;
- **how often the best-paid members leave**, with the comparison firms' best-paid employees as the yardstick;
- **what founders who leave earn next,** against both the comparison owners and their own pay before founding. Earning more after a co-op could mean the brain drain the Uruguayan study found, or skills learned there. The report can't tell those apart, and says so.
The yearly audit of rejected applications ([Making It Last](durability.md#rigging-the-result)) also checks whether founders' past pay predicts approval once the business plan's numbers are held fixed. A bank trying to make step 1 look good would show up there.
## Asking the ones who didn't come
Records only cover founders who applied. The ones who went conventional, and the workers who never started anything, are invisible to the bank. So, once, in year 3, a federal statistical agency, not the bank, runs a voluntary survey with confidential answers. It samples new SBA borrowers in the same industries and workers in those industries who haven't started a business, in several languages.
- **Both deals are described side by side,** including the SBA's personal guarantee and the co-op's smaller downside as well as the lock.
- **The wording is written by the reviewers, filed at year 0 and tested in advance.** Each person is randomly shown one of two framings, and both results are published, so the wording's effect is visible instead of hidden.
- **The response rate is published,** with who didn't answer. No names go to the bank.
A survey answer isn't a decision made with real money, and people say things they wouldn't do. So it's published as background, and nothing in the verdict depends on it.
## What this doesn't solve
- **It measures the founder problem. It doesn't fix it.** If the analysis shows the strongest founders stayed away, the pilot has an answer, not a cure. The cure is in the founder deal ([The Founder Problem](founder-problem.md#the-tools)), and it may not be enough.
- **Records measure chances as much as ability.** Past pay reflects discrimination, bargaining power and who you know, not only skill. The report breaks the founder gap down by race, gender and immigrant status and uses careful wording, but "less past pay" is still partly "fewer chances."
- **Records miss people.** Recent immigrants may have little US record. Cash work and some self-employment never show up. The report says how many founders on each side couldn't be matched and shows them separately.
- **It's a lot of statistics on a small sample.** With about 400 co-ops, "too few to tell" is a likely answer for step 2, and the page says so in advance rather than after.
- **Worker groups muddy the comparison.** When the founders are ten workers from the same shop, the "strongest founder" is often a lead worker, not an entrepreneur. Those co-ops are reported separately.
- **It can't see the founders who never considered co-ops.** The survey is the only window on them, and it's a weak one.
@@ -0,0 +1,90 @@
---
title: Who the Bank Lends To
section: Growing the Sector
order: 66
summary: A bank that lends only to co-ops can still drift toward the safest ones, so part of the loan money is set aside for co-ops in lower-paid work, lending is published by group, and the final verdict has to say which kinds of co-ops it covers.
---
# Who the Bank Lends To
The bank's charter says it lends only to worker co-ops. That stops it from turning into an ordinary bank. It doesn't stop a quieter kind of drift: lending mostly to the co-ops that were always the safest bets, like consultancies and software shops started by people with savings, and seldom to the home care, cleaning and food co-ops where many co-op members work today.
That drift matters for two reasons. It would make the test less useful, because a pass earned by consultancies says little about the work most people do. And it's the drift the founding plan predicts. A public lender judged on its losses has every reason to fund the safe bets. The plan calls that "risk-aversion under political accountability."
## What we know
- **It has happened here.** The law that created America's co-op bank in 1978 told its board to use its "best efforts" to keep at least 35% of its loans going to co-ops of low-income people. Nothing happened when it missed, and the bank drifted toward borrowers that could have borrowed anywhere ([Has This Been Tried?](precedents.md#americas-co-op-bank-the-national-consumer-cooperative-bank)). Lending to safer borrowers may also be part of why it survived. That's the trade this page has to manage.
- **It isn't automatic.** A study of rated microfinance lenders in many countries over 11 years found that, across the industry, average loan size didn't grow and lending didn't shift toward cities. But lenders whose costs rose tended to make bigger loans, because small loans cost more to make per dollar lent ([Mersland and Strøm, *World Development*, 2010](https://doi.org/10.1016/j.worlddev.2009.05.006); [free copy](https://zenodo.org/records/16910149)). The pressure to drift is real, and it comes through costs.
- **The sector may already be moving.** The 2025 survey of US worker co-ops found that the fastest-growing industry was business consulting ([US Federation of Worker Cooperatives, 2025](https://www.usworker.coop/blog/the-2025-state-of-the-sector-report-is-here/)). Only 242 co-ops answered the survey ([survey page](https://www.usworker.coop/census/)), so that's a signal, not a measurement.
- **Enforced floors exist.** To stay certified, a US community development lender has to direct at least 60% of both the number and the dollar amount of its loans to the low-income places or people it's certified to serve. It's checked every year from loan-by-loan data, with no rounding: 59.9% fails. A lender that misses in one year can still pass on its three-year record ([CDFI Fund](https://www.cdfifund.gov/node/1030631); [Dechert, 2024](https://www.dechert.com/knowledge/onpoint/2024/3/department-of-the-treasury-s-community-development-financial-ins0.html)). Those lenders exist to serve that market, though. This bank exists to run a test, so it copies the idea of a checked, published share, not the 60%.
## Two groups, fixed in advance
Before the first loan, the pilot's filed plan defines two groups the results are reported on. The reviewers, not the bank, decide which group a co-op falls in when it applies.
- **Co-ops in lower-paid work.** In the co-op's industry, the average wage in its county is below the county's average for all private-sector jobs. The figures come from the federal count of employment and wages (the Quarterly Census of Employment and Wages), which covers nearly every employer, rural counties included. Where the county figure is withheld for privacy, the state figure is used, then the national one, and the report says how often that happened. The industry is the one the co-op registers under for its payroll taxes, so it can be checked against government records.
- **First-time owners without much wealth.** Most founding members, counted by their hours of work, have never owned a business, and their household wealth is below the median for people their age in the Federal Reserve's household wealth survey. Founders report their wealth in bands, sign it under penalty for false statements, and it's checked against their tax records with their consent. Founders count only if they work at least a set number of hours in the co-op's first year.
A co-op can be in both groups, one or neither.
## Money set aside
**About a third of the loan money, $90 million, is set aside for co-ops in lower-paid work.** Other co-ops can't borrow it. Nobody waits in line. Co-ops outside the group borrow from the rest of the money, the same as before. That's a first guess, sized so the set-aside could fund a large share of the co-ops at the smaller loans this kind of work usually needs.
**The standards don't change.** Each loan is still judged on its own numbers by the same filed standards ([Guarding the Pilot](pilot-safeguards.md)), and the limits on any one industry and county still apply. If the set-aside and the 25% cap on one industry ever clash, the cap wins.
**Unused money goes back, but only after a check.** At the end of year 4, any money not yet lent goes back to the general pool, but only if the reviewers certify two things:
1. The bank decided applications from lower-paid co-ops as fast as others, measured from a complete application, within a time limit filed in advance.
2. An extra-large audit of rejected lower-paid applications found that the bank applied its own standards.
If they can't certify both, the money stays set aside until the lending window closes. The verdict for lower-paid work is then **"not tested,"** not "failed," unless the reviewers show that good applications really were scarce.
**A shortfall with a clean audit is a finding.** If good applications from lower-paid work don't come even when the money is there and the bank handled them fairly, that's evidence about where co-ops can work. It's reported that way, the same rule the pilot uses when applicants are scarce ([The Pilot, in Numbers](pilot-spec.md#why-about-400-co-ops)).
**What counts.** Only new money to a co-op counts, and loans to related co-ops are added together, so splitting one loan into pieces can't pad the numbers. If the reviewers later find a co-op was put in the wrong group, its loan is moved to its real group, and the set-aside figures are recalculated. A co-op that misstated its industry on purpose is treated like any other false statement to the bank, under the rules against sham co-ops.
This isn't a lending target. Nobody at the bank is paid or judged on how much it lends, to any group.
## Priced like any other loan
Market rate means the whole cost of the loan, including the cost of making and servicing it, as a conventional lender would charge ([What "market rate" means](pilot-spec.md#what-market-rate-means)). Small loans cost more to make per dollar, so they carry a higher rate or a fee, as they would anywhere. The yearly rate audit checks that too. The bank can match repayments to a co-op's cash flow, with longer terms or a slow start, as long as the price for the risk stays the same.
So the set-aside isn't a subsidy, and it can't excuse a result. The money test is judged on the whole loan book, with no adjustment for the set-aside, whichever way it comes out.
## Published by group
Every year the bank publishes, for each group: applications, approvals, rejections, offers the co-op turned down, withdrawals, rates, collateral, loan sizes, time to a decision, and how the loans are doing. It also publishes the same figures by industry, by rural or urban county (using the federal government's standard rural codes), and by the founders' wealth band.
To protect small businesses, every published table needs at least 10 co-ops in each cell, counting every combination of details shown. Whether individual loans are behind on payments is published only in totals. Loan-by-loan data goes only to vetted researchers under a data-use agreement.
## Results by group
The pilot still passes or fails on the whole portfolio, using the two measures filed in advance ([The decision rule](pilot-spec.md#the-decision-rule)). Adding group results to the decision would make it easier to find one that looks good.
But the final report gives both measures, survival with fair pay and the bank's money, for each group. Because the groups are much smaller than the whole pilot, the plan files in advance the smallest difference each group could reliably show. For groups of a couple of hundred co-ops, "too few to tell" is the likely answer. When it is, the report says so, with the range of possible results and no single headline number.
Differences between groups are claimed only if a test of the difference itself, filed in advance, finds one. The verdict says which kinds of co-ops it covers. "Passed, with too few lower-paid co-ops to tell" is a different result from "passed across the board," and nobody gets to quote the first as the second.
The reviewers, not the bank, work out the bank's return for each group, using a method filed in advance for splitting shared costs. They report it with and without shared overhead, so the split can't be rigged to make one group look expensive.
If the pilot passes, any larger program's lending to lower-paid work is sized from that group's own result. If that result is "too few to tell," that lending can't grow faster than the rest ([After a Pass](scale.md)).
## Treated the same
The yearly audit of rejected applications ([Making It Last](durability.md#rigging-the-result)) now also looks at the two groups, in three ways.
- **Same numbers, same answer?** It compares approvals, rates and collateral across the groups for applications with similar numbers, and checks samples of approvals in both groups against the filed standards.
- **Are the numbers themselves fair?** It checks each part of the bank's scoring, like collateral, credit history and founders' own money, to see whether it counts against a group more than it predicts repayment. Then it publishes whether a different scoring would predict repayment as well with less of that effect.
- **Is the bar too high?** If approved co-ops in one group repay better than the rest, the bank was demanding too much of that group. The lottery among borderline applicants ([The Pilot, in Numbers](pilot-spec.md#who-co-ops-are-compared-against)) is the cleanest place to check.
The audit also reports by the founders' gender and race, as the pilot already does, collected separately from the loan file so underwriters don't see it.
## What this doesn't solve
- **It can't make people apply.** The set-aside makes money available. It can't make groups come forward. The [founding lottery](before-launch.md) and sponsors like unions and worker centers do that, and the pilot can only measure how well.
- **The lower-paid group is wide.** It runs from the lowest-paid work to work just under the average. The bank could meet it mostly at the top. The results are also published by fifths of the local wage scale, so that shows, but nothing requires lending at the bottom.
- **Market prices aren't neutral.** In home care and cleaning, prices are largely set by whoever buys the work, like Medicaid or a contractor, not by how well the business is run. A co-op that fails there may be failing the price, not the co-op form. Comparing co-ops with conventional firms in the same industry catches some of that, not all.
- **Industry is a rough stand-in.** A co-op in a low-wage industry can still pay well, and the reverse. Industry is used because it can be checked. The pilot publishes members' actual pay too.
- **Wealth is hard to check.** Tax records show income, not savings. Some founders will understate their wealth.
- **The numbers are guesses.** $90 million and year 4 are first guesses. The pilot publishes how much of the set-aside was used, so the next design can do better.
@@ -0,0 +1,31 @@
---
title: Worker Ownership
section: The Co-op
order: 20
summary: What a worker co-op means here: one member, one vote from day one, and a profit share that grows with tenure and then caps.
---
# Worker Ownership
A worker co-op under this model means two things, and they are not the same thing:
**One worker, one vote. Unconditional, from membership.** This is the moral core and it does not ramp, vest, or vary. The objection to the current system is *domination*: being ruled by a distant firm that answers to nobody who works there. The cure for domination is the vote, and you don't earn your way into not being dominated.
**Profit share ramps with tenure, then caps.** A new member starts at a partial share and grows to a full, equal share over roughly five to ten years. After the cap, everyone's share is identical. The thirty-year lifer and the eight-year veteran split profit the same.
Why the ramp: it kills the dilution panic. Under flat equal shares, every new hire immediately eats everyone's slice, and cooperatives empirically under-hire for exactly that reason. With a ramp, hiring decisions move toward "does this person grow the pie" during precisely the window when their value is most uncertain. The ramp only softens the problem, though. How co-ops can grow into lower-margin work without shrinking everyone's share is in [When Growing Means Sharing](growth.md).
Federal tax law narrows what the ramp can do. Profit shares are counted per hour worked, and the part of a share a new member hasn't reached yet can't go to the other members without being taxed twice. So by default it goes to the co-op's permanent reserve. The ramp still makes new members earn their way to a full share, but it no longer protects everyone else's share from new hires, unless a co-op pays the difference as wages for years of service ([How a Locked Co-op Is Taxed](taxes.md#the-tenure-ramp)).
Protected leave (parental, medical, caregiving) counts as tenure, so taking time off to care for someone doesn't set your share back. So does earlier time at the same co-op: someone who leaves and comes back within a few years picks up where they left off.
Membership itself comes automatically after a short probation, unless a worker opts out. In jobs like home care, where most people move on within a year or two, that's what keeps a co-op from becoming a club of the few who stay ([When People Don't Stay Long](high-turnover.md)).
Where does the profit share actually go? Part is paid in cash each year, and part goes into a personal account that pays each year's share back after about five years. See [Member Capital Accounts](capital-accounts.md).
Why the cap: uncapped tenure weighting builds a gerontocracy of shares and chains people to their first employer. Capped at a career-fraction, people can change companies, move cities, start over, and still reach a full share with plenty of career left.
- **Base salaries still exist**, set by the company. A brain surgeon won't work somewhere without a high salary, and shouldn't have to. Hard jobs and in-demand jobs still pay more. The incentive to do difficult work survives intact. Pay isn't capped, but raising salaries doesn't let members skip the co-op's reserve: it takes its share of pay above the going rate too ([Investing for the Long Run](the-long-run.md)). How pay gets set, in the open and by the right people, is in [Who Sets the Pay](pay.md).
- **Governance is up to each company.** Workers pass bylaws deciding what requires a vote and what doesn't. Some will use direct democracy, some representative systems, some will delegate almost everything to management they hire and can fire. "Workers vote on every decision" is a strawman and always was. Why anyone works hard when the profit is split so many ways, and what happens when someone doesn't, is in [Does Sharing the Profit Mean Less Effort?](free-riding.md).
The concrete case: the average employee where I work generates roughly $150,000/year in profit after all costs. That money currently flows upward to private equity. A firm with those economics, built as a co-op, sends it to the people who made it. The numbers, using public figures, are in [What If Conservice Had Been a Co-op?](conservice.md).
+9
View File
@@ -9,9 +9,18 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Markdig" Version="1.4.0" />
<PackageReference Include="System.Text.Json" Version="9.0.3" />
</ItemGroup>
<ItemGroup>
<!-- Public /socialism documents. Underscore-prefixed files (founding draft, progress log) are private and never deployed. -->
<Content Include="Content/Socialism/*.md" Exclude="Content/Socialism/_*.md">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<Content Include="Resources/**">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+15
View File
@@ -0,0 +1,15 @@
namespace JoshHeaps.Net.Models;
public class SocialismDoc
{
public string Slug { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string Summary { get; set; } = string.Empty;
public string Section { get; set; } = string.Empty;
public int Order { get; set; }
public string HtmlContent { get; set; } = string.Empty;
public List<SocialismHeading> Headings { get; set; } = [];
public int ReadingMinutes { get; set; }
}
public record SocialismHeading(int Level, string Id, string Text);
+476 -59
View File
@@ -7,28 +7,58 @@
<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>
<p class="holdemSubtitle">Seat your players, track the action, split the pot.</p>
<span id="handCounterBadge" class="handCounterBadge">Hand #1</span>
</div>
<div id="holdemHeaderActions">
<a href="/casino/texasholdem/quicksplit" class="holdemBtn" title="Chop a pot without setting up a table">Quick Split</a>
<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>
<button id="nextHandBtn" class="holdemBtn" title="Move the button, small blind and big blind to the next occupied seat">Next Hand ⟳</button>
<button id="resolvePotBtn" class="holdemBtn holdemBtnAccent">Resolve Pot</button>
<button id="resetTableBtn" class="holdemBtn holdemBtnDanger">Reset Table</button>
<a href="/casino" class="holdemBtn holdemBtnGhost">← Casino</a>
@* 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 700px 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="tableWatermark">Texas Hold'em</div>
<div id="potBadge">
<span id="potBadgeLabel">Pot</span>
<span id="potBadgeAmount">$0</span>
<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>
@@ -37,6 +67,7 @@
<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>
@@ -52,8 +83,12 @@
</div>
</section>
<details class="sidebarPanel" open>
<summary>Table Setup</summary>
@* 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>
<div class="panelBody">
<label class="fieldRow">
<span>Seats at table</span>
@@ -71,6 +106,22 @@
<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 class="blindsRow">
<label class="fieldRow">
<span>Small blind</span>
@@ -87,6 +138,32 @@
</label>
<p class="fieldHint">Blinds post themselves each hand. Seats without their own stack start here.</p>
<details class="subPanel">
<summary>House take</summary>
<div class="subPanelBody">
<div class="blindsRow">
<label class="fieldRow">
<span>Rake (%)</span>
<input type="number" id="rakePercentInput" min="0" max="100" step="0.5" placeholder="0" />
</label>
<label class="fieldRow">
<span>Cap ($)</span>
<input type="number" id="rakeCapInput" min="0" step="1" 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">
@@ -100,23 +177,93 @@
</div>
</details>
<details class="sidebarPanel">
<summary>Session</summary>
<div class="panelBody">
<h3 class="panelSubhead">Standings</h3>
<div id="standingsTable">
<div class="standingsRow standingsHeader">
<span>Player</span>
<span>In</span>
<span>Won</span>
<span>Net</span>
</div>
<div id="standingsList"></div>
</div>
<p id="standingsEmptyHint" class="fieldHint">Once you resolve a pot, everyone's running total for the night shows up here.</p>
@* 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.
<h3 class="panelSubhead">Hands</h3>
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">
@@ -127,18 +274,66 @@
</details>
<details class="sidebarPanel">
<summary>Chip Legend</summary>
<summary><h2>Chip Legend</h2> <span class="panelGlance" id="glanceChips" hidden></span></summary>
<div class="panelBody">
<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 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>
</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>Table Notes</summary>
<summary><h2>Table Notes</h2> <span class="panelGlance" id="glanceNotes" hidden></span></summary>
<div class="panelBody">
<textarea id="tableNotes" rows="4" placeholder="Anything else worth tracking — side bets, IOUs, running totals..."></textarea>
<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>
@@ -148,9 +343,10 @@
<template id="seatTemplate">
<div class="seat" draggable="true">
<div class="seatChairNumber"></div>
<button class="dealerChip" title="Make this seat the dealer button">D</button>
<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>
@@ -158,6 +354,20 @@
</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">
@@ -174,39 +384,64 @@
<span>Fold</span>
</label>
</div>
<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>
<label class="fieldRow stackRow">
<span>Stack</span>
<input type="number" class="stackInput" min="0" step="1" placeholder="table default" />
</label>
<div class="seatBuyIns" hidden></div>
<div class="seatContribution">
<div class="chipCounts"></div>
<label class="fieldRow amountOverrideRow">
<span>$ amount</span>
<input type="number" class="amountOverrideInput" min="0" step="1" placeholder="auto" />
</label>
<div class="seatTotal">Total: <span class="seatTotalAmount">$0</span></div>
</div>
<div class="customFields"></div>
<button class="holdemBtn holdemBtnSmall addFieldBtn">+ field</button>
<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="number" class="amountOverrideInput" min="0" step="1" 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" />
<input type="number" class="chipValueInput" min="0" step="0.01" placeholder="value" />
<input type="color" class="chipColorInput" aria-label="Chip colour" />
<input type="number" class="chipValueInput" min="0" step="0.01" 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="number" class="chipCountInput" min="0" step="1" value="0" />
</label>
</template>
@@ -225,6 +460,8 @@
<span class="standingsIn"></span>
<span class="standingsWon"></span>
<span class="standingsNet"></span>
<span class="standingsChips"></span>
<span class="standingsCashOut"></span>
</div>
</template>
@@ -253,7 +490,7 @@
</div>
<div class="addChipsActions">
<button id="addChipsConfirmBtn" class="holdemBtn holdemBtnAccent" type="button">Add to stack</button>
<button id="addChipsCancelBtn" class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
<button id="addChipsCancelBtn" data-modal-close class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
</div>
</div>
</div>
@@ -263,7 +500,7 @@
<div class="seatSheetGrip" aria-hidden="true"></div>
<div class="rulingsHeader">
<h2 id="rulingsTitle">Common rulings</h2>
<button id="closeRulingsBtn" class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
<button id="closeRulingsBtn" data-modal-close class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
</div>
<p class="rulingsIntro">
@@ -272,7 +509,9 @@
not during it.
</p>
<section class="ruling">
<nav id="rulingsIndex" class="rulingsIndex" aria-label="Jump to a ruling"></nav>
<section class="ruling" data-short="Misdeal">
<h3>Redeal it — misdeal</h3>
<ul>
<li>The first card goes to the wrong seat.</li>
@@ -282,7 +521,7 @@
</ul>
</section>
<section class="ruling">
<section class="ruling" data-short="Board card early">
<h3>A board card turned too early</h3>
<p>
If the fourth board card gets turned before the betting round is finished,
@@ -291,7 +530,7 @@
</p>
</section>
<section class="ruling">
<section class="ruling" data-short="Out of turn">
<h3>Acting out of turn</h3>
<p>
A one-off is usually just backed up if no one has acted behind them.
@@ -299,6 +538,134 @@
</p>
</section>
<section class="ruling" data-short="Clock">
<h3>Calling the clock on a player</h3>
<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>
</section>
<section class="ruling" data-short="Straddle">
<h3>A straddle is a blind raise</h3>
<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>
</section>
<section class="ruling" data-short="Short all-in">
<h3>A short all-in doesn't reopen the betting</h3>
<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>
</section>
<section class="ruling" data-short="Uncalled bet">
<h3>Chips nobody called come back</h3>
<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>
</section>
<section class="ruling" data-short="Oversized chip">
<h3>One big chip, nothing said</h3>
<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>
</section>
<section class="ruling" data-short="Minimum raise">
<h3>A raise has to be a full raise</h3>
<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>
</section>
<section class="ruling" data-short="Who shows first">
<h3>Who turns their hand over first</h3>
<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>
</section>
<section class="ruling" data-short="Odd chip">
<h3>The odd chip in a split pot</h3>
<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>
</section>
<section class="ruling" data-short="String bet">
<h3>Betting in one motion — the string bet</h3>
<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>
</section>
<p class="rulingsSource">
Follows the Tournament Directors Association rules, which most rooms and
home games take their lead from.
@@ -314,7 +681,7 @@
<span id="seatMenuSubtitle"></span>
</div>
<div id="seatMenuActions"></div>
<button id="seatMenuCancelBtn" class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
<button id="seatMenuCancelBtn" data-modal-close class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
</div>
</div>
@@ -324,14 +691,24 @@
<div class="cardPickerHeader">
<h2 id="cardPickerTitle"></h2>
<button id="cardPickerClearBtn" class="holdemBtn holdemBtnSmall" type="button">Clear</button>
<button id="cardPickerCloseBtn" class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
<button id="cardPickerCloseBtn" data-modal-close class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
</div>
<div id="cardPickerSlots"></div>
<div id="cardPickerGrid"></div>
</div>
</div>
<div id="actionBar" aria-live="polite" hidden></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>
@@ -340,32 +717,71 @@
<h2 id="confirmTitle"></h2>
<p id="confirmMessage"></p>
<div class="confirmActions">
<button id="confirmCancelBtn" class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
<button id="confirmCancelBtn" data-modal-close 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">
<div id="potWizardModal" role="dialog" aria-labelledby="potWizardTitle">
<div class="potWizardHeader">
<h2>Resolve the Pot</h2>
<button id="closePotWizardBtn" class="holdemBtn holdemBtnGhost">✕</button>
<h2 id="potWizardTitle">Resolve the Pot</h2>
<button id="closePotWizardBtn" data-modal-close class="holdemBtn holdemBtnGhost">✕</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>
@@ -374,5 +790,6 @@
<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>
}
@@ -11,6 +11,11 @@
<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>
@@ -18,7 +23,18 @@
<div id="splitDustNote" hidden></div>
</div>
<section class="splitField">
<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>
@@ -28,12 +44,12 @@
</div>
</section>
<section class="splitField">
<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">
<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>
@@ -43,12 +59,30 @@
<a href="/casino/texasholdem" class="splitBtn splitBtnGhost">Full dealer table →</a>
</div>
<p class="splitFootnote">
Uneven pots — someone all-in for less — need the side-pot walkthrough on the
<a href="/casino/texasholdem">full table</a>.
<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" />
}
@@ -56,5 +90,6 @@
@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>
}
+60
View File
@@ -0,0 +1,60 @@
@page "/socialism/{slug}"
@model JoshHeaps.Net.Pages.Socialism.DocModel
@{
ViewData["Title"] = Model.Doc!.Title + " - JoshHeaps.Net";
Layout = "_Layout";
}
<div class="soc-progress" id="socProgress"></div>
<button class="soc-menu-toggle" id="socMenuToggle" aria-controls="socSidebar" aria-expanded="false">Contents</button>
<div class="soc-shell">
<partial name="_Nav" model="Model" />
<div class="soc-main">
<nav class="soc-breadcrumbs" aria-label="Breadcrumb">
<a href="/socialism">Overview</a>
@if (!string.IsNullOrEmpty(Model.Doc.Section))
{
<span>/</span><span>@Model.Doc.Section</span>
}
</nav>
<header class="soc-doc-header">
<h1 class="soc-doc-title">@Model.Doc.Title</h1>
@if (!string.IsNullOrEmpty(Model.Doc.Summary))
{
<p class="soc-doc-summary">@Model.Doc.Summary</p>
}
<span class="soc-doc-meta">@Model.Doc.ReadingMinutes min read</span>
</header>
<article class="soc-content">
@Html.Raw(Model.Doc.HtmlContent)
</article>
<nav class="soc-pager" aria-label="Previous and next">
@if (Model.Previous is not null)
{
<a class="soc-pager-link prev" href="@(Model.Previous.Slug == SocialismDocService.IndexSlug ? "/socialism" : "/socialism/" + Model.Previous.Slug)">
<span class="soc-pager-label">&larr; Previous</span>
<span class="soc-pager-title">@(Model.Previous.Slug == SocialismDocService.IndexSlug ? "Overview" : Model.Previous.Title)</span>
</a>
}
@if (Model.Next is not null)
{
<a class="soc-pager-link next" href="/socialism/@Model.Next.Slug">
<span class="soc-pager-label">Next &rarr;</span>
<span class="soc-pager-title">@Model.Next.Title</span>
</a>
}
</nav>
</div>
<partial name="_Toc" model="Model.Doc.Headings" />
</div>
@section Styles {
<partial name="_Head" />
}
@@ -0,0 +1,31 @@
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Implementations;
using JoshHeaps.Net.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace JoshHeaps.Net.Pages.Socialism;
public class DocModel(ISocialismDocService docService) : SocialismPageModel
{
public SocialismDoc? Doc { get; private set; }
public SocialismDoc? Previous { get; private set; }
public SocialismDoc? Next { get; private set; }
public IActionResult OnGet(string slug)
{
if (slug == SocialismDocService.IndexSlug)
return RedirectPermanent("/socialism");
Doc = docService.GetDoc(slug);
if (Doc is null)
return NotFound();
CurrentSlug = slug;
AllDocs = docService.GetAllDocs();
var position = AllDocs.FindIndex(d => d.Slug == slug);
Previous = position > 0 ? AllDocs[position - 1] : null;
Next = position >= 0 && position < AllDocs.Count - 1 ? AllDocs[position + 1] : null;
return Page();
}
}
@@ -0,0 +1,65 @@
@page "/socialism"
@model JoshHeaps.Net.Pages.Socialism.IndexModel
@{
ViewData["Title"] = Model.Overview!.Title + " - JoshHeaps.Net";
Layout = "_Layout";
}
<div class="soc-progress" id="socProgress"></div>
<button class="soc-menu-toggle" id="socMenuToggle" aria-controls="socSidebar" aria-expanded="false">Contents</button>
<div class="soc-shell">
<partial name="_Nav" model="Model" />
<div class="soc-main">
<header class="soc-hero">
<h1 class="soc-hero-title">@Model.Overview.Title</h1>
@if (!string.IsNullOrEmpty(Model.Overview.Summary))
{
<p class="soc-hero-summary">@Model.Overview.Summary</p>
}
@if (Model.Chapters.Count > 0)
{
<a class="soc-hero-cta" href="/socialism/@Model.Chapters[0].Slug">Start reading &rarr;</a>
}
</header>
<article class="soc-content">
@Html.Raw(Model.Overview.HtmlContent)
</article>
@if (Model.Chapters.Count > 0)
{
<section class="soc-chapters" id="explore">
<h2 class="soc-chapters-title">Explore the full model</h2>
@foreach (var group in Model.Chapters.GroupBy(d => d.Section))
{
@if (!string.IsNullOrEmpty(group.Key))
{
<h3 class="soc-chapters-group">@group.Key</h3>
}
<div class="soc-card-grid">
@foreach (var doc in group)
{
<a class="soc-card" href="/socialism/@doc.Slug">
<span class="soc-card-title">@doc.Title</span>
@if (!string.IsNullOrEmpty(doc.Summary))
{
<span class="soc-card-summary">@doc.Summary</span>
}
<span class="soc-card-meta">@doc.ReadingMinutes min read</span>
</a>
}
</div>
}
</section>
}
</div>
<partial name="_Toc" model="Model.Overview.Headings" />
</div>
@section Styles {
<partial name="_Head" />
}
@@ -0,0 +1,24 @@
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Implementations;
using JoshHeaps.Net.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace JoshHeaps.Net.Pages.Socialism;
public class IndexModel(ISocialismDocService docService) : SocialismPageModel
{
public SocialismDoc? Overview { get; private set; }
public List<SocialismDoc> Chapters { get; private set; } = [];
public IActionResult OnGet()
{
Overview = docService.GetDoc(SocialismDocService.IndexSlug);
if (Overview is null)
return NotFound();
CurrentSlug = SocialismDocService.IndexSlug;
AllDocs = docService.GetAllDocs();
Chapters = AllDocs.Where(d => d.Slug != SocialismDocService.IndexSlug).ToList();
return Page();
}
}
@@ -0,0 +1,21 @@
using JoshHeaps.Net.Models;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace JoshHeaps.Net.Pages.Socialism;
/// <summary>
/// Shared base for the /socialism pages. These pages are unlisted: nothing links to them
/// and search engines are told not to index them.
/// </summary>
public abstract class SocialismPageModel : PageModel
{
public List<SocialismDoc> AllDocs { get; protected set; } = [];
public string CurrentSlug { get; protected set; } = string.Empty;
public override void OnPageHandlerExecuting(PageHandlerExecutingContext context)
{
Response.Headers["X-Robots-Tag"] = "noindex, nofollow";
base.OnPageHandlerExecuting(context);
}
}
@@ -0,0 +1,4 @@
<meta name="robots" content="noindex, nofollow" />
<link href="/css/variables.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
<link href="/css/socialism/socialism.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
<script src="/js/socialism/socialism.js?v=@ViewData["cssVersion"]" defer></script>
+32
View File
@@ -0,0 +1,32 @@
@model SocialismPageModel
@{
var groups = Model.AllDocs
.Where(d => d.Slug != SocialismDocService.IndexSlug)
.GroupBy(d => d.Section);
}
<nav class="soc-sidebar" id="socSidebar" aria-label="Document navigation">
<a href="/socialism" class="soc-sidebar-home @(Model.CurrentSlug == SocialismDocService.IndexSlug ? "active" : "")">Overview</a>
<input type="search" class="soc-filter" id="socFilter" placeholder="Filter sections..." aria-label="Filter sections" />
@foreach (var group in groups)
{
<div class="soc-nav-group">
@if (!string.IsNullOrEmpty(group.Key))
{
<div class="soc-nav-group-title">@group.Key</div>
}
<ul>
@foreach (var doc in group)
{
<li>
<a href="/socialism/@doc.Slug"
class="soc-nav-link @(doc.Slug == Model.CurrentSlug ? "active" : "")"
data-filter-text="@doc.Title.ToLowerInvariant() @doc.Summary.ToLowerInvariant()">@doc.Title</a>
</li>
}
</ul>
</div>
}
</nav>
+16
View File
@@ -0,0 +1,16 @@
@model List<SocialismHeading>
@if (Model.Count > 1)
{
<aside class="soc-toc" aria-label="On this page">
<div class="soc-toc-title">On this page</div>
<ul>
@foreach (var heading in Model)
{
<li class="[email protected]">
<a href="#@heading.Id" data-toc-id="@heading.Id">@heading.Text</a>
</li>
}
</ul>
</aside>
}
@@ -0,0 +1,4 @@
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@using JoshHeaps.Net.Models
@using JoshHeaps.Net.Pages.Socialism
@using JoshHeaps.Net.Services.Implementations
+43
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);
@@ -19,6 +20,7 @@ builder.Services.AddHttpClient("BlogApi", client =>
client.Timeout = TimeSpan.FromSeconds(10);
});
builder.Services.AddSingleton<IBlogService, BlogService>();
builder.Services.AddSingleton<ISocialismDocService, SocialismDocService>();
builder.Services.AddSingleton<IChessService, ChessService>();
builder.Services.AddSingleton<IBackgroundTaskQueue, BackgroundTaskQueue>();
@@ -52,8 +54,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");
@@ -0,0 +1,178 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces;
using Markdig;
using Markdig.Renderers.Html;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
namespace JoshHeaps.Net.Services.Implementations;
/// <summary>
/// Serves the markdown documents in Content/Socialism. Files starting with an underscore
/// (the founding draft, the progress log) are private and never served.
/// </summary>
public partial class SocialismDocService : ISocialismDocService
{
public const string IndexSlug = "index";
private readonly string _contentDir;
private readonly ILogger<SocialismDocService> _logger;
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new();
private static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder()
.UseAdvancedExtensions()
.Build();
public SocialismDocService(IWebHostEnvironment env, ILogger<SocialismDocService> logger)
{
_contentDir = Path.Combine(env.ContentRootPath, "Content", "Socialism");
_logger = logger;
}
public SocialismDoc? GetDoc(string slug)
{
if (!IsValidSlug(slug))
return null;
var path = Path.Combine(_contentDir, slug + ".md");
if (!File.Exists(path))
return null;
// Cache keyed on last-write time so edited documents show up without a restart.
var lastWrite = File.GetLastWriteTimeUtc(path);
if (_cache.TryGetValue(slug, out var entry) && entry.LastWrite == lastWrite)
return entry.Doc;
try
{
var doc = Parse(slug, File.ReadAllText(path));
_cache[slug] = new CacheEntry(doc, lastWrite);
return doc;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to render socialism doc {Slug}", slug);
return entry?.Doc;
}
}
public List<SocialismDoc> GetAllDocs()
{
if (!Directory.Exists(_contentDir))
return [];
return Directory.EnumerateFiles(_contentDir, "*.md")
.Select(Path.GetFileNameWithoutExtension)
.Where(slug => slug is not null && IsValidSlug(slug))
.Select(slug => GetDoc(slug!))
.OfType<SocialismDoc>()
.OrderBy(d => d.Slug == IndexSlug ? 0 : 1)
.ThenBy(d => d.Order)
.ThenBy(d => d.Title)
.ToList();
}
// Lowercase letters, digits and hyphens only: rules out private "_" files and path traversal.
private static bool IsValidSlug(string slug) => SlugRegex().IsMatch(slug);
private static SocialismDoc Parse(string slug, string raw)
{
var (meta, body) = SplitFrontMatter(raw);
var markdown = Markdown.Parse(body, Pipeline);
var headings = new List<SocialismHeading>();
string? firstH1 = null;
foreach (var heading in markdown.Descendants<HeadingBlock>())
{
var text = InlineText(heading.Inline);
if (heading.Level == 1)
{
firstH1 ??= text;
continue;
}
var id = heading.GetAttributes().Id;
if (heading.Level <= 3 && !string.IsNullOrEmpty(id))
headings.Add(new SocialismHeading(heading.Level, id, text));
}
foreach (var link in markdown.Descendants<LinkInline>())
{
if (!link.IsImage && link.Url is not null)
link.Url = RewriteLink(link.Url);
}
var words = WordRegex().Matches(body).Count;
return new SocialismDoc
{
Slug = slug,
Title = meta.GetValueOrDefault("title") ?? firstH1 ?? slug,
Summary = meta.GetValueOrDefault("summary") ?? string.Empty,
Section = meta.GetValueOrDefault("section") ?? string.Empty,
Order = int.TryParse(meta.GetValueOrDefault("order"), out var order) ? order : int.MaxValue,
HtmlContent = markdown.ToHtml(Pipeline),
Headings = headings,
ReadingMinutes = Math.Max(1, (int)Math.Round(words / 230.0))
};
}
// "economy.md#floor" -> "/socialism/economy#floor"; "index.md" -> "/socialism".
private static string RewriteLink(string url)
{
var match = RelativeDocLinkRegex().Match(url);
if (!match.Success)
return url;
var target = match.Groups["slug"].Value;
var anchor = match.Groups["anchor"].Value;
return (target == IndexSlug ? "/socialism" : $"/socialism/{target}") + anchor;
}
private static (Dictionary<string, string> Meta, string Body) SplitFrontMatter(string raw)
{
var meta = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var match = FrontMatterRegex().Match(raw);
if (!match.Success)
return (meta, raw);
foreach (var line in match.Groups["yaml"].Value.Split('\n'))
{
var idx = line.IndexOf(':');
if (idx <= 0)
continue;
meta[line[..idx].Trim()] = line[(idx + 1)..].Trim().Trim('"');
}
return (meta, raw[match.Length..]);
}
private static string InlineText(ContainerInline? inline)
{
if (inline is null)
return string.Empty;
return string.Concat(inline.Descendants<Inline>().Select(i => i switch
{
LiteralInline literal => literal.Content.ToString(),
CodeInline code => code.Content,
_ => string.Empty
}));
}
[GeneratedRegex("^[a-z0-9][a-z0-9-]*$")]
private static partial Regex SlugRegex();
[GeneratedRegex(@"^(?:\./)?(?<slug>[a-z0-9][a-z0-9-]*)\.md(?<anchor>#[^\s]*)?$")]
private static partial Regex RelativeDocLinkRegex();
[GeneratedRegex(@"\A---\r?\n(?<yaml>.*?)\r?\n---\r?\n", RegexOptions.Singleline)]
private static partial Regex FrontMatterRegex();
[GeneratedRegex(@"\b\w+\b")]
private static partial Regex WordRegex();
private record CacheEntry(SocialismDoc Doc, DateTime LastWrite);
}
@@ -0,0 +1,9 @@
using JoshHeaps.Net.Models;
namespace JoshHeaps.Net.Services.Interfaces;
public interface ISocialismDocService
{
SocialismDoc? GetDoc(string slug);
List<SocialismDoc> GetAllDocs();
}
@@ -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);
})());
});
File diff suppressed because it is too large Load Diff
+339 -12
View File
@@ -9,6 +9,58 @@ body {
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;
@@ -45,6 +97,256 @@ body {
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 {
@@ -104,6 +406,12 @@ body {
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;
@@ -165,9 +473,11 @@ body {
cursor: pointer;
}
.clearPotBtn:hover {
background: #333;
color: #ddd;
@media (hover: hover) {
.clearPotBtn:hover {
background: #333;
color: #ddd;
}
}
/* ---------- Tap targets ---------- */
@@ -193,9 +503,11 @@ body {
transition: all 0.15s ease;
}
.choiceChip:hover {
border-color: #4a4a4a;
background: linear-gradient(180deg, #383838, #2a2a2a);
@media (hover: hover) {
.choiceChip:hover {
border-color: #4a4a4a;
background: linear-gradient(180deg, #383838, #2a2a2a);
}
}
.choiceChip.selected {
@@ -230,8 +542,10 @@ body {
cursor: pointer;
}
.stepBtn:hover {
background: #333;
@media (hover: hover) {
.stepBtn:hover {
background: #333;
}
}
.waysReadout {
@@ -268,9 +582,11 @@ body {
transition: all 0.15s ease;
}
.splitBtn:hover {
border-color: #4a4a4a;
background: linear-gradient(180deg, #383838, #2a2a2a);
@media (hover: hover) {
.splitBtn:hover {
border-color: #4a4a4a;
background: linear-gradient(180deg, #383838, #2a2a2a);
}
}
.splitBtnGhost {
@@ -279,12 +595,23 @@ body {
.splitFootnote {
margin-top: 1.5rem;
color: #6f6f6f;
/* 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;
}
@@ -0,0 +1,557 @@
*, *::before, *::after {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
scroll-padding-top: 1.5rem;
}
body {
margin: 0;
background-color: var(--color-bg);
color: var(--color-text);
font-family: var(--font-body);
line-height: 1.6;
}
/* ── Reading progress ── */
.soc-progress {
position: fixed;
top: 0;
left: 0;
height: 2px;
width: 0;
background: var(--color-accent);
z-index: 20;
transition: width 0.1s linear;
}
/* ── Three-column shell: nav | content | on-this-page ── */
.soc-shell {
display: grid;
grid-template-columns: 250px minmax(0, 760px) 220px;
justify-content: center;
gap: 3rem;
padding: 0 clamp(1rem, 3vw, 2rem);
}
.soc-main {
padding: 4rem 0 6rem;
min-width: 0;
}
/* ── Sidebar ── */
.soc-sidebar {
position: sticky;
top: 0;
align-self: start;
max-height: 100vh;
overflow-y: auto;
padding: 4rem 0 2rem;
scrollbar-width: thin;
scrollbar-color: var(--color-border) transparent;
}
.soc-sidebar-home {
display: block;
color: var(--color-heading-secondary);
font-weight: 600;
text-decoration: none;
margin-bottom: 1rem;
}
.soc-sidebar-home.active,
.soc-sidebar-home:hover {
color: var(--color-accent);
}
.soc-filter {
width: 100%;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 6px;
color: var(--color-heading-secondary);
padding: 0.45rem 0.65rem;
font: inherit;
font-size: 0.8rem;
margin-bottom: 1.25rem;
}
.soc-filter:focus {
outline: none;
border-color: var(--color-accent-border);
}
.soc-nav-group {
margin-bottom: 1.25rem;
}
.soc-nav-group-title {
color: var(--color-text-subtle);
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 0.4rem;
}
.soc-sidebar ul {
list-style: none;
margin: 0;
padding: 0;
}
.soc-nav-link {
display: block;
color: var(--color-text);
text-decoration: none;
font-size: 0.85rem;
padding: 0.3rem 0 0.3rem 0.75rem;
border-left: 1px solid var(--color-border);
transition: color 0.2s, border-color 0.2s;
}
.soc-nav-link:hover {
color: var(--color-heading-secondary);
border-left-color: var(--color-border-hover);
}
.soc-nav-link.active {
color: var(--color-accent);
border-left-color: var(--color-accent);
}
.soc-nav-link.hidden {
display: none;
}
/* ── On this page ── */
.soc-toc {
position: sticky;
top: 0;
align-self: start;
max-height: 100vh;
overflow-y: auto;
padding: 4rem 0 2rem;
font-size: 0.8rem;
}
.soc-toc-title {
color: var(--color-text-subtle);
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 0.6rem;
}
.soc-toc ul {
list-style: none;
margin: 0;
padding: 0;
}
.soc-toc a {
display: block;
color: var(--color-text-subtle);
text-decoration: none;
padding: 0.2rem 0;
transition: color 0.2s;
}
.soc-toc .soc-toc-level-3 a {
padding-left: 0.85rem;
}
.soc-toc a:hover {
color: var(--color-heading-secondary);
}
.soc-toc a.active {
color: var(--color-accent);
}
/* ── Landing hero ── */
.soc-hero {
margin-bottom: 3rem;
}
.soc-hero-title {
color: var(--color-heading);
font-size: clamp(2rem, 4vw, 2.75rem);
letter-spacing: -0.02em;
line-height: 1.15;
margin: 0 0 1rem;
}
.soc-hero-summary {
color: var(--color-heading-secondary);
font-size: 1.15rem;
line-height: 1.7;
margin: 0 0 1.5rem;
}
.soc-hero-cta {
display: inline-block;
color: var(--color-bg);
background: var(--color-accent);
font-weight: 600;
font-size: 0.9rem;
text-decoration: none;
padding: 0.55rem 1.1rem;
border-radius: 6px;
transition: background 0.2s;
}
.soc-hero-cta:hover {
background: var(--color-accent-hover);
}
/* ── Chapter cards ── */
.soc-chapters {
margin-top: 4rem;
}
.soc-chapters-title {
color: var(--color-heading);
font-size: 1.4rem;
margin: 0 0 1.25rem;
}
.soc-chapters-group {
color: var(--color-text-subtle);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
margin: 2rem 0 0.75rem;
}
.soc-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1rem;
}
.soc-card {
display: flex;
flex-direction: column;
gap: 0.4rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 1.1rem 1.2rem;
text-decoration: none;
transition: background 0.2s, border-color 0.2s, transform 0.2s;
}
.soc-card:hover {
background: var(--color-surface-hover);
border-color: var(--color-accent-border);
transform: translateY(-2px);
}
.soc-card-title {
color: var(--color-heading-secondary);
font-weight: 600;
}
.soc-card-summary {
color: var(--color-text);
font-size: 0.85rem;
line-height: 1.5;
flex: 1;
}
.soc-card-meta {
color: var(--color-text-subtle);
font-size: 0.75rem;
}
/* ── Document header ── */
.soc-breadcrumbs {
display: flex;
gap: 0.5rem;
font-size: 0.8rem;
color: var(--color-text-subtle);
}
.soc-breadcrumbs a {
color: var(--color-text-subtle);
text-decoration: none;
transition: color 0.2s;
}
.soc-breadcrumbs a:hover {
color: var(--color-heading-secondary);
}
.soc-doc-header {
margin: 1.5rem 0 2.5rem;
}
.soc-doc-title {
color: var(--color-heading);
font-size: clamp(1.75rem, 3vw, 2.25rem);
letter-spacing: -0.02em;
line-height: 1.25;
margin: 0 0 0.75rem;
}
.soc-doc-summary {
color: var(--color-heading-secondary);
font-size: 1.05rem;
margin: 0 0 0.5rem;
}
.soc-doc-meta {
color: var(--color-text-subtle);
font-size: 0.8rem;
}
/* ── Rendered markdown ── */
.soc-content {
line-height: 1.8;
}
.soc-content > h1:first-child {
display: none; /* the page header already shows the title */
}
.soc-content h2,
.soc-content h3,
.soc-content h4 {
position: relative;
color: var(--color-heading-secondary);
letter-spacing: -0.01em;
margin: 2.5rem 0 0.75rem;
}
.soc-content h2 {
font-size: 1.45rem;
padding-top: 1.5rem;
border-top: 1px solid var(--color-border);
}
.soc-content h3 { font-size: 1.15rem; }
.soc-anchor {
position: absolute;
left: -1.2em;
color: var(--color-text-subtle);
text-decoration: none;
opacity: 0;
transition: opacity 0.2s;
}
.soc-content h2:hover .soc-anchor,
.soc-content h3:hover .soc-anchor {
opacity: 1;
}
.soc-content p {
margin: 0 0 1.25rem;
}
.soc-content a {
color: var(--color-accent);
text-decoration: none;
}
.soc-content a:hover {
color: var(--color-accent-hover);
text-decoration: underline;
}
.soc-content strong {
color: var(--color-heading-secondary);
}
.soc-content ul,
.soc-content ol {
margin: 0 0 1.25rem 1.5rem;
padding: 0;
}
.soc-content li {
margin-bottom: 0.4rem;
}
.soc-content blockquote {
margin: 1.5rem 0;
padding: 0.75rem 1.25rem;
border-left: 3px solid var(--color-accent);
background: var(--color-surface);
color: var(--color-heading-secondary);
border-radius: 0 8px 8px 0;
}
.soc-content blockquote p:last-child {
margin-bottom: 0;
}
.soc-content hr {
border: none;
border-top: 1px solid var(--color-border);
margin: 2.5rem 0;
}
.soc-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);
}
.soc-content table {
width: 100%;
border-collapse: collapse;
margin: 0 0 1.5rem;
font-size: 0.9rem;
display: block;
overflow-x: auto;
}
.soc-content th,
.soc-content td {
border: 1px solid var(--color-border);
padding: 0.5rem 0.75rem;
text-align: left;
vertical-align: top;
}
.soc-content th {
background: var(--color-surface-raised);
color: var(--color-heading-secondary);
}
.soc-content details {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 0.75rem 1rem;
margin: 0 0 1.25rem;
}
.soc-content summary {
cursor: pointer;
color: var(--color-heading-secondary);
font-weight: 600;
}
.soc-content .footnotes {
font-size: 0.85rem;
color: var(--color-text-subtle);
}
/* ── Pager ── */
.soc-pager {
display: flex;
gap: 1rem;
margin-top: 4rem;
}
.soc-pager-link {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.2rem;
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 0.9rem 1.1rem;
text-decoration: none;
transition: border-color 0.2s, background 0.2s;
}
.soc-pager-link.next {
text-align: right;
margin-left: auto;
}
.soc-pager-link:hover {
border-color: var(--color-accent-border);
background: var(--color-surface);
}
.soc-pager-label {
color: var(--color-text-subtle);
font-size: 0.75rem;
}
.soc-pager-title {
color: var(--color-heading-secondary);
font-weight: 600;
}
/* ── Mobile ── */
.soc-menu-toggle {
display: none;
}
@media (max-width: 1200px) {
.soc-shell {
grid-template-columns: 230px minmax(0, 760px);
}
.soc-toc {
display: none;
}
}
@media (max-width: 860px) {
.soc-shell {
grid-template-columns: minmax(0, 1fr);
}
.soc-main {
padding-top: 4.5rem;
}
.soc-menu-toggle {
display: block;
position: fixed;
top: 1rem;
left: 1rem;
z-index: 15;
background: var(--color-surface-raised);
backdrop-filter: blur(8px);
border: 1px solid var(--color-border);
border-radius: 6px;
color: var(--color-heading-secondary);
font: inherit;
font-size: 0.85rem;
padding: 0.4rem 0.8rem;
cursor: pointer;
}
.soc-sidebar {
position: fixed;
inset: 0 30% 0 0;
min-width: 260px;
max-height: none;
background: var(--color-bg);
border-right: 1px solid var(--color-border);
padding: 4.5rem 1.25rem 2rem;
z-index: 10;
transform: translateX(-100%);
transition: transform 0.25s ease;
}
.soc-sidebar.open {
transform: translateX(0);
}
.soc-pager {
flex-direction: column;
}
}
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

File diff suppressed because it is too large Load Diff
@@ -35,7 +35,7 @@ const AddChips = {
quickAmounts() {
const base = this.baseAmount();
return [
{ label: "½ buy-in", value: Math.round(base / 2 * 100) / 100 },
{ label: "½ buy-in", value: Money.round(base / 2) },
{ label: "Buy-in", value: base },
{ label: "2 buy-ins", value: base * 2 },
];
@@ -83,7 +83,7 @@ const AddChips = {
confirm() {
const amount = Math.max(0, Number(document.getElementById("addChipsInput").value) || 0);
this.close();
if (amount > 0) this.onConfirm(this.seatIndex, Math.round(amount * 100) / 100);
if (amount > 0) this.onConfirm(this.seatIndex, Money.round(amount));
},
};
+595 -27
View File
@@ -13,12 +13,21 @@
const Betting = {
STREETS: ["preflop", "flop", "turn", "river"],
// How many community cards are face up once a street has been dealt. The flop is
// three at once and everything after it is one at a time, which is why this is a
// table rather than an index.
BOARD_CARDS: { preflop: 0, flop: 3, turn: 4, river: 5 },
boardCardsFor(street) {
return this.BOARD_CARDS[street] ?? 0;
},
streetLabel(street) {
return { preflop: "Pre-flop", flop: "Flop", turn: "Turn", river: "River" }[street] || "Pre-flop";
},
round(amount) {
return Math.round((Number(amount) || 0) * 100) / 100;
return Money.round(amount);
},
// ---- Reading the table ----
@@ -29,6 +38,162 @@ const Betting = {
.filter(s => s.player.inHand && !s.player.folded);
},
// Who would post if the hand were dealt right now. Used to preview the blinds
// while the table is still being set, so a dealer can see where they'll land
// before any money moves.
// A seat that is dealt in, has nothing in the pot, and has no chips behind.
//
// Not the same as all-in: an all-in player has money riding on the hand. This is
// somebody who busted and hasn't bought back in yet, which a home game produces
// about once an hour. A seat with no stack *recorded* isn't broke either —
// stackOf falls back to the table's starting stack for those — only an explicit
// zero counts.
brokeSeats(state) {
const broke = [];
state.seats.forEach((player, index) => {
if (!player.inHand) return;
if (this.handTotal(player) > this.EPSILON_CHIPS) return;
if (this.stackOf(state, player) > this.EPSILON_CHIPS) return;
broke.push(index);
});
return broke;
},
// The table as it will be once the seats that can't post are dealt around. A view,
// not a change: the preview has to ask the same question the deal will answer, and
// asking it of a different shape of table is how the two came apart last time.
tableAsDealt(state) {
const broke = this.brokeSeats(state);
if (broke.length === 0) return state;
const skip = new Set(broke);
return {
...state,
seats: state.seats.map((player, index) =>
skip.has(index) ? { ...player, inHand: false, folded: false } : player),
};
},
blindPreview(state) {
// Dealing relocates a button parked on an empty chair before it works out who
// posts, so the preview has to answer for the button where it *will* be. Asking
// from where it currently sits gave a different pair of blinds than the deal
// produced — the preview promising one thing and doing another is exactly the
// guessing this whole step exists to remove. Same reason the broke seats come
// out first: the preview once promised a small blind from a seat with no chips.
const dealt = this.tableAsDealt(state);
const buttonIndex = HoldemState.buttonSeatForDeal(dealt);
const asDealt = buttonIndex === dealt.dealerSeatIndex
? dealt
: { ...dealt, dealerSeatIndex: buttonIndex };
const satOut = this.brokeSeats(state);
const { sbIndex, bbIndex, headsUp, manual, deadSmallBlind } = HoldemState.getBlindSeats(asDealt);
if (bbIndex === null) return null;
return {
sbIndex, bbIndex, headsUp, manual: !!manual, deadSmallBlind: !!deadSmallBlind,
deadButton: !!asDealt.deadButton && !asDealt.seats[buttonIndex].inHand,
// Asked of the table as it will be dealt, for the same reason the blinds
// are: someone sitting out for having no chips isn't owed a blind, and the
// big blind of the hand about to go out isn't either.
debtors: HoldemState.blindDebtors(asDealt, bbIndex),
posting: (state.postingIn || []).slice(),
buttonIndex,
buttonMoves: buttonIndex !== state.dealerSeatIndex,
satOut,
smallBlind: this.round(state.smallBlind || 0),
bigBlind: this.round(state.bigBlind || 0),
// What the straddles will be if the hand is dealt as it stands, and how
// many more this table could take.
straddles: this.straddleSeats(state),
maxStraddles: this.maxStraddles(state),
};
},
// ---- Straddles ----
//
// A straddle is a blind raise put out before the cards, by the player left of the
// big blind, for twice it. It buys the last word before the flop: everyone else
// acts first, and the straddler still has the option when it comes back round even
// if nobody raised. Re-straddles carry on left, each doubling again.
//
// Home games run them constantly and a dealer has to get three things right at
// once — the amount, who is first to act, and what the minimum raise becomes. The
// tool used to do none of them, so a dealer's only way through was to deal, have
// the straddler "raise" to $4, and hand the action to the wrong player.
// How many straddles this table could take. Everyone but the two blinds can put
// one out; heads-up that is nobody, which is the right answer — the button is
// already the small blind and there is no seat left of the big blind to straddle
// from.
maxStraddles(state) {
return Math.max(0, this.liveSeats(this.tableAsDealt(state)).length - 2);
},
// Who straddles and for how much, walking left from the big blind and doubling.
// Asked of the table as it will be dealt, so a seat about to be sat out for having
// no chips is never handed a straddle it can't post.
straddleSeats(state, count = state.straddleCount) {
const wanted = Math.min(Math.max(0, Number(count) || 0), this.maxStraddles(state));
if (wanted <= 0) return [];
const dealt = this.tableAsDealt(state);
const buttonIndex = HoldemState.buttonSeatForDeal(dealt);
const asDealt = buttonIndex === dealt.dealerSeatIndex
? dealt
: { ...dealt, dealerSeatIndex: buttonIndex };
const { bbIndex } = HoldemState.getBlindSeats(asDealt);
if (bbIndex === null) return [];
const straddles = [];
let index = bbIndex;
let amount = this.round((Number(state.bigBlind) || 0) * 2);
for (let n = 0; n < wanted; n++) {
index = this.nextToAct(asDealt, index);
if (index === null || index === bbIndex) break;
straddles.push({ index, amount });
amount = this.round(amount * 2);
}
return straddles;
},
// Does handing this seat chips also put them back in the game?
//
// At a real table those are one event, and here they were two: a busted player got
// their chips and stayed sat out, silently skipped hand after hand until somebody
// noticed and found "Deal in" in the seat menu. Nothing said so — the toast just
// read "Ben added $100".
//
// Not for a seat that was sat out while *holding* chips. That's a dealer who sat
// somebody out on purpose and is topping them up, and dealing them back in would be
// the tool guessing at something it wasn't told. Only a seat with no stack of its
// own: busted at zero, or a chair being bought into for the first time.
//
// Deliberately reads player.stack rather than stackOf(), which substitutes the
// table's starting stack for a seat that has none — the question here is what this
// seat was actually recorded as holding.
buyInDealsThemIn(player) {
if (!player || player.inHand) return false;
const own = player.stack;
const recorded = own !== null && own !== undefined && String(own).trim() !== "";
return !recorded || Number(own) <= this.EPSILON_CHIPS;
},
// "bet" or "raise" — what putting more in is actually called right now.
//
// A wager into a pot nobody has wagered into is a bet; everything after that is a
// raise. The bar decided this from what the acting player still owes, which 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. It gets the other
// half wrong too — the raise pad said "Raise to" for an opening bet on the flop,
// and wrote "Ann's raise to $10" into the undo control for it.
//
// The arithmetic was always right (minRaiseTo returns two big blinds for the big
// blind's option and one for an opening bet). This is only what it's called, which
// for a tool a dealer announces the action from is not a small thing.
wagerVerb(state) {
return (state.currentBet || 0) > this.EPSILON_CHIPS ? "raise" : "bet";
},
committed(player) {
return this.round(player.committed || 0);
},
@@ -39,11 +204,16 @@ const Betting = {
return this.round((Number(player.amountOverride) || 0) + this.committed(player));
},
// Never below zero. A seat can't have negative chips in front of it, and a typo in
// the stack field used to come back as-is — which then flowed into putIn(), where
// `Math.min(amount, -50)` moves a negative amount and *adds* chips to the table.
stackOf(state, player) {
const own = player.stack;
if (own !== null && own !== undefined && String(own).trim() !== "") return this.round(own);
if (own !== null && own !== undefined && String(own).trim() !== "") {
return Math.max(0, this.round(own));
}
const fallback = Number(state.defaultStack);
return Number.isNaN(fallback) ? 0 : this.round(fallback);
return Number.isNaN(fallback) ? 0 : Math.max(0, this.round(fallback));
},
toCall(state, player) {
@@ -70,15 +240,42 @@ const Betting = {
for (let step = 1; step <= count; step++) {
const index = this.seatAt(state, fromIndex, step);
const player = state.seats[index];
if (player.inHand && !player.folded && this.stackOf(state, player) >= 0) return index;
if (player.inHand && !player.folded) return index;
}
return null;
},
seatAt(state, fromIndex, step) {
// Who the dealer asks next. A player who is all-in is still in the hand and still
// wins pots, but has no decision left, so the action passes over them.
nextToAct(state, fromIndex) {
const count = state.seatCount;
const delta = state.direction === "counterclockwise" ? -step : step;
return ((fromIndex + delta) % count + count) % count;
for (let step = 1; step <= count; step++) {
const index = this.seatAt(state, fromIndex, step);
const player = state.seats[index];
if (player.inHand && !player.folded && this.stackOf(state, player) > this.EPSILON_CHIPS) return index;
}
return null;
},
// A player still in the hand with chips in the pot and nothing left behind. This is
// the same stack test nextToAct uses to pass over them, which is the point: the
// seat the felt marks all-in is exactly the seat the dealer will never be asked to
// act on again.
//
// The "chips in the pot" half matters. A seat with an empty stack who hasn't put
// anything in is a busted player who shouldn't have been dealt in, not a player
// who moved all-in, and calling them all-in on the felt would be a lie.
isAllIn(state, player) {
if (!player || !player.inHand || player.folded) return false;
return this.handTotal(player) > this.EPSILON_CHIPS &&
this.stackOf(state, player) <= this.EPSILON_CHIPS;
},
// Which chair sits `step` places along in the direction of play. The table owns
// this — walking the seats is exactly where the button, the blinds and the action
// used to drift apart from each other, so there is one implementation of it.
seatAt(state, fromIndex, step) {
return HoldemState.nextSeatIndex(state, fromIndex, step);
},
// A street is done when everyone still live has either matched the bet or has
@@ -135,14 +332,38 @@ const Betting = {
const delta = this.round(target - this.committed(player));
const moved = this.putIn(state, index, delta);
const newBet = this.committed(player);
const previousBet = state.currentBet || 0;
if (newBet > previousBet) {
const increase = this.round(newBet - previousBet);
// Aggression, whether or not it was a full raise — a short all-in doesn't
// reopen the betting, but the player who made it is still the last one who
// put money in, and at the showdown they are still the one who shows first.
state.streetAggressorIndex = index;
state.handAggressorIndex = index;
// What a full raise would have had to be: at least as much again as the
// last one, or the big blind if there hasn't been a raise yet.
const fullRaise = this.round(state.lastRaiseSize || state.bigBlind || 0);
const isFullRaise = increase >= fullRaise - this.EPSILON_CHIPS;
if (newBet > (state.currentBet || 0)) {
state.lastRaiseSize = this.round(newBet - (state.currentBet || 0));
state.currentBet = newBet;
// A raise reopens the action: everyone else owes a response again.
state.seats.forEach((p, i) => {
if (i !== index && p.inHand && !p.folded) p.hasActed = false;
});
if (isFullRaise) {
state.lastRaiseSize = increase;
// A real raise reopens the action: everyone else owes a response again.
state.seats.forEach((p, i) => {
if (i !== index && p.inHand && !p.folded) p.hasActed = false;
});
}
// Otherwise this is a short all-in — somebody with less than a full raise
// left shoving it in. The most misread rule in poker: it does *not* reopen
// the betting to anyone who has already acted. They owe the difference or
// they fold; they don't get to raise again because a short stack happened
// to be between them. Players yet to act keep their full rights, which
// they do here because their hasActed is already false.
//
// lastRaiseSize deliberately isn't lowered either, or the next legal raise
// would be computed off the short amount and come out too small.
}
player.hasActed = true;
@@ -156,7 +377,7 @@ const Betting = {
},
passAction(state, index) {
state.actingSeatIndex = this.isStreetComplete(state) ? null : this.nextLiveIndex(state, index);
state.actingSeatIndex = this.isStreetComplete(state) ? null : this.nextToAct(state, index);
},
// ---- Street and hand boundaries ----
@@ -181,24 +402,236 @@ const Betting = {
state.currentBet = 0;
state.lastRaiseSize = 0;
// Whoever led this street stops being the answer the moment the next one opens.
// The hand's aggressor is deliberately left alone: it's the fallback for a
// river nobody could bet on.
state.streetAggressorIndex = null;
const nextStreetIndex = this.STREETS.indexOf(state.street || "preflop") + 1;
state.street = this.STREETS[nextStreetIndex] || "river";
state.actingSeatIndex = this.liveSeats(state).length > 1
? this.nextLiveIndex(state, state.dealerSeatIndex)
: null;
// It takes two players with chips to have a bet. Once everyone left is all-in
// the rest of the board is just dealt out.
if (!this.canStillBet(state)) {
state.actingSeatIndex = null;
return;
}
// After the flop the first live player left of the button acts — which heads-up
// is the big blind, since the button is the small blind. Naming the big blind
// directly also covers a button parked on a seat nobody is sitting in, and it
// still has to be someone with chips left to bet.
const { bbIndex, headsUp } = HoldemState.getBlindSeats(state);
const bigBlindCanAct =
headsUp &&
bbIndex !== null &&
!state.seats[bbIndex].folded &&
this.stackOf(state, state.seats[bbIndex]) > this.EPSILON_CHIPS;
state.actingSeatIndex = bigBlindCanAct
? bbIndex
: this.nextToAct(state, state.dealerSeatIndex);
},
isHandOver(state) {
return this.liveSeats(state).length <= 1;
},
// Deals a fresh betting round: blinds posted, action to the seat after the big
// blind, which is where a dealer expects to be looking when the cards go out.
beginHand(state) {
// Players still in the hand who have chips left to put in. Fewer than two of them
// means the betting is finished even though the hand isn't.
seatsWithChips(state) {
return this.liveSeats(state).filter(s => this.stackOf(state, s.player) > this.EPSILON_CHIPS);
},
EPSILON_CHIPS: Money.EPSILON,
canStillBet(state) {
return this.liveSeats(state).length > 1 && this.seatsWithChips(state).length > 1;
},
// True when the hand is still live but nobody can bet again — the dealer is just
// running the board out.
isRunningOut(state) {
// Only while a hand is actually being played. Before the deal, a table of
// busted players who haven't rebought looks exactly like a table of all-ins to
// this test, and the bar was announcing a run-out over a felt with no cards and
// no chips on it.
if (!state.bettingOpen) return false;
return this.liveSeats(state).length > 1 && !this.canStillBet(state);
},
// True while nothing has happened in this hand except the blinds going in. Up to
// that point the dealer can still move the button or rename the blinds and have
// it cost nothing — which is the state a home game spends a lot of time in,
// arguing about whose turn it is before the cards come out.
onlyBlindsIn(state) {
return (state.street || "preflop") === "preflop" && state.seats.every(p => !p.hasActed);
},
// Are the blinds actually down? Moving the button or naming a blind means two
// completely different things depending on the answer, and `onlyBlindsIn` alone
// cannot tell them apart: a table sitting in setup, with no cards out and nothing
// committed, satisfies it just as well as a hand that has only just been dealt.
//
// With a hand open, the blinds are chips on the felt. Re-aiming them has to take
// those chips back and post them again from the new positions, or the badges and
// the money disagree.
//
// Before the deal it is a marker being slid round a table with nothing in the
// middle. Re-posting there would *deal the hand* — betting opened, blinds taken off
// two stacks, action on the seat after the big blind — which is not what a dealer
// means by "she's the big blind this one". Setting the positions is how a dealer
// gets ready to deal; the cards come out when they press the button, and not a
// moment before.
blindsArePosted(state) {
return !!state.bettingOpen && this.onlyBlindsIn(state);
},
// Chips the pot has been priced with but nobody has actually paid.
//
// Two ways a contribution gets into this tool. Tapping the action moves chips: the
// bet leaves the stack, sits in front of the player, and is swept into their hand
// total at the end of the street. The other way is the "without live betting" panel
// — a base pot amount, or chips counted out on a seat — where the dealer is
// recording a hand they are watching rather than driving, and nothing ever leaves
// anybody's stack.
//
// The pot wizard prices both the same way, and awarding it credits the winner. So
// in that second mode the table simply grew: measured at a three-handed table with
// a $50 base pot, paying the pot took the chips on the table from $600 to $750 and
// left the bank check blaming a rebuy that never happened.
//
// So before the pot is pushed, whatever was assumed rather than taken comes off the
// stacks that assumed it. Chips already committed or swept are left alone; they
// have been paid once already, and taking them twice is the same bug the other way
// round.
collectAssumedContributions(state) {
const taken = [];
state.seats.forEach((player, index) => {
const priced = HoldemState.computeContribution(player, state.chipLegend, state.basePotAmount);
const alreadyPaid = this.round(this.committed(player) + (Number(player.amountOverride) || 0));
const owed = this.round(priced - alreadyPaid);
if (owed <= this.EPSILON_CHIPS) return;
// Never more than they are holding. A seat priced above its own stack is a
// dealer's typo or a player who is short, and inventing the difference is
// what this whole function exists to stop.
const moved = this.round(Math.min(owed, this.stackOf(state, player)));
if (moved <= this.EPSILON_CHIPS) return;
player.stack = this.round(this.stackOf(state, player) - moved);
// Recorded where a swept bet lives, so the money is in exactly the place
// the rest of the tool looks for chips that are in the middle this hand.
player.amountOverride = this.round((Number(player.amountOverride) || 0) + moved);
taken.push({ index, name: HoldemState.displayName(player, index), amount: moved });
});
return { taken, total: this.round(taken.reduce((sum, row) => sum + row.amount, 0)) };
},
// A misdeal: the hand never happened, so every chip goes back where it came from.
//
// The rulings sheet in this tool has told dealers to redeal since the day it was
// written — first card to the wrong seat, a seat entitled to a hand dealt out, one
// of the first two cards exposed — and the tool 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 right for an abandoned
// pot and wrong for a misdeal, where the blinds and any calls belong to the people
// who put them in.
//
// Undo covers the first second of it — "↶ dealing the hand" is one tap — but only
// while the action history is still there. It lives in memory, so a dealer who
// refreshes the page mid-hand, or who is three calls in, has nothing.
//
// What is deliberately kept: the button, the blinds, the straddle and anyone
// posting in. A misdeal is the *same* hand dealt again, not the next one.
takeHandBack(state) {
const returned = [];
state.seats.forEach((player, index) => {
const back = this.round(this.committed(player) + (Number(player.amountOverride) || 0));
if (back > 0) {
player.stack = this.round(this.stackOf(state, player) + back);
returned.push({ index, name: HoldemState.displayName(player, index), amount: back });
}
player.committed = 0;
player.amountOverride = null;
player.hasActed = false;
player.folded = false;
player.cards = ["", ""];
});
state.bettingOpen = false;
state.street = "preflop";
state.currentBet = 0;
state.lastRaiseSize = 0;
state.actingSeatIndex = null;
state.streetAggressorIndex = null;
state.handAggressorIndex = null;
HoldemState.clearBoard(state);
// Nothing was posted, as far as the next rotation is concerned: the hand this
// would have counted as is being dealt again from the same button.
state.postedBlinds = null;
state.postedStraddles = [];
return {
returned,
total: this.round(returned.reduce((sum, row) => sum + row.amount, 0)),
};
},
// Pulls the blinds back off the table and posts them again from wherever the
// button and the blind assignment now say they belong. Refunding first is what
// keeps the chip count straight when the button gets passed round three or four
// times before anybody is happy with it.
repostBlinds(state) {
state.seats.forEach(player => {
const back = this.committed(player);
if (back > 0) player.stack = this.round(this.stackOf(state, player) + back);
player.committed = 0;
player.hasActed = false;
});
this.beginHand(state);
},
// Deals a fresh betting round: blinds posted, action to the seat after the big
// blind, which is where a dealer expects to be looking when the cards go out.
//
// Called when the dealer says the table is ready, not automatically when the
// previous hand ends — everything before this point is setup, where seats and the
// button can move freely because no money has been committed.
beginHand(state) {
state.bettingOpen = true;
state.street = "preflop";
state.currentBet = 0;
state.lastRaiseSize = 0;
// A fresh deal starts on an empty felt. Dealing without moving the button —
// after a misdeal, or a table set up and dealt twice — would otherwise leave
// the last hand's board sitting there being ranked against the new cards.
HoldemState.clearBoard(state);
// Posting a blind isn't aggression — a hand limped and checked all the way
// down has no aggressor at all, which is exactly the case the showdown rule
// sends to the seat left of the button.
state.streetAggressorIndex = null;
state.handAggressorIndex = null;
// Deal around anyone with no chips, before anything else reads the table.
//
// A busted player who hasn't rebought used to be dealt in anyway, and the
// result was a hand nobody could finish: the table promised a small blind from
// a seat with nothing to post it with, took it from nobody, put the action on
// the big blind with only their own money in front of them, and announced
// "3 players — all-in, running out the board" before a card was dealt.
//
// Sitting them out is what a dealer does. Seat, name and buy-in record are
// untouched, so dealing them back in is one tap when they buy chips.
this.brokeSeats(state).forEach(index => {
state.seats[index].inHand = false;
state.seats[index].folded = false;
});
// Position is read off the button all through the hand, so it has to be on
// somebody before the blinds are worked out — not on the empty seat 1 a
// brand-new table starts on.
HoldemState.ensureButtonOnPlayer(state);
state.seats.forEach(player => {
player.committed = 0;
@@ -207,15 +640,150 @@ const Betting = {
const sb = Number(state.smallBlind) || 0;
const bb = Number(state.bigBlind) || 0;
const sbIndex = this.seatAt(state, state.dealerSeatIndex, 1);
const bbIndex = this.seatAt(state, state.dealerSeatIndex, 2);
const { sbIndex, bbIndex, headsUp } = HoldemState.getBlindSeats(state);
if (sb > 0 && state.seats[sbIndex].inHand) this.putIn(state, sbIndex, sb);
if (bb > 0 && state.seats[bbIndex].inHand) this.putIn(state, bbIndex, bb);
// A null big blind means there aren't enough players to post at all. A null
// small blind with a big blind behind it is the dead small blind, which is a
// hand that gets dealt like any other — it just has one blind in it.
if (bbIndex === null) {
state.currentBet = 0;
state.actingSeatIndex = null;
state.postedBlinds = null;
state.postedStraddles = [];
return;
}
state.currentBet = this.round(Math.max(this.committed(state.seats[sbIndex]), this.committed(state.seats[bbIndex])));
state.lastRaiseSize = bb || 0;
state.actingSeatIndex = this.nextLiveIndex(state, bbIndex);
if (sb > 0 && sbIndex !== null) this.putIn(state, sbIndex, sb);
if (bb > 0) this.putIn(state, bbIndex, bb);
// Anyone the dealer said would post to come in past the blinds. It's a big
// blind like any other — it buys them a hand they'd otherwise have to wait for
// — so it goes in the middle the same way, before the count below reads it.
const posting = Array.isArray(state.postingIn) ? state.postingIn : [];
if (bb > 0) {
state.seats.forEach((player, index) => {
if (index === sbIndex || index === bbIndex) return;
if (player.inHand && posting.includes(player.id)) this.putIn(state, index, bb);
});
}
// Deliberately not cleared here. repostBlinds takes every posted chip back and
// deals again through this same function, so a post that cleared itself on the
// way in would be refunded and never retaken — the dealer moves the button and
// somebody's post quietly vanishes. startNextHand is what ends it.
// What the next hand's rotation is worked out from. Recorded here, where the
// blinds are actually posted, because by the time it's needed the seats have
// moved on and the answer can no longer be read off the table.
state.postedBlinds = { sbIndex, bbIndex };
// Who got cards. Read next hand to tell a player who has been here all along
// from one who has just walked up and had the blinds go past them.
state.dealtLastHand = state.seats.filter(p => p.inHand).map(p => p.id);
// Straddles go out after the blinds and before the cards, each one left of the
// last. Posted here rather than left to the dealer to fake as a raise, because
// a raise from that seat would hand the action to the wrong player.
const straddles = this.straddleSeats(state);
straddles.forEach(({ index, amount }) => this.putIn(state, index, amount));
state.postedStraddles = straddles.map(({ index }) => index);
state.currentBet = this.round(state.seats.reduce(
(most, player) => Math.max(most, this.committed(player)), 0));
// A straddle is a blind raise, so what has to be matched to raise again is the
// straddle itself, not the big blind underneath it: $2 blinds with a $4
// straddle make the next raise $8, the same way the $2 blind makes it $4.
// Left alone, the minimum would come out at $6 and the dealer would be talked
// out of it by every player at the table.
state.lastRaiseSize = straddles.length > 0 ? state.currentBet : (bb || 0);
// Heads-up the button is the small blind and acts first before the flop; with
// three or more it's the seat after the big blind. Either way, a blind big
// enough to put that player all-in leaves them nothing to decide.
//
// A straddle moves that seat along: first to act is left of the last straddler,
// and the straddler keeps the option, which falls out of nobody having acted
// yet.
const lastPosted = straddles.length > 0
? straddles[straddles.length - 1].index
: bbIndex;
state.actingSeatIndex = headsUp
? (this.stackOf(state, state.seats[sbIndex]) > this.EPSILON_CHIPS ? sbIndex : this.nextToAct(state, sbIndex))
: this.nextToAct(state, lastPosted);
},
// What a pot-sized raise actually is, as a "raise to" total.
//
// Not "the bet plus the pot as it stands". The raiser calls first, and their call
// is in the pot before the raise is measured off it, so the pot being matched is
// the one that exists after the call:
//
// raise to = the current bet + (pot + what they owe) × fraction
//
// Pre-flop at 1/2 that gives $7, not $5: call the $2, which makes the pot $5, then
// raise $5. Leaving the call out understates every one of these, and players say
// "pot" out loud meaning a definite number.
//
// Facing no bet this is a plain pot-sized bet — toCall is zero and currentBet is
// zero, so it comes out as the pot, which is the right answer there too.
// Rounded up to something the table's chips can actually build. Half the pot is
// constantly a half-chip amount — pre-flop at 1/2 it is $4.50, and the smallest
// chip in the default legend is a dollar, so the button offered a number the
// dealer would have to round themselves every single time.
//
// 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. Left exact there, and the wrong
// legend stays visible rather than being quietly applied.
potRaiseTo(state, index, fraction = 1) {
const player = state.seats[index];
if (!player) return 0;
const potAfterCall = this.round(this.potSoFar(state) + this.toCall(state, player));
const exact = this.round((state.currentBet || 0) + potAfterCall * fraction);
const step = Money.fromCents(Money.chipStepCents(state.chipLegend));
const bigBlind = Number(state.bigBlind) || 0;
if (step <= 0 || (bigBlind > 0 && step > bigBlind)) return exact;
return Money.upToChip(exact, state.chipLegend);
},
// Who turns their hand over first.
//
// A call the dealer makes at every showdown that isn't a fold, and one nobody at
// the table volunteers for — everyone would rather see a hand than show one. The
// tool knew who last raised and who sits left of the button and said neither, so
// the dealer was working it out from memory while five people waited.
//
// Three cases, in the order the rule asks them:
// * somebody bet or raised on the final round — they show first;
// * the round was checked through — first live player left of the button;
// * nobody could bet, because the chips were already in — the last player who
// did put money in, on whichever round that was.
//
// Returns null when there's nothing to call: one player left, or none.
showdownOrder(state) {
const live = this.liveSeats(state);
if (live.length < 2) return null;
const seatOf = index =>
Number.isInteger(index) && live.some(s => s.index === index) ? index : null;
const streetAggressor = seatOf(state.streetAggressorIndex);
if (streetAggressor !== null) {
return { index: streetAggressor, reason: "aggressor" };
}
// Nobody could have bet this round, so "no bet on the final round" isn't a
// choice anybody made and the seat left of the button hasn't earned the
// obligation. It falls to whoever last put money in.
if (!this.canStillBet(state)) {
const handAggressor = seatOf(state.handAggressorIndex);
if (handAggressor !== null) return { index: handAggressor, reason: "allInAggressor" };
}
const order = HoldemState.liveOrderFromButton(state)
.filter(index => live.some(s => s.index === index));
return order.length > 0 ? { index: order[0], reason: "leftOfButton" } : null;
},
// Winners get their share put back in front of them.
@@ -16,75 +16,147 @@ const CardPicker = {
state: null,
hooks: null,
// Which set of slots is being filled: a seat's two hole cards, or the five
// community cards. The grid, the duplicate check and the tap-to-fill behaviour are
// the same job either way — what changes is where the card is written and how many
// slots there are to walk along.
target: null,
seatIndex: null,
cardIndex: 0,
open(state, hooks, seatIndex, cardIndex) {
this.state = state;
this.hooks = hooks;
this.target = "seat";
this.seatIndex = seatIndex;
this.cardIndex = cardIndex;
this.render();
document.getElementById("cardPickerOverlay").hidden = false;
},
close() {
document.getElementById("cardPickerOverlay").hidden = true;
// The board. `limit` is how many of the five are actually out — three on the flop,
// four on the turn — so the sheet never offers a slot for a card the dealer has not
// turned over yet.
openBoard(state, hooks, cardIndex, limit = 5) {
this.state = state;
this.hooks = hooks;
this.target = "board";
this.seatIndex = null;
this.boardLimit = Math.max(1, Math.min(5, limit));
this.cardIndex = Math.min(cardIndex, this.boardLimit - 1);
this.render();
document.getElementById("cardPickerOverlay").hidden = false;
},
// Every card sitting in front of anyone else. The slot being edited is excluded,
// so re-picking the card that's already there isn't treated as a duplicate.
onBoard() {
return this.target === "board";
},
// The slots the sheet is filling, whichever set that is.
slotValues() {
return this.onBoard()
? (this.state.board || []).slice(0, this.boardLimit)
: this.state.seats[this.seatIndex].cards;
},
write(index, card) {
if (this.onBoard()) {
if (!Array.isArray(this.state.board)) this.state.board = ["", "", "", "", ""];
this.state.board[index] = card;
return;
}
this.state.seats[this.seatIndex].cards[index] = card;
},
// Why the sheet is closing, because one caller needs to tell the difference.
//
// "filled" is the sheet finishing its own job — the last empty slot it was offering
// got a card. "dismissed" is the dealer leaving: Done, Escape, or a tap on the scrim.
// A single seat's cards don't care which. A showdown does: it walks the players one
// at a time, and the two answers are "next player" and "stop asking".
//
// The hooks are dropped before the callback runs, so a caller that opens the sheet
// again from inside `onDone` installs its own and this close can't fire twice.
close(reason = "dismissed") {
document.getElementById("cardPickerOverlay").hidden = true;
const done = this.hooks && this.hooks.onDone;
this.hooks = null;
if (done) done(reason);
},
// Every card already dealt somewhere — in front of a player or face up in the
// middle. The slot being edited is excluded, so re-picking the card that's already
// there isn't treated as a duplicate.
//
// The board belongs in here as much as the hands do, and in both directions: the
// seven of clubs cannot be on the flop and in somebody's hand, whichever of the two
// the dealer is filling in at the time.
cardsInUse() {
const used = new Map();
this.state.seats.forEach((player, seat) => {
(player.cards || []).forEach((card, slot) => {
if (!card) return;
if (seat === this.seatIndex && slot === this.cardIndex) return;
if (!this.onBoard() && seat === this.seatIndex && slot === this.cardIndex) return;
used.set(card.toUpperCase(), HoldemState.displayName(player, seat));
});
});
(this.state.board || []).forEach((card, slot) => {
if (!card) return;
if (this.onBoard() && slot === this.cardIndex) return;
used.set(card.toUpperCase(), "the board");
});
return used;
},
assign(card) {
const player = this.state.seats[this.seatIndex];
player.cards[this.cardIndex] = card;
this.write(this.cardIndex, card);
this.hooks.onPick(this.seatIndex, this.cardIndex, card);
// Straight on to the second card — a dealer picking hole cards is always
// picking two, and making them re-open the picker doubles the taps.
if (this.cardIndex === 0 && !player.cards[1]) {
this.cardIndex = 1;
// Straight on to the next empty slot — a dealer picking hole cards is always
// picking two and a flop is always three, and making them re-open the picker
// between each one multiplies the taps by the only thing the sheet is for.
const values = this.slotValues();
const next = values.findIndex((value, i) => i > this.cardIndex && !value);
if (next !== -1) {
this.cardIndex = next;
this.render();
return;
}
this.close();
this.close("filled");
},
clearSlot() {
const player = this.state.seats[this.seatIndex];
player.cards[this.cardIndex] = "";
this.write(this.cardIndex, "");
this.hooks.onPick(this.seatIndex, this.cardIndex, "");
this.render();
},
render() {
const player = this.state.seats[this.seatIndex];
const used = this.cardsInUse();
// What this slot is called out loud: the flop's three cards, then the turn, then
// the river — which is what a dealer is holding when they open this, rather than
// "card 4".
boardSlotLabel(slot) {
return ["Flop 1", "Flop 2", "Flop 3", "Turn", "River"][slot] || `Card ${slot + 1}`;
},
document.getElementById("cardPickerTitle").textContent =
`${HoldemState.displayName(player, this.seatIndex)} — card ${this.cardIndex + 1}`;
render() {
const used = this.cardsInUse();
const values = this.slotValues();
document.getElementById("cardPickerTitle").textContent = this.onBoard()
? `Board — ${this.boardSlotLabel(this.cardIndex).toLowerCase()}`
: `${HoldemState.displayName(this.state.seats[this.seatIndex], this.seatIndex)} — card ${this.cardIndex + 1}`;
// Which slot the next tap fills, switchable without closing the sheet.
const slots = document.getElementById("cardPickerSlots");
slots.innerHTML = "";
[0, 1].forEach(slot => {
values.forEach((_, slot) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "pickerSlot";
btn.classList.toggle("active", slot === this.cardIndex);
const value = player.cards[slot] || "";
const value = values[slot] || "";
btn.textContent = value || "—";
if (this.onBoard()) btn.title = this.boardSlotLabel(slot);
btn.classList.toggle("cardRed", /[HD]$/i.test(value));
btn.addEventListener("click", () => {
this.cardIndex = slot;
@@ -105,8 +177,12 @@ const CardPicker = {
btn.className = "pickerCard";
btn.classList.toggle("cardRed", suit.red);
btn.disabled = !!takenBy;
if (takenBy) btn.title = `Already dealt to ${takenBy}`;
if ((player.cards[this.cardIndex] || "").toUpperCase() === card) {
if (takenBy) {
btn.title = takenBy === "the board"
? "Already face up on the board"
: `Already dealt to ${takenBy}`;
}
if ((values[this.cardIndex] || "").toUpperCase() === card) {
btn.classList.add("selected");
}
@@ -46,7 +46,10 @@ const Toast = {
host.appendChild(toast);
// Next frame, so the entrance transition actually runs.
requestAnimationFrame(() => toast.classList.add("toastVisible"));
requestAnimationFrame(() => {
toast.classList.add("toastVisible");
this.publishHeight();
});
this.timer = setTimeout(
() => this.dismiss(),
@@ -61,6 +64,24 @@ const Toast = {
}
const host = document.getElementById("toastHost");
if (host) host.innerHTML = "";
this.publishHeight();
},
// How much of the bottom of the window this is standing on.
//
// The toast is fixed, so it is not in the flow and nothing scrolling a control into
// view knows it is there — measured on the desktop sweep, tabbing to Common rulings
// while a toast was up left the focus ring entirely behind it. The stylesheet adds
// this to the bar's own height to work out where a focused control has to stop, so
// it is published rather than guessed: the toast is one line or two depending on the
// message, and on a narrow screen the same message is taller again.
publishHeight() {
if (typeof document === "undefined" || !document.documentElement) return;
const host = document.getElementById("toastHost");
const height = host && host.firstElementChild
? Math.round(host.getBoundingClientRect().height)
: 0;
document.documentElement.style.setProperty("--toastHeight", `${height}px`);
},
};
@@ -15,8 +15,16 @@ const HandHistoryExport = {
}
},
toCsv(handHistory, standings = []) {
const rows = [["Hand #", "Time", "Pot", "Payouts"]];
// Money columns go in as bare numbers. Written as "$1,250,000.00" they import as
// text, so the one thing anybody opens this file to do — total a column and see
// who owes what — doesn't work without cleaning every cell first. The commas were
// quoted correctly, so the file parsed; it just wouldn't add up.
toCsv(handHistory, standings = [], { notes = "" } = {}) {
// The rake gets its own column rather than being folded into the pot: the two
// numbers answer different questions — what was in the middle, and what the
// house took out of it — and a night's worth of the second one is what the box
// is counted against.
const rows = [["Hand #", "Time", "Pot", "House take", "Payouts"]];
// handHistory is stored newest-first; the export reads better oldest-first.
[...handHistory].reverse().forEach(entry => {
const payouts = (entry.payouts || [])
@@ -25,7 +33,8 @@ const HandHistoryExport = {
rows.push([
entry.handNumber,
this.formatTimestamp(entry.timestamp),
Money.format(entry.pot),
Money.forSheet(entry.pot),
Money.forSheet(entry.rake || 0),
payouts,
]);
});
@@ -37,18 +46,59 @@ const HandHistoryExport = {
rows.push(["Session Standings"]);
// Bought in and chips now are what a cash-out is settled from; paid in and
// won describe the hands. Both belong in the file the dealer hands over.
rows.push(["Player", "Paid In", "Won", "Net", "Bought In", "Chips Now", "Up/Down"]);
//
// Notes go last so the money columns keep the shape a spreadsheet expects.
// "IOU: $20 to Ann" is exactly the kind of thing that has to be in the file
// somebody settles up from, and it was only ever visible inside that seat's
// own editor.
rows.push(["Player", "Paid In", "Won", "Net", "Bought In", "Chips Now", "Up/Down", "Notes"]);
standings.forEach(row => {
rows.push([
row.name,
Money.format(row.in),
Money.format(row.won),
Money.formatSigned(row.net),
Money.format(row.boughtIn || 0),
Money.format(row.chipsNow || 0),
Money.formatSigned(row.cashPosition || 0),
// Somebody who has gone home is marked in the file the same way the
// panel marks them, because this is the copy that gets read at the
// end of the night when nobody remembers who has already been paid.
row.cashedOut ? `${row.name} (paid out)` : row.name,
Money.forSheet(row.in),
Money.forSheet(row.won),
Money.forSheet(row.net),
Money.forSheet(row.boughtIn || 0),
Money.forSheet(row.chipsNow || 0),
Money.forSheet(row.cashPosition || 0),
row.notes || "",
]);
});
// And the answer the standings are read to get to. Whoever opens this file
// wants the payment list, not to redo the arithmetic in a spreadsheet.
const plan = SettleUp.plan(standings);
if (plan.payments.length > 0) {
rows.push([]);
rows.push(["Settle Up"]);
rows.push(["Pays", "Receives", "Amount"]);
plan.payments.forEach(payment => {
rows.push([payment.from, payment.to, Money.forSheet(payment.amount)]);
});
if (!plan.balanced) {
// The file is the paper trail, so the caveat travels with it rather
// than living only in a sidebar nobody exported.
rows.push([
plan.shortBy > 0
? `Unpaid: ${Money.forSheet(plan.shortBy)} — more chips in play than cash taken`
: `Unclaimed: ${Money.forSheet(plan.overBy)} — more cash taken than chips in play`,
]);
}
}
}
// The table's own notes — side bets, IOUs, the promo running until midnight.
// The dealer typed them because they matter to the night, and the export is the
// thing that outlives the browser tab; leaving them behind meant the one place
// the context was written down was the one place it couldn't be handed over.
// csvField quotes the line breaks, so a multi-line note survives as one cell.
if (notes && notes.trim()) {
rows.push([]);
rows.push(["Table Notes"]);
rows.push([notes.trim()]);
}
return rows.map(row => row.map(f => this.csvField(f)).join(",")).join("\r\n");
@@ -61,8 +111,8 @@ const HandHistoryExport = {
return `holdem-hand-history-${stamp}.csv`;
},
download(handHistory, standings = []) {
const csv = this.toCsv(handHistory, standings);
download(handHistory, standings = [], options = {}) {
const csv = this.toCsv(handHistory, standings, options);
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
@@ -0,0 +1,285 @@
// What beats what.
//
// The dealer has always been the one who decides who won: the pot wizard asks "who won
// the hand?" and pays whoever gets ticked. That is the right default — most hands are
// won without a showdown, and at a home game the table reads the cards out loud between
// them. But when the board is on the felt and the hands are on their backs, the tool
// knows the answer, and reading it off a screen beats three people counting kickers.
//
// Cards are the same two-character strings the rest of the tool has always stored:
// a rank from `RANKS` and a suit from S, H, D, C. Case is not significant.
//
// Nothing in here touches the DOM, the table, or money. It compares cards, and the pot
// wizard decides what to do about it.
const HandRank = {
RANKS: "23456789TJQKA",
// High to low. The number is the hand's category and nothing else — kickers and
// the rest of the tie-breaking live in the `tiebreak` array beside it.
STRAIGHT_FLUSH: 8,
FOUR_OF_A_KIND: 7,
FULL_HOUSE: 6,
FLUSH: 5,
STRAIGHT: 4,
THREE_OF_A_KIND: 3,
TWO_PAIR: 2,
ONE_PAIR: 1,
HIGH_CARD: 0,
// 2 through 14. Null for anything that isn't a card, which is how a half-filled
// slot gets told apart from a bad one.
rankValue(card) {
if (typeof card !== "string" || card.length < 2) return null;
const index = this.RANKS.indexOf(card[0].toUpperCase());
return index < 0 ? null : index + 2;
},
suitOf(card) {
if (typeof card !== "string" || card.length < 2) return null;
const suit = card[card.length - 1].toUpperCase();
return "SHDC".includes(suit) ? suit : null;
},
isCard(card) {
return this.rankValue(card) !== null && this.suitOf(card) !== null;
},
// Singular and plural, for reading a hand out loud.
NAMES: {
14: ["ace", "aces"], 13: ["king", "kings"], 12: ["queen", "queens"],
11: ["jack", "jacks"], 10: ["ten", "tens"], 9: ["nine", "nines"],
8: ["eight", "eights"], 7: ["seven", "sevens"], 6: ["six", "sixes"],
5: ["five", "fives"], 4: ["four", "fours"], 3: ["three", "threes"],
2: ["two", "twos"],
},
name(rank, plural = false) {
const pair = this.NAMES[rank];
return pair ? pair[plural ? 1 : 0] : "?";
},
// ---- The five-card hand -------------------------------------------------------
// Exactly five cards, ranked. Everything else is built on this.
//
// Five rather than seven because five is the hand a player actually holds, and it
// is small enough to check by hand — which is the whole argument for doing it this
// way. The seven-card version below tries all twenty-one five-card hands and keeps
// the best, which is a hand of poker's own definition of "your best five" written
// out literally. A dealer resolves one pot a hand with at most a dozen players in
// it; two hundred and fifty comparisons of five cards is not a cost worth trading
// a subtler algorithm's correctness for.
evaluate5(cards) {
if (!Array.isArray(cards) || cards.length !== 5) return null;
if (!cards.every(card => this.isCard(card))) return null;
const ranks = cards.map(card => this.rankValue(card)).sort((a, b) => b - a);
const suits = cards.map(card => this.suitOf(card));
const flush = suits.every(suit => suit === suits[0]);
// How many of each rank, ordered by how many first and by rank second — so
// the front of this list is always the rank that decides the hand.
const counts = new Map();
ranks.forEach(rank => counts.set(rank, (counts.get(rank) || 0) + 1));
const groups = [...counts.entries()]
.sort((a, b) => (b[1] - a[1]) || (b[0] - a[0]));
const shape = groups.map(([, count]) => count);
const byGroup = groups.map(([rank]) => rank);
const straightHigh = this.straightHigh(ranks);
const made = (category, tiebreak) => ({ category, tiebreak, cards: [...cards] });
if (flush && straightHigh) return made(this.STRAIGHT_FLUSH, [straightHigh]);
if (shape[0] === 4) return made(this.FOUR_OF_A_KIND, byGroup);
if (shape[0] === 3 && shape[1] === 2) return made(this.FULL_HOUSE, byGroup);
if (flush) return made(this.FLUSH, ranks);
if (straightHigh) return made(this.STRAIGHT, [straightHigh]);
if (shape[0] === 3) return made(this.THREE_OF_A_KIND, byGroup);
if (shape[0] === 2 && shape[1] === 2) return made(this.TWO_PAIR, byGroup);
if (shape[0] === 2) return made(this.ONE_PAIR, byGroup);
return made(this.HIGH_CARD, ranks);
},
// The top card of the straight these five ranks make, or null.
//
// The wheel is the exception the rest of the ordering has to be told about: A-2-3-4-5
// is a straight, and it is the *lowest* one, so its ace counts as five rather than
// as fourteen. Getting that wrong is how A-2-3-4-5 comes to beat 10-J-Q-K-A.
straightHigh(sortedDesc) {
const distinct = [...new Set(sortedDesc)];
if (distinct.length !== 5) return null;
if (distinct[0] - distinct[4] === 4) return distinct[0];
// Ace, five, four, three, two.
if (distinct[0] === 14 && distinct[1] === 5 && distinct[4] === 2) return 5;
return null;
},
// ---- The best five out of however many ----------------------------------------
// The best five-card hand available from five, six or seven cards — hole cards and
// board together, which is what Hold'em asks for. Returns null if there aren't five
// real cards, which is the ordinary state of a hand still being dealt.
best(cards) {
const real = (cards || []).filter(card => this.isCard(card));
if (real.length < 5) return null;
// A card in two places at once is a mis-entry, not a hand — say so rather than
// ranking a board that has the same king on it twice.
if (new Set(real.map(card => card.toUpperCase())).size !== real.length) return null;
let best = null;
this.eachFive(real, five => {
const hand = this.evaluate5(five);
if (!best || this.compare(hand, best) > 0) best = hand;
});
return best;
},
// Every five-card subset, without building an array of them.
eachFive(cards, visit) {
const n = cards.length;
for (let a = 0; a < n - 4; a++) {
for (let b = a + 1; b < n - 3; b++) {
for (let c = b + 1; c < n - 2; c++) {
for (let d = c + 1; d < n - 1; d++) {
for (let e = d + 1; e < n; e++) {
visit([cards[a], cards[b], cards[c], cards[d], cards[e]]);
}
}
}
}
}
},
// Positive if `a` wins, negative if `b` does, zero for a chop. A chop is a real
// answer here rather than a rounding of one: two players playing the same board
// split the pot, and the fifth card decides it far more often than a table expects.
compare(a, b) {
if (!a && !b) return 0;
if (!a) return -1;
if (!b) return 1;
if (a.category !== b.category) return a.category - b.category;
const length = Math.max(a.tiebreak.length, b.tiebreak.length);
for (let i = 0; i < length; i++) {
const diff = (a.tiebreak[i] || 0) - (b.tiebreak[i] || 0);
if (diff !== 0) return diff;
}
return 0;
},
// ---- Saying it out loud -------------------------------------------------------
// What a dealer says when they turn the hand over. Named to the level that settles
// it and no further: "two pair, aces and eights" is the call, and the kicker only
// gets a mention where the tool has had to use one to break a tie.
describe(hand) {
if (!hand) return "";
const [first, second] = hand.tiebreak;
switch (hand.category) {
case this.STRAIGHT_FLUSH:
return first === 14 ? "Royal flush" : `Straight flush, ${this.name(first)} high`;
case this.FOUR_OF_A_KIND:
return `Four of a kind, ${this.name(first, true)}`;
case this.FULL_HOUSE:
return `Full house, ${this.name(first, true)} full of ${this.name(second, true)}`;
case this.FLUSH:
return `Flush, ${this.name(first)} high`;
case this.STRAIGHT:
return `Straight, ${this.name(first)} high`;
case this.THREE_OF_A_KIND:
return `Three of a kind, ${this.name(first, true)}`;
case this.TWO_PAIR:
return `Two pair, ${this.name(first, true)} and ${this.name(second, true)}`;
case this.ONE_PAIR:
return `Pair of ${this.name(first, true)}`;
default:
return `${this.name(first).charAt(0).toUpperCase()}${this.name(first).slice(1)} high`;
}
},
// The same call with the article a table would put in front of it. Not derivable
// from the first letter: it is "a full house" and "a flush", but "four of a kind"
// and "two pair" take none at all, and "an ace high" is not something anybody says.
// So it belongs to the category, like the name does.
ARTICLED: {
8: hand => (hand.tiebreak[0] === 14 ? "a royal flush" : "a"),
7: () => null,
6: () => "a",
5: () => "a",
4: () => "a",
3: () => null,
2: () => null,
1: () => "a",
0: () => null,
},
// "wins with X" — the phrase, ready to drop into a sentence.
phrase(hand) {
if (!hand) return "the best hand";
const described = this.describe(hand);
const article = (this.ARTICLED[hand.category] || (() => null))(hand);
if (article === null) return described.toLowerCase();
if (article !== "a") return article;
return `a ${described.toLowerCase()}`;
},
// ---- The showdown -------------------------------------------------------------
// Rank a set of players against a board.
//
// `players` is [{ id, cards }, ...] — the two in front of each of them. Every player
// is returned whether or not they could be ranked, in the order they were given, so
// the caller can show a row for each and say which ones it could not read. `place`
// is 1 for the winner and shared by a chop; a player whose cards aren't known has a
// null hand and no place.
//
// `complete` is the question the pot wizard actually asks: can this be trusted to
// pay the table. It is only true when the board is all five cards and every single
// player handed in has two of their own — one unknown hand and the tool cannot say
// who won, only who is winning so far, which is not the same thing and must not be
// dressed up as it.
showdown(board, players) {
const boardCards = (board || []).filter(card => this.isCard(card));
const boardComplete = boardCards.length === 5;
const ranked = (players || []).map(player => {
const hole = (player.cards || []).filter(card => this.isCard(card));
const hand = hole.length === 2 && boardComplete
? this.best([...hole, ...boardCards])
: null;
return { id: player.id, cards: hole, hand, place: null };
});
const readable = ranked.filter(row => row.hand);
// A duplicated card anywhere means somebody has been mis-entered, and the
// ranking would be of a deck that doesn't exist.
const all = [...boardCards, ...ranked.flatMap(row => row.cards)].map(c => c.toUpperCase());
const duplicated = new Set(all).size !== all.length;
const sorted = [...readable].sort((a, b) => this.compare(b.hand, a.hand));
let place = 0;
sorted.forEach((row, i) => {
if (i === 0 || this.compare(row.hand, sorted[i - 1].hand) !== 0) place = i + 1;
row.place = place;
});
const winners = sorted.filter(row => row.place === 1);
const complete = boardComplete && !duplicated &&
ranked.length > 0 && readable.length === ranked.length;
return {
players: ranked,
winners: complete ? winners.map(row => row.id) : [],
boardComplete,
duplicated,
complete,
// Who the tool could not read, so the dialog can name them rather than
// just declining to help.
unknown: ranked.filter(row => !row.hand).map(row => row.id),
};
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = HandRank;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,147 @@
// Keyboard and screen-reader behaviour for every overlay in the tool, in one place.
//
// There are six of them — seat menu, card picker, add chips, rulings, pot wizard,
// confirm — opened and closed by five different modules, and each one was missing
// the same three things: focus never moved into the dialog, Tab walked straight out
// of it into the page behind the scrim, and Escape did nothing (only the rulings
// sheet handled it). Measured before this: 0 of 5 moved focus in, 5 of 5 leaked Tab,
// 1 of 5 closed on Escape.
//
// Rather than patch that into five modules, this watches the `hidden` attribute every
// overlay already toggles. Nothing else had to change, and an overlay added later
// gets the same behaviour without anyone remembering to wire it up — which is the
// failure this is fixing in the first place.
const Modal = {
// `:not([hidden])` matters — a sheet that is itself hidden inside an open overlay
// must not be a Tab stop.
FOCUSABLE: [
"a[href]",
"button:not([disabled])",
'input:not([disabled]):not([type="hidden"])',
"select:not([disabled])",
"textarea:not([disabled])",
'[tabindex]:not([tabindex="-1"])',
].join(","),
open: [],
mount() {
document.querySelectorAll('[id$="Overlay"]').forEach(overlay => this.watch(overlay));
// Capture phase: the dialog gets first refusal on Escape and Tab, before any
// table-level shortcut sees them.
document.addEventListener("keydown", e => this.onKeyDown(e), true);
},
watch(overlay) {
const sync = () => {
const isOpen = !overlay.hidden;
const known = this.open.some(entry => entry.overlay === overlay);
if (isOpen && !known) this.onOpened(overlay);
else if (!isOpen && known) this.onClosed(overlay);
};
new MutationObserver(sync).observe(overlay, { attributes: true, attributeFilter: ["hidden"] });
sync();
},
sheetOf(overlay) {
return overlay.querySelector('[role="dialog"], [role="alertdialog"]') || overlay.firstElementChild || overlay;
},
focusableIn(overlay) {
return [...overlay.querySelectorAll(this.FOCUSABLE)].filter(el => {
if (el.closest("[hidden]")) return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
});
},
onOpened(overlay) {
const sheet = this.sheetOf(overlay);
// aria-modal is only true while the thing is actually open, so it's set here
// rather than baked into the markup.
if (sheet) sheet.setAttribute("aria-modal", "true");
this.open.push({ overlay, returnFocus: document.activeElement });
// Focus the first real control. A dialog that leaves focus on the button
// behind it reads, to a screen reader, as though nothing happened at all.
// Content-only sheets get focus on the sheet so the title is announced.
const focusable = this.focusableIn(overlay);
const target = focusable[0];
if (target) {
target.focus({ preventScroll: true });
} else if (sheet) {
if (!sheet.hasAttribute("tabindex")) sheet.setAttribute("tabindex", "-1");
sheet.focus({ preventScroll: true });
}
},
onClosed(overlay) {
const index = this.open.findIndex(entry => entry.overlay === overlay);
if (index === -1) return;
const [entry] = this.open.splice(index, 1);
const sheet = this.sheetOf(overlay);
if (sheet) sheet.removeAttribute("aria-modal");
// Put the dealer back where they were. Without this, closing the pot wizard
// drops focus to the top of the document and the next Tab starts from the
// page header.
const back = entry.returnFocus;
if (back && back.isConnected && typeof back.focus === "function") {
back.focus({ preventScroll: true });
}
},
topmost() {
return this.open.length ? this.open[this.open.length - 1] : null;
},
// Escape presses the dialog's own cancel/close button rather than just hiding the
// overlay, so whatever that button does — resolving the confirm promise, clearing
// the add-chips draft — still happens. Only if there isn't one does it hide the
// overlay directly.
dismiss(overlay) {
const closer = overlay.querySelector("[data-modal-close]");
if (closer) closer.click();
else overlay.hidden = true;
},
onKeyDown(event) {
const top = this.topmost();
if (!top) return;
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
this.dismiss(top.overlay);
return;
}
if (event.key !== "Tab") return;
// Keep Tab inside the dialog. Everything behind the scrim is unreachable by
// mouse, so letting the keyboard wander back there is a trap with no way out.
const focusable = this.focusableIn(top.overlay);
if (focusable.length === 0) {
event.preventDefault();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = document.activeElement;
if (event.shiftKey && (active === first || !top.overlay.contains(active))) {
event.preventDefault();
last.focus({ preventScroll: true });
} else if (!event.shiftKey && (active === last || !top.overlay.contains(active))) {
event.preventDefault();
first.focus({ preventScroll: true });
}
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = Modal;
}
+176 -4
View File
@@ -1,10 +1,182 @@
// Shared money formatting for every casino surface. Kept apart from the table's
// rendering so the quick-split calculator can pull in the formatter without dragging
// the whole seat/chip UI along with it.
// Shared money handling for every casino surface. Kept apart from the table's
// rendering so the quick-split calculator can pull it in without dragging the whole
// seat/chip UI along with it.
//
// Everything the dealer touches is money, and money is decimal, so every module used
// to carry its own `Math.round(x * 100) / 100`. That's one rounding rule written out
// sixteen times in six files, which is sixteen chances for one of them to drift.
// It lives here now, and the modules that do arithmetic call these instead.
const Money = {
// Half a cent. Chip comparisons need a tolerance: a stack that lands on
// 0.0000000001 after a split is empty as far as a dealer is concerned, and an
// exact `> 0` would treat that player as still having chips to bet.
EPSILON: 0.004,
// All money maths goes through cents, so 0.1 + 0.2 never reaches a payout.
toCents(amount) {
return Math.round((Number(amount) || 0) * 100);
},
fromCents(cents) {
return cents / 100;
},
round(amount) {
return this.toCents(amount) / 100;
},
// Cents only when there are cents.
//
// A chip game deals in whole numbers nearly all the time, and a trailing ".00"
// is two characters that say nothing — measured at ~22px, against the 64px a
// seat puck has to show a stack in. "$1,250,000.00" doesn't fit and wraps;
// "$1,250,000" does. Amounts that genuinely carry cents still show them, so a
// 25c-blind game reads exactly as it did.
format(amount) {
const value = Number(amount) || 0;
return `$${value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
const hasCents = this.toCents(value) % 100 !== 0;
return `$${value.toLocaleString(undefined, {
minimumFractionDigits: hasCents ? 2 : 0,
maximumFractionDigits: 2,
})}`;
},
// The same amount for a spreadsheet rather than for a person: no currency symbol
// and no thousands separators, because those are what make a column of money
// import as text and refuse to add up.
forSheet(amount) {
return (this.toCents(amount) / 100).toFixed(2);
},
// The chips to actually push, for an amount.
//
// A payout figure is the answer to the arithmetic, not to the dealer's job: they
// still have to work out that $132 is two blacks, a green, a red and two whites
// while six people watch. The chip legend already knows the denominations.
//
// Largest first, which is greedy and therefore not optimal for an arbitrary set of
// values — but 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 gets a valid answer that might
// use one chip more than it had to.
//
// Anything the smallest chip can't make comes back as `remainder` rather than being
// rounded away: a 50c share in a game whose smallest chip is a dollar is a real
// problem for the dealer to solve, not a rounding error to hide.
// The smallest chip the table has said it plays with, in cents. Zero when no
// denominations are set, which means the tool has been told nothing about what
// chips exist and shouldn't pretend otherwise.
chipStepCents(legend) {
const values = (legend || [])
.map(chip => this.toCents(chip.value))
.filter(cents => cents > 0);
return values.length > 0 ? Math.min(...values) : 0;
},
// Rounds an amount up to something the table can actually build out of its chips.
//
// Up rather than down or nearest: these are bet sizes, and a pot-sized bet that
// has been shaved is no longer a pot-sized bet. Rounding up also can't drop an
// amount below a legal minimum, which rounding the other way can.
upToChip(amount, legend) {
const step = this.chipStepCents(legend);
if (step <= 0) return this.round(amount);
const cents = this.toCents(amount);
if (cents <= 0) return this.round(amount);
return this.fromCents(Math.ceil(cents / step) * step);
},
// And down, for money coming *out* of a pot rather than going into it.
//
// The rake is the one amount in this tool that leaves the table, so the direction
// of the rounding is a decision about whose money it is. Down: a house that can't
// make 40c out of its chips takes nothing rather than a dollar, and the players
// keep the difference. Being wrong in the players' favour is the only way to be
// wrong about this that nobody has to argue about.
downToChip(amount, legend) {
const step = this.chipStepCents(legend);
if (step <= 0) return this.round(amount);
const cents = this.toCents(amount);
if (cents <= 0) return 0;
return this.fromCents(Math.floor(cents / step) * step);
},
chipBreakdown(amount, legend) {
let cents = this.toCents(amount);
if (cents <= 0) return { chips: [], remainder: 0 };
const values = (legend || [])
.map(chip => ({ chip, cents: this.toCents(chip.value) }))
.filter(entry => entry.cents > 0)
.sort((a, b) => b.cents - a.cents);
const chips = [];
values.forEach(entry => {
const count = Math.floor(cents / entry.cents);
if (count <= 0) return;
cents -= count * entry.cents;
chips.push({ value: this.fromCents(entry.cents), count, color: entry.chip.color });
});
return { chips, remainder: this.fromCents(cents) };
},
// "2×$50, 1×$25, 2×$1" — the breakdown as a dealer would say it out loud.
//
// A table with no chip legend gets nothing rather than "$132 no chip covers": it
// hasn't told us its denominations, so there is no advice to give and the amount
// stands on its own. A legend that exists but can't reach the amount is different,
// and does say so.
formatChipBreakdown(amount, legend) {
const hasDenominations = (legend || []).some(chip => this.toCents(chip.value) > 0);
if (!hasDenominations) return "";
const { chips, remainder } = this.chipBreakdown(amount, legend);
if (chips.length === 0 && remainder === 0) return "";
const parts = chips.map(chip => `${chip.count}×${this.format(chip.value)}`);
if (remainder > 0) parts.push(`${this.format(remainder)} no chip covers`);
return parts.join(", ");
},
// Should focusing this field select what's already in it?
//
// Every amount box in this tool opens pre-filled with a suggestion: the raise pad
// offers the minimum, Add chips offers a buy-in, the seat editor offers the stack
// it already has. A dealer taps the box because they want a *different* number,
// and a tap puts the caret after the suggestion instead of over it. Measured on a
// phone: the raise pad showing 4, tap, type "60" — the field reads 460. Add chips
// showing 200, tap, type "50" — 20050.
//
// Only number boxes. 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.
selectsOnFocus(field) {
if (!field || field.tagName !== "INPUT") return false;
const type = String(field.type || "").toLowerCase();
if (type === "number") return true;
return type === "text" && String(field.inputMode || "").toLowerCase() === "decimal";
},
// Would a wheel over this field change what it says?
//
// True only for a focused `<input type="number">`, which a browser treats as a
// spinbutton and lets the wheel drive. Every other way of driving one is deliberate
// — the arrow keys, the stepper arrows, typing — and all of them are left alone.
// Scrolling is the one nobody means: the dealer is reaching for another panel and
// the cursor happens to still be over a field they touched a moment ago.
//
// A predicate rather than a listener so the rule can be tested without a browser,
// the same way selectsOnFocus is. main.js does the blurring.
wheelWouldEdit(field, activeElement) {
// The tag check is belt and braces and known to be so: nothing but an <input>
// carries type="number", so the line below already excludes every textarea,
// select and button on the page. Deleting it passes every test in this file,
// which was checked rather than assumed. It stays for symmetry with
// selectsOnFocus above, not because it is load-bearing.
if (!field || field.tagName !== "INPUT") return false;
if (String(field.type || "").toLowerCase() !== "number") return false;
// An unfocused spinbutton ignores the wheel, so there is nothing to defend and
// blurring something that isn't focused would be a no-op with a side effect.
return activeElement === field;
},
// "+$40.00" / "-$40.00" rather than "$-40.00" — a standings column is read at a
@@ -4,7 +4,7 @@
// A "contribution entry" is { id, remaining, folded }. `remaining` starts as the amount
// that player put in the pot and is whittled down round by round as money gets paid out.
const PotCalculator = {
EPSILON: 0.004,
EPSILON: Money.EPSILON,
// Builds the initial entry list from raw contributions, keeping folded players in
// (their chips are still "dead money" owed to whoever wins) while excluding anyone
@@ -33,24 +33,39 @@ const PotCalculator = {
// to their own contribution, and only players who reached that height can win it.
// Folded money stays in — it's dead money owed to whoever wins the layer it fell in.
layers(players) {
const live = players.filter(p => p.contribution > this.EPSILON);
// Uncalled chips were never played for, so they come off the top before any
// pot is built. Deriving the pots from what's left is what keeps this function
// and uncalledReturns() from disagreeing about the same money — they used to
// decide "uncontested" separately, and an overbet that its own bettor was
// still live for got announced as a side pot only they could win.
const returned = new Map(this.uncalledReturns(players).map(r => [r.id, Money.toCents(r.amount)]));
const live = players
.map(p => ({ ...p, cents: Money.toCents(p.contribution) - (returned.get(p.id) || 0) }))
.filter(p => p.cents > 0);
if (live.length === 0) return [];
const heights = [...new Set(live.map(p => Math.round(p.contribution * 100)))].sort((a, b) => a - b);
const heights = [...new Set(live.map(p => p.cents))].sort((a, b) => a - b);
const layers = [];
let floor = 0;
heights.forEach(height => {
const band = height - floor;
const payers = live.filter(p => Math.round(p.contribution * 100) >= height);
const payers = live.filter(p => p.cents >= height);
const amount = band * payers.length;
const eligible = payers.filter(p => !p.folded).map(p => p.id);
// A band nobody can win isn't a pot — it's an overbet that never got
// called, and it goes back to whoever put it in. Announcing it as a side
// pot would overstate what's actually being played for.
if (amount > 0 && eligible.length > 0) {
layers.push({ amount: amount / 100, eligible });
if (amount > 0) {
if (eligible.length > 0) {
layers.push({ amount: Money.fromCents(amount), eligible });
} else if (layers.length > 0) {
// Everyone who reached this height folded, so it isn't a pot of its
// own — it's dead money owed to whoever wins the layer underneath.
// Dropping it would quietly shrink the announced pot.
const below = layers[layers.length - 1];
below.amount = Money.round(below.amount + Money.fromCents(amount));
}
// With no layer underneath either, nobody in the hand is live at all.
// That's a fold flag the dealer needs to fix, not a payout to guess at.
}
floor = height;
});
@@ -66,7 +81,7 @@ const PotCalculator = {
previous.eligible.every(id => layer.eligible.includes(id));
if (sameEligibility) {
previous.amount = Math.round((previous.amount + layer.amount) * 100) / 100;
previous.amount = Money.round(previous.amount + layer.amount);
} else {
merged.push({ ...layer });
}
@@ -79,21 +94,28 @@ const PotCalculator = {
},
// Chips nobody could call, which the dealer pushes back rather than into the pot.
// Anything above the biggest live contribution was never contested — most often an
// overbet by someone who then folded.
//
// A bet is only called up to what somebody *else* was willing to put in, so the
// ceiling for each player is the largest contribution among everyone but them.
// Measuring against the whole table instead — including the bettor's own chips —
// makes the biggest bet its own yardstick, so the one case this exists for (you
// shove, a short stack calls for less) comes back empty.
uncalledReturns(players) {
const contributing = players.filter(p => p.contribution > this.EPSILON);
const liveCents = contributing.filter(p => !p.folded).map(p => Math.round(p.contribution * 100));
// Nobody live at all means the fold flags are wrong, not that every chip on
// the table was uncalled. That's a correction for the dealer to make, so say
// nothing here and let the wizard ask them to fix it.
if (liveCents.length === 0) return [];
if (contributing.filter(p => !p.folded).length === 0) return [];
const cap = Math.max(...liveCents);
const cents = contributing.map(p => Money.toCents(p.contribution));
return contributing
.map(p => ({ id: p.id, amount: (Math.round(p.contribution * 100) - cap) / 100 }))
.map((p, index) => {
const others = cents.filter((_, i) => i !== index);
const cap = others.length > 0 ? Math.max(...others) : 0;
return { id: p.id, amount: Money.fromCents(cents[index] - cap) };
})
.filter(r => r.amount > this.EPSILON);
},
@@ -113,12 +135,12 @@ const PotCalculator = {
if (e.remaining <= this.EPSILON) return e;
const used = Math.min(e.remaining, cap);
payoutPool += used;
return { ...e, remaining: Math.round((e.remaining - used) * 100) / 100 };
return { ...e, remaining: Money.round(e.remaining - used) };
});
payoutPool = Math.round(payoutPool * 100) / 100;
const baseShareCents = Math.floor((payoutPool * 100) / winners.length);
let leftoverCents = Math.round(payoutPool * 100) - baseShareCents * winners.length;
payoutPool = Money.round(payoutPool);
const baseShareCents = Math.floor(Money.toCents(payoutPool) / winners.length);
let leftoverCents = Money.toCents(payoutPool) - baseShareCents * winners.length;
const perWinner = winnerIds
.filter(id => winners.some(w => w.id === id))
@@ -133,7 +155,7 @@ const PotCalculator = {
i++;
}
const result = perWinner.map(w => ({ id: w.id, amount: w.amountCents / 100 }));
const result = perWinner.map(w => ({ id: w.id, amount: Money.fromCents(w.amountCents) }));
const remainingContenders = this.getContenders(updatedEntries);
return {
@@ -18,6 +18,7 @@ const PotWizard = {
this.playerLabelById = new Map(
players.map(p => [p.id, this.labelFor(p, state.seats.indexOf(p))])
);
this.seatIndexById = new Map(players.map(p => [p.id, state.seats.indexOf(p)]));
// The label carries name and cards together for the log and the payout lines;
// the choice rows want them separately so each can be styled and read on its own.
this.nameById = new Map(
@@ -29,7 +30,10 @@ const PotWizard = {
const contributions = players.map(p => ({
id: p.id,
contribution: HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount),
folded: p.folded,
// A seat switched off mid-hand still has its chips in the pot, but it
// can no more win them than a folded one can — same dead money, and the
// wizard must not offer it as a winner.
folded: p.folded || HoldemState.isDeadMoney(p),
}));
this.entries = PotCalculator.buildEntries(contributions);
// Kept so the wizard can show the whole breakdown up front, not just the layer
@@ -57,7 +61,54 @@ const PotWizard = {
this.totals.set(id, (this.totals.get(id) || 0) + amount);
},
// A chopped pot rarely divides exactly, and the odd chip goes to the first player
// left of the button — not to whoever the dealer happened to tick first. The
// calculator hands leftovers out in the order it's given the winners, so ordering
// them here is what makes the payout match the rule.
orderFromButton(ids) {
const distance = new Map();
for (let step = 1; step <= this.state.seatCount; step++) {
distance.set(HoldemState.nextSeatIndex(this.state, this.state.dealerSeatIndex, step), step);
}
return [...ids].sort((a, b) =>
(distance.get(this.seatIndexById.get(a)) ?? 0) - (distance.get(this.seatIndexById.get(b)) ?? 0)
);
},
renderStep() {
this.renderStepInto();
this.markOverflow();
},
// Say when the list runs on past the bottom of the dialog.
//
// The body scrolls and the footer stays put, which is the right shape and was as far
// as it went: nothing on screen said there was anything below. Measured on a Galaxy
// S8, a three-way pot showed two names and cut the third off on a row boundary, just
// under a hairline that reads as the end of the list. A dealer would have ticked the
// two they could see. On the dialog that pays the table, a list that silently
// continues is not a small thing.
markOverflow() {
const body = document.getElementById("potWizardBody");
const modal = document.getElementById("potWizardModal");
if (!body || !modal) return;
const update = () => {
const below = body.scrollHeight - body.clientHeight - body.scrollTop;
modal.classList.toggle("potWizardMoreBelow", below > 1);
};
if (!this.watchingOverflow) {
body.addEventListener("scroll", update, { passive: true });
window.addEventListener("resize", update);
this.watchingOverflow = true;
}
update();
// Again once the browser has laid the step out — on the first open the body has
// no height yet, and an answer taken then is always "nothing below".
requestAnimationFrame(update);
},
renderStepInto() {
// Auto-resolve any round where only one player is left standing — nothing to ask.
let contenders = PotCalculator.getContenders(this.entries);
while (contenders.length === 1) {
@@ -69,6 +120,11 @@ const PotWizard = {
const body = document.getElementById("potWizardBody");
body.innerHTML = "";
// The footer is a sibling of the body, outside the scroll — see the note in
// TexasHoldem.cshtml. Cleared alongside it so a step can never inherit the
// previous step's buttons.
const footer = this.footer();
footer.innerHTML = "";
if (this.log.length === 0) body.appendChild(this.renderBreakdown());
body.appendChild(this.renderLog());
@@ -101,13 +157,25 @@ const PotWizard = {
const round = document.createElement("div");
round.className = "potWizardRound";
const showdown = this.readShowdown(contenders);
const decided = showdown && showdown.complete ? new Set(showdown.winners) : null;
const handById = new Map((showdown ? showdown.players : []).map(p => [p.id, p.hand]));
const prompt = document.createElement("p");
prompt.className = "potWizardPrompt";
prompt.innerHTML = this.log.length === 0
? `Who won the hand? <strong>${Money.format(remaining)}</strong> is in play.`
: `That side pot still leaves <strong>${Money.format(remaining)}</strong> unassigned — who's next best?`;
prompt.innerHTML = decided
// It has read the hands, so it stops asking a question it knows the answer
// to. The list stays — the dealer confirms, and a hand entered wrong is
// corrected by unticking rather than by starting over.
? `<strong>${Money.format(remaining)}</strong> in play.`
: this.log.length === 0
? `Who won the hand? <strong>${Money.format(remaining)}</strong> is in play.`
: `That side pot still leaves <strong>${Money.format(remaining)}</strong> unassigned — who's next best?`;
round.appendChild(prompt);
const verdict = this.renderVerdict(showdown, contenders);
if (verdict) round.appendChild(verdict);
const choices = document.createElement("div");
choices.className = "potWizardChoices";
contenders.forEach(c => {
@@ -117,6 +185,10 @@ const PotWizard = {
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.value = c.id;
if (decided && decided.has(c.id)) {
checkbox.checked = true;
label.classList.add("chosen");
}
// The row is the target, not the box — so the row has to look chosen too.
checkbox.addEventListener("change", () => {
label.classList.toggle("chosen", checkbox.checked);
@@ -131,11 +203,25 @@ const PotWizard = {
name.textContent = this.nameById.get(c.id);
text.appendChild(name);
// How this hand was read, under the name — on every row rather than only
// the winning one, because a dealer checking the tool's answer is reading
// the losers' hands, and a row that says nothing is a row they have to work
// out for themselves. It takes the line the ceiling used to have; the
// ceiling moves out beside the name, which is where the phone layout had
// already put it to save exactly this line.
const read = handById.get(c.id);
if (read) {
const ranked = document.createElement("span");
ranked.className = "potWizardChoiceHand";
ranked.textContent = HandRank.describe(read);
text.appendChild(ranked);
}
label.appendChild(text);
const upTo = document.createElement("span");
upTo.className = "potWizardChoiceUpTo";
upTo.textContent = `up to ${Money.format(c.remaining)}`;
text.appendChild(upTo);
label.appendChild(text);
label.appendChild(upTo);
// Cards sit apart from the name rather than inside it, so a dealer reading
// a row across the table sees the hand, not a sentence.
@@ -164,18 +250,104 @@ const PotWizard = {
const confirmBtn = document.createElement("button");
confirmBtn.className = "holdemBtn holdemBtnAccent";
confirmBtn.textContent = "Confirm Winner(s)";
confirmBtn.textContent = decided
? (showdown.winners.length === 1
? `Pay ${this.nameById.get(showdown.winners[0])}`
: "Split the pot")
: "Confirm Winner(s)";
confirmBtn.addEventListener("click", () => {
const selected = Array.from(choices.querySelectorAll("input:checked")).map(i => i.value);
if (selected.length === 0) return;
const result = PotCalculator.resolveRound(this.entries, selected);
const result = PotCalculator.resolveRound(this.entries, this.orderFromButton(selected));
this.recordRound(result);
this.entries = result.updatedEntries;
this.renderStep();
});
round.appendChild(confirmBtn);
body.appendChild(round);
footer.appendChild(confirmBtn);
},
// How the tool reads the hands still in for this round of the pot.
//
// Asked per round rather than once for the whole hand, because a side pot has its
// own contenders: the player who won the main pot is out of the question for the
// next one, and the best hand among who is left is a different answer.
//
// Only when the dealer asked to track the board — everything here is off by default
// and the wizard behaves exactly as it always has.
readShowdown(contenders) {
if (!this.state.trackBoard || typeof HandRank === "undefined") return null;
return HandRank.showdown(this.state.board, contenders.map(c => ({
id: c.id,
cards: this.cardsById.get(c.id) || [],
})));
},
// What the winners would actually be paid if this round were confirmed now.
//
// Worked out from the same function that does the paying rather than from a second
// one that agrees with it today — a preview that can drift from the payout is worse
// than no preview, because it is the number the dealer says out loud.
previewPayout(winnerIds) {
try {
const result = PotCalculator.resolveRound(this.entries, this.orderFromButton(winnerIds));
return result.perWinner || [];
} catch {
return [];
}
},
// "Alexandra wins $25 with a full house, kings full of threes."
//
// The whole point of the feature, in one line: who won and how much. It goes above
// the list rather than replacing it, because the dealer still confirms — the tool
// reads the cards, a person still says the pot is good.
renderVerdict(showdown, contenders) {
if (!showdown) return null;
const line = document.createElement("p");
line.className = "potWizardVerdict";
if (!showdown.boardComplete) {
line.classList.add("potWizardVerdictUnsure");
line.textContent = "The board isn't all five cards yet, so pick the winner yourself — or fill it in on the felt and this will read it.";
return line;
}
if (showdown.duplicated) {
line.classList.add("potWizardVerdictUnsure");
line.textContent = "The same card is in two places, so this can't be read. Check the cards on the felt.";
return line;
}
if (!showdown.complete) {
const missing = showdown.unknown.map(id => this.nameById.get(id)).filter(Boolean);
line.classList.add("potWizardVerdictUnsure");
line.textContent = missing.length
? `${this.listOf(missing)} ${missing.length === 1 ? "hasn't" : "haven't"} turned over, so who won is still yours to say.`
: "Not enough cards to read this one.";
return line;
}
const paid = this.previewPayout(showdown.winners);
const amount = paid.length ? Money.format(paid[0].amount) : "";
const names = showdown.winners.map(id => this.nameById.get(id)).filter(Boolean);
const hand = showdown.players.find(p => p.id === showdown.winners[0]);
const described = HandRank.phrase(hand && hand.hand);
line.classList.add("potWizardVerdictSure");
line.textContent = names.length === 1
? `${names[0]} wins ${amount} with ${described}.`
: `${this.listOf(names)} split it, ${amount} each, with ${described}.`;
return line;
},
listOf(names) {
if (names.length <= 1) return names[0] || "";
return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
},
// The row of buttons that never scrolls away.
footer() {
return document.getElementById("potWizardFooter");
},
recordRound(result) {
@@ -214,6 +386,24 @@ const PotWizard = {
return wrap;
},
// What the house takes out of this pot, and what is left to push.
//
// Worked out here rather than at the end so the dealer sees it before anything
// moves: the summary is the last chance to disagree with the arithmetic, and a
// rake that only appeared in the hand log afterwards would be the tool taking
// money quietly.
rakeSplit() {
const gross = Array.from(this.totals.entries()).map(([id, amount]) => ({
id,
label: this.playerLabelById.get(id),
amount,
}));
const pot = gross.reduce((sum, p) => sum + p.amount, 0);
const sawFlop = (this.state.street || "preflop") !== "preflop";
const rake = HoldemState.rakeOn(this.state, pot, sawFlop);
return { pot, sawFlop, ...HoldemState.applyRake(gross, rake) };
},
renderSummary() {
const wrap = document.createElement("div");
wrap.className = "potWizardSummary";
@@ -222,16 +412,43 @@ const PotWizard = {
heading.textContent = "Final Payouts";
wrap.appendChild(heading);
const split = this.rakeSplit();
const rakeSet = Number((this.state.rake || {}).percent) > 0;
if (rakeSet && this.totals.size > 0) {
const line = document.createElement("p");
line.className = "potWizardRake";
const cap = Number(this.state.rake.cap) > 0 ? `, capped at ${Money.format(this.state.rake.cap)}` : "";
line.textContent = split.rake > 0
? `House takes ${Money.format(split.rake)} of ${Money.format(split.pot)} — ${this.state.rake.percent}%${cap}.`
: split.sawFlop
? `No rake on this one — ${this.state.rake.percent}% of ${Money.format(split.pot)} doesn't make a chip the table has.`
: "No flop, no drop — nothing taken from a pot that ended before the flop.";
wrap.appendChild(line);
}
if (this.totals.size === 0) {
const empty = document.createElement("p");
empty.className = "fieldHint";
empty.textContent = "Nobody has money in this pot yet — mark seats \"In hand\" and set contributions first.";
wrap.appendChild(empty);
} else {
const legend = (this.state && this.state.chipLegend) || [];
const list = document.createElement("ul");
for (const [id, amount] of this.totals.entries()) {
for (const { id, amount } of split.payouts) {
const li = document.createElement("li");
li.textContent = `${this.playerLabelById.get(id)} — ${Money.format(amount)}`;
// The amount is the answer to the arithmetic; the chips are the answer
// to the dealer's job. Only when the legend has been filled in — a
// table that never set up chip colours gets the number on its own,
// which is what it had before.
const chips = Money.formatChipBreakdown(amount, legend);
if (chips) {
const note = document.createElement("span");
note.className = "payoutChips";
note.textContent = chips;
li.appendChild(note);
}
list.appendChild(li);
}
wrap.appendChild(list);
@@ -244,23 +461,34 @@ const PotWizard = {
applyBtn.className = "holdemBtn holdemBtnAccent";
applyBtn.textContent = "Start Next Hand";
applyBtn.addEventListener("click", () => {
const payouts = Array.from(this.totals.entries()).map(([id, amount]) => ({
id,
label: this.playerLabelById.get(id),
amount,
}));
// Recomputed rather than reused from the render: the same numbers, but
// taken at the moment the dealer commits rather than when they last
// looked at the screen.
const paid = this.rakeSplit();
this.close();
this.onFinished(payouts);
this.onFinished(paid.payouts, { rake: paid.rake, pot: paid.pot });
});
actions.appendChild(applyBtn);
const closeBtn = document.createElement("button");
closeBtn.className = "holdemBtn holdemBtnGhost";
closeBtn.textContent = "Close";
closeBtn.addEventListener("click", () => this.close());
actions.appendChild(closeBtn);
// This button cancels — it does not pay anybody. Under a heading reading
// "Final Payouts" with an amount beside a name, "Close" reads like dismissing
// something already done, and a dealer who taps it walks away believing the
// pot has been pushed. It hasn't: the chips are still in the middle and the
// hand isn't logged. So it says what it does.
const cancelBtn = document.createElement("button");
cancelBtn.className = "holdemBtn holdemBtnGhost";
cancelBtn.textContent = "Cancel — don't pay yet";
cancelBtn.addEventListener("click", () => this.close());
actions.appendChild(cancelBtn);
wrap.appendChild(actions);
if (this.totals.size > 0) {
const note = document.createElement("p");
note.className = "fieldHint potWizardPendingNote";
note.textContent = "Nothing has been paid out yet. Start Next Hand pushes the pot and logs the hand.";
wrap.appendChild(note);
}
this.footer().appendChild(actions);
return wrap;
},
};
@@ -15,12 +15,28 @@ const QuickSplit = {
ways: 2,
chip: 1,
// "even" is the common case — one pot, everyone matched. "uneven" is the one this
// screen used to send people back to the full table for: somebody all-in for less,
// so the money splits into a main pot and side pots.
mode: "even",
entries: [],
mount() {
this.potInput = document.getElementById("potInput");
this.clearPotBtn = document.getElementById("clearPotBtn");
this.entries = [{ amount: "", folded: false }, { amount: "", folded: false }, { amount: "", folded: false }];
this.renderWayChoices();
this.renderChipChoices();
this.renderUnevenRows();
document.getElementById("modeEvenBtn").addEventListener("click", () => this.setMode("even"));
document.getElementById("modeUnevenBtn").addEventListener("click", () => this.setMode("uneven"));
document.getElementById("addPlayerBtn").addEventListener("click", () => {
this.entries.push({ amount: "", folded: false });
this.renderUnevenRows();
this.update();
});
this.potInput.addEventListener("input", () => {
this.sanitizePotInput();
@@ -127,7 +143,196 @@ const QuickSplit = {
});
},
setMode(mode) {
this.mode = mode;
document.getElementById("modeEvenBtn").classList.toggle("selected", mode === "even");
document.getElementById("modeUnevenBtn").classList.toggle("selected", mode === "uneven");
document.querySelectorAll(".evenOnly").forEach(el => { el.hidden = mode !== "even"; });
document.querySelectorAll(".unevenOnly").forEach(el => { el.hidden = mode !== "uneven"; });
document.getElementById("splitResult").hidden = mode !== "even";
document.getElementById("unevenResult").hidden = mode !== "uneven";
this.update();
},
renderUnevenRows() {
const host = document.getElementById("unevenRows");
const template = document.getElementById("unevenRowTemplate");
host.innerHTML = "";
this.entries.forEach((entry, index) => {
const row = template.content.firstElementChild.cloneNode(true);
row.querySelector(".unevenRowLabel").textContent = `P${index + 1}`;
const amount = row.querySelector(".unevenAmount");
amount.value = entry.amount;
amount.setAttribute("aria-label", `Player ${index + 1} amount`);
amount.addEventListener("input", () => {
entry.amount = amount.value.replace(/[^0-9.]/g, "").replace(/(\..*)\./g, "$1");
if (entry.amount !== amount.value) amount.value = entry.amount;
this.update();
});
// Folded money stays in the pot as dead money, but its owner can't win any
// of it — which is exactly the distinction that makes side pots tricky.
const fold = row.querySelector(".unevenFoldBtn");
fold.classList.toggle("selected", entry.folded);
fold.setAttribute("aria-pressed", String(entry.folded));
fold.addEventListener("click", () => {
entry.folded = !entry.folded;
this.renderUnevenRows();
this.update();
});
const remove = row.querySelector(".unevenRemoveBtn");
remove.disabled = this.entries.length <= 2;
remove.addEventListener("click", () => {
this.entries.splice(index, 1);
this.renderUnevenRows();
this.update();
});
host.appendChild(row);
});
},
// Chopping one of the pots listed above, without leaving the screen that worked it
// out. Starts closed and at as many ways as there are players who can win the pot,
// which is the common case; the stepper covers a chop between fewer of them, which
// is the other one.
//
// The per-pot ways live on the row rather than in `this.ways`, so opening a chop on
// the main pot doesn't change what the side pot below it is saying.
addChopControl(row, amount, contenderCount) {
let ways = contenderCount;
let open = false;
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "unevenChopToggle";
const detail = document.createElement("div");
detail.className = "unevenChopDetail";
detail.hidden = true;
const draw = () => {
toggle.textContent = open ? "Hide chop" : "Chop";
toggle.setAttribute("aria-expanded", String(open));
detail.hidden = !open;
if (!open) return;
const result = SplitCalculator.split(amount, ways, this.chip);
detail.innerHTML = "";
const stepper = document.createElement("div");
stepper.className = "unevenChopWays";
stepper.appendChild(this.makeStepButton("−", () => {
if (ways > 2) { ways -= 1; draw(); }
}));
const label = document.createElement("span");
label.className = "unevenChopWaysLabel";
label.textContent = `${ways} ways`;
stepper.appendChild(label);
stepper.appendChild(this.makeStepButton("+", () => {
if (ways < Math.max(contenderCount, this.MAX_WAYS)) { ways += 1; draw(); }
}));
detail.appendChild(stepper);
const each = document.createElement("p");
each.className = "unevenChopEach";
each.textContent = `${Money.format(result.each)} each`;
detail.appendChild(each);
// Same wording as the main split, because it is the same rule and a dealer
// shouldn't have to notice they're reading a different screen.
if (result.valid && result.oddChips > 0) {
const odd = document.createElement("p");
odd.className = "unevenChopNote";
odd.textContent = result.oddChips === 1
? `plus one odd ${Money.format(result.chip)} chip to the first player left of the button`
: `plus ${result.oddChips} odd ${Money.format(result.chip)} chips — one each to the ` +
`first ${result.oddChips} players left of the button`;
detail.appendChild(odd);
}
if (result.valid && result.dust > 0) {
const dust = document.createElement("p");
dust.className = "unevenChopNote unevenChopDust";
dust.textContent =
`${Money.format(result.dust)} doesn't divide into ${Money.format(result.chip)} chips — ` +
`pick a smaller denomination.`;
detail.appendChild(dust);
}
};
toggle.addEventListener("click", () => { open = !open; draw(); });
draw();
row.appendChild(toggle);
row.appendChild(detail);
},
updateUneven() {
const players = this.entries
.map((entry, index) => ({ id: `P${index + 1}`, contribution: Number(entry.amount) || 0, folded: entry.folded }))
.filter(p => p.contribution > 0);
const pots = PotCalculator.layers(players);
const returns = PotCalculator.uncalledReturns(players);
const host = document.getElementById("unevenPots");
const empty = document.getElementById("unevenEmpty");
host.innerHTML = "";
if (pots.length === 0 && returns.length === 0) {
empty.hidden = false;
empty.textContent = players.length === 0
? "Enter what each player put in."
: "Nobody left who can win — un-fold whoever should take it.";
return;
}
empty.hidden = true;
const addRow = (label, amount, who, className, chopWays) => {
const row = document.createElement("div");
row.className = `unevenPot ${className || ""}`;
const name = document.createElement("span");
name.className = "unevenPotLabel";
name.textContent = label;
const value = document.createElement("span");
value.className = "unevenPotAmount";
value.textContent = Money.format(amount);
const eligible = document.createElement("span");
eligible.className = "unevenPotWho";
eligible.textContent = who;
row.appendChild(name);
row.appendChild(value);
row.appendChild(eligible);
host.appendChild(row);
// Two players tying for a side pot is ordinary, and working out each one's
// share meant copying the amount into the other mode by hand — retyping a
// number the screen had just worked out, which is a transcription error
// waiting to happen with money on it. The chop happens here instead.
//
// Only offered where it means something: a pot with at least two people
// who can win it. Not the uncalled row, which goes back to one player, and
// not the total.
if (chopWays >= 2) this.addChopControl(row, amount, chopWays);
};
pots.forEach(pot => addRow(pot.label, pot.amount, pot.eligible.join(", "), "", pot.eligible.length));
returns.forEach(ret => addRow("Uncalled", ret.amount, `back to ${ret.id}`, "unevenPotReturn"));
const total = players.reduce((sum, p) => sum + p.contribution, 0);
addRow("On the table", Money.round(total), "", "unevenPotTotal");
},
update() {
if (this.mode === "uneven") {
this.updateUneven();
return;
}
const potValue = Number(this.potInput.value);
const pot = Number.isNaN(potValue) ? 0 : potValue;
this.clearPotBtn.hidden = this.potInput.value === "";
File diff suppressed because it is too large Load Diff
@@ -6,10 +6,16 @@ const SeatMenu = {
hooks: null,
seatIndex: null,
mode: null,
open(state, hooks, seatIndex) {
this.state = state;
this.hooks = hooks;
this.seatIndex = seatIndex;
// Always the action list, never whatever the sheet was showing when it last
// closed — a seat menu that opens on a list of chairs is a menu that lost the
// dealer's place.
this.mode = null;
this.render();
document.getElementById("seatMenuOverlay").hidden = false;
},
@@ -41,33 +47,124 @@ const SeatMenu = {
run: () => this.hooks.toggleDealtIn(index),
});
// An empty chair with people waiting: the dealer taps the chair somebody is
// about to sit in, which is what their hand is doing anyway. Only the front of
// the queue is offered, for the same reason the panel only offers them — a list
// that isn't kept in order is just a pile of names.
const waiting = (this.state.waitingList || [])[0];
const emptyChair = !HoldemState.seatInPlay(player);
if (waiting && emptyChair) {
actions.push({
label: `Seat ${waiting.name}`,
className: "menuAccent",
run: () => this.hooks.seatWaiting(waiting.id, index),
});
}
// Leaving for the night, which is a different thing from sitting out a hand:
// the chips go back over the rail and the chair opens. Offered only for a seat
// that has somebody in it, since an empty chair has nobody to pay.
if (HoldemState.seatInPlay(player)) {
actions.push({ label: "Cash out…", run: () => this.hooks.cashOut(index) });
}
if (!this.state.dedicatedDealer && index !== this.state.dealerSeatIndex) {
actions.push({ label: "Give the button", run: () => this.hooks.makeDealer(index) });
}
// Naming a blind by hand. Only offered while nothing but the blinds are in —
// once somebody has called, moving a blind would mean unpicking a bet that
// has already been answered, and that's a decision for the dealer's mouth,
// not a menu item. Which blind a seat already has is shown rather than
// offered again.
if (dealtIn && Betting.onlyBlindsIn(this.state)) {
const blinds = HoldemState.getBlindSeats(this.state);
if (blinds.sbIndex !== index) {
actions.push({ label: "Make small blind", run: () => this.hooks.assignBlind(index, "sb") });
}
if (blinds.bbIndex !== index) {
actions.push({ label: "Make big blind", run: () => this.hooks.assignBlind(index, "bb") });
}
if (this.state.blindOverride) {
actions.push({ label: "Blinds follow the button again", run: () => this.hooks.clearBlindOverride() });
}
}
// Changing chairs was drag-and-drop only, and HTML5 drag events are never
// generated from a touch screen — so on the device this tool is mostly used on,
// the "drag to swap seats" the hint advertised did nothing at all. This is the
// same move, by the route a thumb can actually take.
if (this.state.seats.length > 1) {
actions.push({ label: "Move to…", run: () => this.openMovePicker() });
}
actions.push({ label: "Edit seat…", run: () => this.hooks.editSeat(index) });
return actions;
},
// The chairs this player could move to. Every other chair: swapping into an empty
// one is how a player moves, and swapping with an occupied one is two players
// changing places, which is the other half of the same job.
destinationsFor(index) {
return this.state.seats
.map((player, seat) => ({ player, seat }))
.filter(entry => entry.seat !== index);
},
openMovePicker() {
this.mode = "move";
this.render();
},
render() {
const player = this.state.seats[this.seatIndex];
document.getElementById("seatMenuTitle").textContent = HoldemState.displayName(player, this.seatIndex);
const title = document.getElementById("seatMenuTitle");
const subtitle = document.getElementById("seatMenuSubtitle");
const list = document.getElementById("seatMenuActions");
list.innerHTML = "";
if (this.mode === "move") {
title.textContent = `Move ${HoldemState.displayName(player, this.seatIndex)} to…`;
subtitle.textContent = "Their chips, cards and bet go with them. The button and the blinds stay on the chair.";
this.destinationsFor(this.seatIndex).forEach(({ player: other, seat }) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "seatMenuBtn";
// displayName falls back to "Seat 5" for a chair with nobody in it,
// which reads as "Seat 5 — Seat 5". An empty chair says it's empty.
const named = (other.name || "").trim();
btn.textContent = named ? `Seat ${seat + 1} — ${named}` : `Seat ${seat + 1} — empty`;
btn.addEventListener("click", () => {
const from = this.seatIndex;
this.close();
this.hooks.swapSeats(from, seat);
});
list.appendChild(btn);
});
return;
}
title.textContent = HoldemState.displayName(player, this.seatIndex);
const stack = Betting.stackOf(this.state, player);
const owed = Betting.toCall(this.state, player);
const bits = [];
if (stack > 0) bits.push(`${Money.format(stack)} behind`);
if (owed > 0) bits.push(`${Money.format(owed)} to call`);
document.getElementById("seatMenuSubtitle").textContent = bits.join(" · ");
subtitle.textContent = bits.join(" · ");
const list = document.getElementById("seatMenuActions");
list.innerHTML = "";
this.actionsFor(player, this.seatIndex).forEach(action => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = `seatMenuBtn ${action.className || ""}`;
btn.textContent = action.label;
btn.addEventListener("click", () => {
// "Move to…" stays on the sheet and swaps it for the seat list; every
// other action is the end of the interaction.
if (action.label === "Move to…") {
action.run();
return;
}
this.close();
action.run();
});
@@ -0,0 +1,66 @@
// Who hands cash to whom at the end of the night.
//
// The standings panel already works out what everyone is up or down. It then stopped,
// which leaves the dealer with six signed numbers at 2am and the actual job still to
// do: turning them into payments. That's the step people get wrong — not the poker.
//
// Everything is done in cents. A settlement that has to come out exactly right is the
// last place to let a float land on 41.99999999.
const SettleUp = {
// Largest debt against largest win, repeatedly. It's greedy rather than optimal,
// and for this problem that's the right trade: finding the true minimum number of
// transfers is NP-hard, while this is guaranteed to need at most one payment fewer
// than there are people, and each payment it does emit clears somebody out of the
// list entirely. A dealer wants a short list they can read down, not a proof.
//
// Payers are the outer loop so that everything one person owes is printed together
// — the list is read out loud, one player at a time.
plan(standings) {
const owed = [];
const owing = [];
(standings || []).forEach(row => {
const cents = Money.toCents(row.cashPosition);
if (cents > 0) owed.push({ name: row.name, cents });
else if (cents < 0) owing.push({ name: row.name, cents: -cents });
});
owed.sort((a, b) => b.cents - a.cents || a.name.localeCompare(b.name));
owing.sort((a, b) => b.cents - a.cents || a.name.localeCompare(b.name));
const payments = [];
let creditor = 0;
owing.forEach(payer => {
while (payer.cents > 0 && creditor < owed.length) {
const taker = owed[creditor];
const amount = Math.min(payer.cents, taker.cents);
payments.push({ from: payer.name, to: taker.name, amount: Money.fromCents(amount) });
payer.cents -= amount;
taker.cents -= amount;
if (taker.cents === 0) creditor++;
}
});
// What's left over when the chips on the table don't match the cash that came
// in. It is never a rounding artifact — the arithmetic is integer cents — so it
// is always the thing the bank check is already warning about: a rebuy nobody
// wrote down, or a miscount. Reported rather than quietly spread across the
// payments, because inventing a transfer to make the columns agree is how a
// real person ends up short.
const unpaid = Money.fromCents(owed.reduce((sum, row) => sum + row.cents, 0));
const unspent = Money.fromCents(owing.reduce((sum, row) => sum + row.cents, 0));
return {
payments,
// Only one of these can be non-zero: either there are more chips than cash
// or less, never both.
shortBy: unpaid,
overBy: unspent,
balanced: unpaid === 0 && unspent === 0,
};
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = SettleUp;
}
@@ -18,6 +18,23 @@ const Shortcuts = {
escape: "cancel",
},
// What each one does, in a dealer's words, for the legend in the sidebar.
//
// Lives beside KEYS because the two must not drift: F, C and R are printed on the
// buttons they drive, but A and U were on nothing at all — a dealer who learned
// the first three from the bar had no way to find out the other two existed. A
// shortcut with no line here is a shortcut nobody can find, and there's a test
// that says so.
LEGEND: [
{ keys: ["F"], action: "fold", what: "Fold" },
{ keys: ["C"], action: "checkOrCall", what: "Check, or call the bet" },
{ keys: ["R"], action: "raise", what: "Open the amount pad to bet or raise" },
{ keys: ["A"], action: "allIn", what: "Put this player all-in" },
{ keys: ["U"], action: "undo", what: "Take back the last action" },
{ keys: ["Space", "Enter"], action: "advance", what: "Deal, or move to the next street" },
{ keys: ["Esc"], action: "cancel", what: "Close the amount pad without betting" },
],
mount(handlers) {
this.handlers = handlers;
document.addEventListener("keydown", e => this.onKey(e));
@@ -32,14 +49,28 @@ const Shortcuts = {
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable;
},
// An overlay owns the keyboard while it's up: the confirm dialog already binds
// Enter and Escape, and the card picker and seat menu are modal.
// An overlay owns the keyboard while it's up. Asking the document which overlays
// are open, rather than listing them, means a sheet added later can't quietly
// start letting "f" fold the table behind it — the same reason Modal watches for
// them instead of being told about each one.
overlayOpen() {
return ["confirmOverlay", "potWizardOverlay", "cardPickerOverlay", "seatMenuOverlay", "rulingsOverlay", "addChipsOverlay"]
.some(id => {
const el = document.getElementById(id);
return el && !el.hidden;
});
return [...document.querySelectorAll('[id$="Overlay"]')].some(el => !el.hidden);
},
// Enter and Space are how a keyboard presses the control it is already sitting on.
// Claiming them for "advance" didn't just block that press — it ran a different
// action instead. Measured: Tab to Resolve Pot, press Enter, and the table dealt
// the next hand. Space on the same button worked, because "advance" happened to
// decline that time and the browser's own activation got through. A control that
// does something else every other press is worse than one that does nothing.
//
// Only these two keys are handed back. The letter shortcuts stay live with a
// button focused, because a button does nothing with "f" and the dealer's hand
// shouldn't have to leave them.
focusedControlOwnsKey(key) {
if (key !== "enter" && key !== " ") return false;
const el = document.activeElement;
return !!(el && el.matches && el.matches('button, [role="button"], a[href], summary'));
},
onKey(e) {
@@ -49,6 +80,8 @@ const Shortcuts = {
const action = this.KEYS[key];
if (!action) return;
if (this.focusedControlOwnsKey(key)) return;
// Escape is the exception: it should still close the raise pad even if focus
// happens to be sitting in its amount field.
if (this.isTyping() && !(action === "cancel")) return;
@@ -7,7 +7,7 @@
const SplitCalculator = {
// Everything is done in cents to keep 0.1 + 0.2 out of a payout.
toCents(amount) {
return Math.round((Number(amount) || 0) * 100);
return Money.toCents(amount);
},
split(potAmount, ways, chipAmount) {
@@ -40,11 +40,11 @@ const SplitCalculator = {
// Sanity check for the UI: what the split actually hands out has to equal the pot.
accountedFor(result) {
return (
Math.round(result.each * 100) * result.players +
Math.round(result.chip * 100) * result.oddChips +
Math.round(result.dust * 100)
) / 100;
return Money.fromCents(
Money.toCents(result.each) * result.players +
Money.toCents(result.chip) * result.oddChips +
Money.toCents(result.dust)
);
},
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,126 @@
// Keeps the screen on while a hand is live.
//
// The dealer's phone spends the night face-up on the felt with nine people round it,
// and the one thing it reliably does between the flop and the turn is dim, then lock.
// Every hand, all night. A dozen rounds of work have gone into making the next step
// readable at a glance, and none of it survives a black screen and a passcode with
// four people waiting to know whether the bet is called.
//
// Held only while betting is actually open. A phone left on the table through a break,
// a re-buy, or the twenty minutes where everyone is arguing about pizza sleeps exactly
// like any other phone — which is the difference between a tool that respects a battery
// and one a dealer stops trusting with their evening.
const WakeLock = {
// The browser's promise to stay awake. Null whenever we aren't holding one, which
// includes every moment the browser has taken it back on us — see `start()`.
sentinel: null,
// What the last sync decided we want. Kept separate from `sentinel` because
// requesting one is asynchronous: a hand can be dealt and folded out inside the
// await, and the answer to "should we still be holding this" has to be the current
// one rather than the one that was true when the request went out.
wanted: false,
// One request at a time. `sync()` runs off renderAll, which fires many times a
// second during a busy street; without this, two overlapping requests both get
// past the "already holding one?" check and the second sentinel leaks — the screen
// then stays awake after the hand ends, with nothing left holding a reference to
// turn it off.
pending: false,
// Whether the dealer has been told this is happening. Once per page load, not once
// per hand: the same toast at the top of all forty hands of a session would be the
// single most annoying thing the tool does.
told: false,
// Set by main.js so the toast's "Turn off" can actually turn it off.
hooks: null,
supported() {
return typeof navigator !== "undefined" && "wakeLock" in navigator;
},
// The whole decision, with no browser in it, so it can be tested as what it is:
// a rule about when a poker table needs the screen.
shouldHold(state) {
if (!state) return false;
if (state.keepScreenAwake === false) return false;
return state.bettingOpen === true;
},
// Called after anything that could start or end a hand. Safe to call constantly.
sync(state) {
this.wanted = this.supported() && this.shouldHold(state);
if (this.wanted) this.acquire();
else this.release();
},
async acquire() {
if (this.sentinel || this.pending) return;
this.pending = true;
try {
const sentinel = await navigator.wakeLock.request("screen");
// The hand can have ended while that was in flight. Letting this one go
// immediately is the only correct answer; holding it "just until the next
// sync" is how a phone stays lit on an empty table.
if (!this.wanted) {
try { await sentinel.release(); } catch { /* already gone */ }
return;
}
this.sentinel = sentinel;
// The browser releases this itself when the tab is hidden, and tells us so.
// Without this listener our `sentinel` would be a stale object and the
// re-acquire in `start()` would decline to do anything.
sentinel.addEventListener("release", () => {
if (this.sentinel === sentinel) this.sentinel = null;
});
this.tell();
} catch {
// Battery saver, a hidden tab, a browser that simply says no. All of these
// are the browser doing its job, and none of them is worth an error at a
// poker table — the tool works exactly as it did before, the screen just
// dims the way the phone's own settings say it should.
} finally {
this.pending = false;
}
},
async release() {
const sentinel = this.sentinel;
this.sentinel = null;
if (!sentinel) return;
try { await sentinel.release(); } catch { /* already released */ }
},
// Said once, at the moment it first becomes true, with the way out attached.
//
// The alternative was a permanent indicator in the header, and the header is the
// one part of this page that has been fought over for crowding more than any other.
// A dealer needs to know their phone is behaving unusually exactly once; after that
// it is a setting, and settings live in the sidebar.
tell() {
if (this.told) return;
this.told = true;
if (typeof Toast === "undefined") return;
Toast.show("Keeping the screen on while a hand is live.", {
actionLabel: "Turn off",
onAction: () => { if (this.hooks && this.hooks.turnOff) this.hooks.turnOff(); },
});
},
// A screen wake lock is not something you take once. The browser drops it whenever
// the document stops being visible — switching apps to check a rule, the phone
// locking on its own before the first hand was dealt, a notification pulled down —
// and it never comes back on its own. A tool that requests it at the start of the
// hand and assumes it still has it is a tool that works until the first interruption.
start(getState) {
if (!this.supported()) return;
document.addEventListener("visibilitychange", () => {
if (document.visibilityState !== "visible") return;
this.sync(getState());
});
},
};
if (typeof module !== "undefined" && module.exports) module.exports = WakeLock;
@@ -0,0 +1,86 @@
document.addEventListener('DOMContentLoaded', () => {
addHeadingAnchors();
trackReadingProgress();
highlightCurrentSection();
setUpSidebarFilter();
setUpMobileMenu();
});
// Hover "#" links on headings so any section can be linked to directly.
function addHeadingAnchors() {
document.querySelectorAll('.soc-content h2[id], .soc-content h3[id]').forEach(heading => {
const anchor = document.createElement('a');
anchor.className = 'soc-anchor';
anchor.href = '#' + heading.id;
anchor.textContent = '#';
anchor.setAttribute('aria-label', 'Link to this section');
heading.prepend(anchor);
});
}
function trackReadingProgress() {
const bar = document.getElementById('socProgress');
if (!bar) return;
const update = () => {
const scrollable = document.documentElement.scrollHeight - window.innerHeight;
bar.style.width = scrollable > 0 ? (window.scrollY / scrollable) * 100 + '%' : '0';
};
window.addEventListener('scroll', update, { passive: true });
update();
}
// Highlights the "On this page" entry for the section currently being read.
function highlightCurrentSection() {
const links = [...document.querySelectorAll('.soc-toc a[data-toc-id]')];
if (links.length === 0) return;
const headings = links
.map(link => document.getElementById(link.dataset.tocId))
.filter(Boolean);
const update = () => {
let current = headings[0];
for (const heading of headings) {
if (heading.getBoundingClientRect().top <= 120) current = heading;
}
links.forEach(link => link.classList.toggle('active', link.dataset.tocId === current?.id));
};
window.addEventListener('scroll', update, { passive: true });
update();
}
function setUpSidebarFilter() {
const input = document.getElementById('socFilter');
if (!input) return;
input.addEventListener('input', () => {
const query = input.value.trim().toLowerCase();
document.querySelectorAll('.soc-nav-link').forEach(link => {
link.classList.toggle('hidden', query !== '' && !link.dataset.filterText.includes(query));
});
document.querySelectorAll('.soc-nav-group').forEach(group => {
const anyVisible = group.querySelector('.soc-nav-link:not(.hidden)');
group.style.display = anyVisible ? '' : 'none';
});
});
}
function setUpMobileMenu() {
const toggle = document.getElementById('socMenuToggle');
const sidebar = document.getElementById('socSidebar');
if (!toggle || !sidebar) return;
const setOpen = open => {
sidebar.classList.toggle('open', open);
toggle.setAttribute('aria-expanded', String(open));
toggle.textContent = open ? 'Close' : 'Contents';
};
toggle.addEventListener('click', () => setOpen(!sidebar.classList.contains('open')));
sidebar.addEventListener('click', event => {
if (event.target.closest('a')) setOpen(false);
});
}
File diff suppressed because it is too large Load Diff
+128
View File
@@ -0,0 +1,128 @@
// Saying who the blind is, before saying "cards".
//
// A dealer sets the table up out loud — button here, she's the small blind, he's the
// big blind — and only then deals. The seat menu offers "Make small blind" for exactly
// that, and it was answering by dealing the hand: 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".
//
// The condition it was asking is `onlyBlindsIn`, which means "nothing has happened in
// this hand except the blinds" — true of a hand one second old, and equally true of a
// table in setup with no cards out and nothing committed. Those two need telling apart,
// which is what `blindsArePosted` is for.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
describe("before the deal", () => {
test("the blinds are not down, however much the table looks ready", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] });
assert.equal(state.bettingOpen, false);
// The old condition, on its own, says yes here — this is the whole bug.
assert.equal(Betting.onlyBlindsIn(state), true);
assert.equal(Betting.blindsArePosted(state), false);
});
test("a table straight out of setup, with nobody seated, is the same answer", () => {
assert.equal(Betting.blindsArePosted(HoldemState.createDefault()), false);
});
test("re-posting would have dealt the hand, which is why it is guarded", () => {
// Not a rule, a demonstration: repostBlinds ends in beginHand. Calling it in
// setup is the deal, and nothing about naming a blind should reach it.
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] });
Betting.repostBlinds(state);
assert.equal(state.bettingOpen, true, "chips off two stacks and the action bar up");
assert.notEqual(state.actingSeatIndex, null);
});
test("naming a blind moves a badge and no chips", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] });
const before = chipsOnTable(state);
// What the seat menu does: pin the override, repost only if the blinds are down.
state.blindOverride = { sbIndex: 3, bbIndex: 0 };
if (Betting.blindsArePosted(state)) Betting.repostBlinds(state);
assert.equal(state.bettingOpen, false, "still setup — the dealer hasn't dealt");
assert.equal(state.actingSeatIndex, null, "nobody is being asked to act");
assert.deepEqual(state.seats.map(p => Betting.committed(p)), [0, 0, 0, 0, 0, 0]);
assert.equal(chipsOnTable(state), before);
// And the badges say what the dealer just said.
const blinds = HoldemState.getBlindSeats(state);
assert.equal(blinds.sbIndex, 3);
assert.equal(blinds.bbIndex, 0);
});
test("and the hand that follows posts from where they were aimed", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], smallBlind: 1, bigBlind: 2 });
state.blindOverride = { sbIndex: 3, bbIndex: 0 };
if (Betting.blindsArePosted(state)) Betting.repostBlinds(state);
Betting.beginHand(state);
assert.equal(Betting.committed(state.seats[3]), 1);
assert.equal(Betting.committed(state.seats[0]), 2);
});
test("giving the button away is a marker moving too", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] });
const before = chipsOnTable(state);
const willRepost = Betting.blindsArePosted(state);
state.dealerSeatIndex = 2;
if (willRepost) Betting.repostBlinds(state);
assert.equal(willRepost, false);
assert.equal(state.bettingOpen, false);
assert.equal(chipsOnTable(state), before);
});
});
describe("once the hand is dealt", () => {
test("the blinds are down, and re-aiming them moves real chips", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
const before = chipsOnTable(state);
Betting.beginHand(state);
assert.equal(Betting.blindsArePosted(state), true);
assert.equal(Betting.committed(state.seats[1]), 1);
state.blindOverride = { sbIndex: 3, bbIndex: 0 };
if (Betting.blindsArePosted(state)) Betting.repostBlinds(state);
assert.equal(Betting.committed(state.seats[1]), 0, "the old small blind got their chip back");
assert.equal(Betting.committed(state.seats[3]), 1);
assert.equal(Betting.committed(state.seats[0]), 2);
assert.equal(chipsOnTable(state), before, "no chips created or lost in the shuffle");
});
test("but not once somebody has answered them", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.call(state, state.actingSeatIndex);
assert.equal(Betting.blindsArePosted(state), false, "there is a call to unpick now");
});
test("nor on a later street", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
state.street = "flop";
assert.equal(Betting.blindsArePosted(state), false);
});
test("and a misdealt hand goes back to being a table in setup", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.takeHandBack(state);
assert.equal(Betting.blindsArePosted(state), false, "nothing is posted until the dealer deals again");
});
test("as does a hand that has been paid out", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.sweepToHandTotals(state);
HoldemState.startNextHand(state);
assert.equal(Betting.blindsArePosted(state), false);
});
});
+173
View File
@@ -0,0 +1,173 @@
// Who the felt marks "ALL IN".
//
// The mark's whole value is that a dealer can scan the ring and see who can still be
// bet at, so the test that matters most is the last one: the seats it marks are
// exactly the seats the betting round refuses to ask for another chip.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
const CSS = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
// The declarations inside one rule, by its exact selector.
const ruleBody = selector => {
const at = CSS.indexOf(`\n${selector} {`);
if (at < 0) return null;
const open = CSS.indexOf("{", at);
return CSS.slice(open + 1, CSS.indexOf("}", open));
};
test("a player who moved all-in is marked", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
state.seats[3].stack = 12;
Betting.beginHand(state);
Betting.allIn(state, state.actingSeatIndex);
assert.equal(Betting.isAllIn(state, state.seats[3]), true);
assert.equal(state.seats[3].stack, 0);
});
test("a player with chips behind is not", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
Betting.beginHand(state);
assert.equal(Betting.isAllIn(state, state.seats[0]), false);
assert.equal(Betting.isAllIn(state, state.seats[1]), false);
});
test("a busted player who was dealt in but has put nothing in is not", () => {
// An empty stack alone isn't all-in — it's someone who shouldn't have been dealt
// in. Calling that "ALL IN" on the felt would be a lie about a live pot.
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 100 });
state.seats[2].stack = 0;
assert.equal(Betting.isAllIn(state, state.seats[2]), false);
});
test("a folded player with an empty stack is not", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 100 });
state.seats[1].committed = 5;
state.seats[1].stack = 0;
state.seats[1].folded = true;
assert.equal(Betting.isAllIn(state, state.seats[1]), false);
});
test("an empty chair is not", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
assert.equal(Betting.isAllIn(state, state.seats[4]), false);
});
test("a seat with no stack of its own falls back to the table's and isn't marked", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 100 });
state.defaultStack = 200;
state.seats[2].stack = null;
state.seats[2].committed = 10;
assert.equal(Betting.isAllIn(state, state.seats[2]), false);
});
test("the seats marked are exactly the ones the action passes over", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
state.seats[1].stack = 8;
state.seats[3].stack = 30;
Betting.beginHand(state);
// Everyone in, two of them for less than the others can cover.
for (let step = 0; step < 4; step++) Betting.allIn(state, state.actingSeatIndex);
const marked = state.seats
.map((player, index) => (Betting.isAllIn(state, player) ? index : null))
.filter(index => index !== null);
const skipped = state.seats
.map((player, index) =>
player.inHand && !player.folded && Betting.nextToAct(state, index - 1) !== index ? index : null)
.filter(index => index !== null);
assert.deepEqual(marked, skipped);
assert.ok(marked.length > 0, "the scenario should actually produce all-in players");
});
test("nobody is marked once the next hand starts", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 100 });
state.seats[2].stack = 5;
Betting.beginHand(state);
Betting.allIn(state, state.actingSeatIndex);
assert.ok(state.seats.some(player => Betting.isAllIn(state, player)));
// The short stack got there by winning nothing; give them chips back the way a
// dealer would before the next deal.
HoldemState.startNextHand(state);
state.seats[2].stack = 50;
assert.equal(state.seats.filter(player => Betting.isAllIn(state, player)).length, 0);
});
test("marking nobody doesn't move a chip", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
state.seats[3].stack = 12;
Betting.beginHand(state);
const before = chipsOnTable(state);
state.seats.forEach(player => Betting.isAllIn(state, player));
assert.equal(chipsOnTable(state), before);
});
// ---- And the shape of the mark, which is the felt's problem ------------------
//
// "ALL IN" is the only thing that ever lands on the stack line with a space in it, and
// a space is somewhere to break. Measured at 360x640 with twelve players all-in, it
// wanted 36px and had 34: it broke onto a second line, and an 11px line became 22.
//
// That is not a typography complaint. The puck went from 55px tall to 66, and puck
// height is what the seat ring is short of — at the ends of an ellipse the seats are
// separated vertically, so 11px taller is 11px further into the seat next door. The
// layout search then 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.
describe("the mark is one line, because the ring cannot afford two", () => {
test("a stack line can never wrap", () => {
const body = ruleBody(".seatPuckStack");
assert.ok(body, "the .seatPuckStack rule should exist");
assert.match(body, /white-space:\s*nowrap/,
"the stack line must not be allowed to wrap — a second line is 11px the ring hasn't got");
});
test("and on the tightest pucks it gives up its letter-spacing to fit", () => {
// Not wrapping alone only converts a wrap into a spill: a line that cannot wrap
// and still doesn't fit paints over the seat beside it instead. The spacing is
// 3.6px of the 36 it wanted, and it is the one of the mark's three signals that
// can go — the warm colour and the bold weight stay.
const base = ruleBody(".seatPuckStack.seatPuckAllIn");
const tight = ruleBody(".seatsTight .seatPuckStack.seatPuckAllIn");
assert.ok(base, "the base all-in rule should exist");
assert.ok(tight, "the tight-tier all-in rule should exist");
const spacingOf = body => {
const match = body.match(/letter-spacing:\s*([\d.]+)em/);
return match ? Number(match[1]) : null;
};
const wide = spacingOf(base);
const narrow = spacingOf(tight);
assert.ok(wide !== null && narrow !== null, "both rules should set letter-spacing in em");
assert.ok(narrow < wide,
`the tight tier should space the mark more tightly than the ordinary one (${narrow} vs ${wide})`);
// The arithmetic that has to hold for it to fit at all: six characters at 10px
// with 4px of padding either side and a 1px border, inside a 44px puck.
const CONTENT_PX = 44 - 2 * 4 - 2 * 1;
const inkAt = spacing => 32.4 + spacing * 10 * "ALL IN".length;
assert.ok(inkAt(narrow) <= CONTENT_PX,
`"ALL IN" must fit the narrowest puck: ${inkAt(narrow).toFixed(1)}px into ${CONTENT_PX}px`);
assert.ok(inkAt(wide) > CONTENT_PX,
"the ordinary spacing is what didn't fit — if it does now, this test is measuring the wrong thing");
});
test("the two signals that survive are the ones doing the work", () => {
// The spacing exists so "ALL IN" doesn't read as money at a glance. Dropping it
// on the narrow pucks is only safe while the colour and the weight remain, so
// they are pinned here rather than left to be tidied away later.
const base = ruleBody(".seatPuckStack.seatPuckAllIn");
assert.match(base, /color:\s*#ffcf6b/i, "the warm colour separates it from a grey stack");
assert.match(base, /font-weight:\s*700/, "and the weight does the rest");
});
});
+135
View File
@@ -0,0 +1,135 @@
// What the tool says out loud between taps.
//
// The action bar carried `aria-live="polite"` on the element whose entire contents
// render() rebuilds every action, so a screen reader read the whole bar after every
// single tap. Measured, after one:
//
// "PRE-FLOP Pot $15 9 players Bob · $250 behind 0:00 ↶ dealing the hand U
// Fold Call $10 Raise"
//
// Nothing in that is wrong. It is just 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.
//
// What a dealer running this by ear needs is who is on the clock and what it costs
// them. These are the sentences.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { ActionBar, Betting, HoldemState, makeTable } = require("./load");
const barOn = (state) => {
ActionBar.state = state;
ActionBar.lastAnnouncement = undefined;
return ActionBar;
};
describe("before the cards are out", () => {
test("a table that could be dealt says so", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] });
assert.equal(barOn(state).announcement(), "Ready to deal.");
});
test("and an empty table says what it is, rather than nothing", () => {
// This used to announce "" — silence being the honest answer when the bar was
// hidden on a table nobody was sitting at. The bar isn't hidden any more: an
// empty table is the one state a dealer meets before they know how any of this
// works, so it says so and offers the next step.
const state = HoldemState.createDefault();
assert.equal(barOn(state).announcement(), "Nobody's at the table yet.");
});
test("and a table with one named player has moved on from that", () => {
const state = HoldemState.createDefault();
state.seats[0].name = "Ann";
assert.notEqual(barOn(state).announcement(), "Nobody's at the table yet.");
});
});
describe("while a hand is running", () => {
const dealt = () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], smallBlind: 1, bigBlind: 2 });
state.seats.forEach((p, i) => { p.name = ["Ann", "Ben", "Cal", "Dee"][i] || ""; });
Betting.beginHand(state);
return state;
};
test("names who is on the clock, what it costs, and the pot", () => {
const state = dealt();
const said = barOn(state).announcement();
assert.match(said, /to act/);
assert.match(said, /\$2 to call/);
assert.match(said, /Pot \$3\./);
});
test("it is the seat that is actually acting, not the one after it", () => {
const state = dealt();
const name = HoldemState.displayName(state.seats[state.actingSeatIndex], state.actingSeatIndex);
assert.ok(barOn(state).announcement().startsWith(name + " to act"));
});
test("says checked to them rather than $0 to call", () => {
// A number nobody owes is a number a dealer has to stop and parse.
const state = dealt();
state.currentBet = 0;
assert.match(barOn(state).announcement(), /checked to them/);
assert.doesNotMatch(barOn(state).announcement(), /\$0 to call/);
});
test("a street with nobody left to act says the street is done", () => {
const state = dealt();
state.actingSeatIndex = null;
state.street = "flop";
assert.equal(barOn(state).announcement(), "Flop betting is done.");
});
test("and a finished hand points at the pot", () => {
const state = dealt();
state.seats.forEach((p, i) => { if (i !== 0) p.folded = true; });
assert.equal(Betting.isHandOver(state), true);
assert.equal(barOn(state).announcement(), "Hand over. Resolve the pot.");
});
});
describe("how often it speaks", () => {
// A live region that is handed the same sentence twice reads it out twice, and
// render() runs for a great many reasons that have nothing to do with whose turn
// it is — a resize, a panel opening, a note being typed.
const stubTarget = () => {
const el = { textContent: "" };
global.document = { getElementById: id => (id === "actionAnnouncer" ? el : null) };
return el;
};
test("says nothing new when nothing changed", () => {
const el = stubTarget();
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], smallBlind: 1, bigBlind: 2 });
Betting.beginHand(state);
const bar = barOn(state);
bar.announce();
const first = el.textContent;
assert.ok(first.length > 0);
el.textContent = "CLEARED"; // if announce() writes again, this comes back
bar.announce();
assert.equal(el.textContent, "CLEARED", "the same sentence was not written twice");
delete global.document;
});
test("but does when the action moves", () => {
const el = stubTarget();
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], smallBlind: 1, bigBlind: 2 });
Betting.beginHand(state);
const bar = barOn(state);
bar.announce();
const first = el.textContent;
Betting.call(state, state.actingSeatIndex);
bar.announce();
assert.notEqual(el.textContent, first);
assert.match(el.textContent, /to act/);
delete global.document;
});
});
+98
View File
@@ -0,0 +1,98 @@
// Perceptual contrast (APCA), and how much of it each piece of text needs.
//
// The audit already had a contrast check and it passed, which is why this exists. That
// check measures WCAG 2 contrast ratio, and WCAG 2's own authors say it is the wrong
// instrument for this page: the ratio is a luminance gap, and a luminance gap
// systematically *overstates* readability once one of the two colours is near black.
// This table's page is #141414 and its panels are darker still, so every reading it
// takes is on the side of the algorithm where it is least reliable. Text can clear
// 4.5:1 and still read as a smudge.
//
// APCA asks the other question — can text of *this size and weight* be read on this
// background — and answers it on a scale where a given number means the same
// readability at either polarity. It replaces nothing: the WCAG check stays, because
// that is the number an accessibility audit will ask for. This is the one that says
// whether a dealer can read it in a dim room.
//
// Constants are 0.98G-4g, and the implementation is checked against the eight
// published reference pairs in apcaMath.test.js. An unverified contrast checker is
// worse than none, because it launders a guess into a number.
const MAIN_TRC = 2.4;
const R_CO = 0.2126729, G_CO = 0.7151522, B_CO = 0.0721750;
const NORM_BG = 0.56, NORM_TXT = 0.57, REV_TXT = 0.62, REV_BG = 0.65;
const BLK_THRS = 0.022, BLK_CLMP = 1.414;
const SCALE_BOW = 1.14, SCALE_WOB = 1.14;
const LO_BOW_OFFSET = 0.027, LO_WOB_OFFSET = 0.027;
const DELTA_Y_MIN = 0.0005, LO_CLIP = 0.001;
// Screen luminance. Note this is a straight exponent, not the sRGB piecewise curve —
// APCA specifies the simple 2.4 power and the two are not interchangeable here.
function sRGBtoY(rgb) {
const s = c => Math.pow(c / 255.0, MAIN_TRC);
return R_CO * s(rgb[0]) + G_CO * s(rgb[1]) + B_CO * s(rgb[2]);
}
// Signed: positive is dark text on light, negative is light text on dark. The sign
// carries the polarity, and polarity is not cosmetic — the same luminance pair reads
// differently each way round, which is the whole reason this exists.
function contrast(txtY, bgY) {
txtY = txtY > BLK_THRS ? txtY : txtY + Math.pow(BLK_THRS - txtY, BLK_CLMP);
bgY = bgY > BLK_THRS ? bgY : bgY + Math.pow(BLK_THRS - bgY, BLK_CLMP);
if (Math.abs(bgY - txtY) < DELTA_Y_MIN) return 0.0;
let sapc, out;
if (bgY > txtY) {
sapc = (Math.pow(bgY, NORM_BG) - Math.pow(txtY, NORM_TXT)) * SCALE_BOW;
out = sapc < LO_CLIP ? 0.0 : sapc - LO_BOW_OFFSET;
} else {
sapc = (Math.pow(bgY, REV_BG) - Math.pow(txtY, REV_TXT)) * SCALE_WOB;
out = sapc > -LO_CLIP ? 0.0 : sapc + LO_WOB_OFFSET;
}
return out * 100;
}
function lc(textRgb, bgRgb) {
return contrast(sRGBtoY(textRgb), sRGBtoY(bgRgb));
}
// How much contrast this text needs, from the published APCA levels:
//
// Lc 90 fluent body text; and the floor for anything at 14px/400 or 12px/400
// Lc 75 the minimum for a column of body text (18px/400, 16px/500, 14px/700)
// Lc 60 the minimum for content text that isn't a column or block
// Lc 45 larger, heavier text — headlines at 36px/400 or 24px/700
// Lc 30 the absolute floor, and only for genuinely spot-read text
//
// Interpolating between those anchors rather than inventing a curve. Weight is folded
// in as an effective size because that is how the published table behaves: bolder
// strokes buy roughly the same readability as bigger ones, and 14px/700 sitting at the
// same Lc 75 as 18px/400 is the anchor that fixes the exchange rate.
function requiredLc(px, weight = 400) {
const heavier = Math.max(0, (Number(weight) || 400) - 400) / 300; // 400..700 -> 0..1
const effective = px * (1 + 0.28 * heavier); // 14px/700 ~= 18px/400
const anchors = [
[12, 90],
[14, 90],
[15, 82],
[18, 75],
[24, 60],
[36, 45],
];
if (effective <= anchors[0][0]) return anchors[0][1];
const last = anchors[anchors.length - 1];
if (effective >= last[0]) return last[1];
for (let i = 1; i < anchors.length; i++) {
const [x0, y0] = anchors[i - 1];
const [x1, y1] = anchors[i];
if (effective <= x1) {
const t = (effective - x0) / (x1 - x0);
return y0 + t * (y1 - y0);
}
}
return last[1];
}
module.exports = { sRGBtoY, contrast, lc, requiredLc };
+141
View File
@@ -0,0 +1,141 @@
// The contrast arithmetic, checked against the numbers its authors publish.
//
// A contrast checker that is wrong is worse than not having one, because it launders a
// guess into a number and then a stylesheet gets tuned to it. The eight pairs below are
// the reference values published with APCA 0.98G-4g; if the implementation drifts from
// them by so much as a rounding change, these fail and nothing downstream is believed.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { lc, contrast, sRGBtoY, requiredLc } = require("./apca");
const hex = h => {
h = h.replace("#", "");
if (h.length === 3) h = h.split("").map(c => c + c).join("");
return [0, 2, 4].map(i => parseInt(h.slice(i, i + 2), 16));
};
describe("the published reference pairs", () => {
// Text first, background second, exactly as the reference table lists them.
const CASES = [
["#888", "#fff", 63.056469930209424],
["#fff", "#888", -68.54146436644962],
["#000", "#aaa", 58.146262578561334],
["#aaa", "#000", -56.24113336839742],
["#123", "#def", 91.66830811481631],
["#def", "#123", -93.06770049484275],
["#123", "#444", 8.32326136957393],
["#444", "#123", -7.526878460278154],
];
CASES.forEach(([text, bg, expected]) => {
test(`${text} on ${bg} is Lc ${expected.toFixed(2)}`, () => {
assert.ok(Math.abs(lc(hex(text), hex(bg)) - expected) < 1e-9,
`got ${lc(hex(text), hex(bg))}, want ${expected}`);
});
});
});
describe("polarity", () => {
test("is signed, and the sign is not decoration", () => {
// Dark text on light is positive, light text on dark negative. The two are not
// mirror images — swapping them changes the magnitude as well as the sign,
// which is the entire reason this is used instead of a ratio.
const dark = lc(hex("#888"), hex("#fff"));
const light = lc(hex("#fff"), hex("#888"));
assert.ok(dark > 0 && light < 0);
assert.ok(Math.abs(Math.abs(dark) - Math.abs(light)) > 5,
"the same pair reads differently each way round");
});
test("a colour on itself is nothing at all", () => {
assert.equal(lc(hex("#2c2c2c"), hex("#2c2c2c")), 0);
});
test("and so is a difference too small to see", () => {
// Below deltaYmin the answer is zero rather than a tiny number, so a rounding
// artefact can never be reported as contrast.
assert.equal(lc([44, 44, 44], [44, 44, 45]), 0);
});
});
describe("luminance", () => {
test("is the straight 2.4 power, not the sRGB piecewise curve", () => {
// Worth pinning: the two are close enough to look right and different enough to
// move a borderline reading. Mid grey under the piecewise curve is ~0.2159.
assert.ok(Math.abs(sRGBtoY([128, 128, 128]) - Math.pow(128 / 255, 2.4)) < 1e-6);
});
test("white is as close to 1 as the published coefficients get", () => {
// 1.0000001, not 1. The three coefficients are given to seven places and sum to
// a hair over one, and that is the specification rather than a mistake to
// correct — the reference pairs above only come out exact with these values.
// Asserted loosely on purpose: tightening this to 1e-12 fails on correct code.
assert.ok(Math.abs(sRGBtoY([255, 255, 255]) - 1) < 1e-6);
assert.equal(sRGBtoY([0, 0, 0]), 0);
});
test("green carries most of it", () => {
// Not arbitrary: it's why gold on green was the worst pairing on the felt.
assert.ok(sRGBtoY([0, 255, 0]) > sRGBtoY([255, 0, 0]) + sRGBtoY([0, 0, 255]));
});
test("contrast() takes luminances, lc() takes colours", () => {
assert.equal(contrast(sRGBtoY(hex("#888")), sRGBtoY(hex("#fff"))), lc(hex("#888"), hex("#fff")));
});
});
describe("how much contrast a given piece of text needs", () => {
test("the published anchors land where they should", () => {
assert.equal(Math.round(requiredLc(12, 400)), 90);
assert.equal(Math.round(requiredLc(14, 400)), 90);
assert.equal(Math.round(requiredLc(18, 400)), 75);
assert.equal(Math.round(requiredLc(24, 400)), 60);
assert.equal(Math.round(requiredLc(36, 400)), 45);
});
test("smaller text asks for more, never less", () => {
for (let px = 9; px < 40; px++) {
assert.ok(requiredLc(px, 400) >= requiredLc(px + 1, 400),
`${px}px should ask for at least as much as ${px + 1}px`);
}
});
test("weight buys size, at the rate the published table sets", () => {
// 14px/700 and 18px/400 sit at the same Lc 75 in the table, and that pair is
// what fixes the exchange rate between weight and size here.
assert.ok(Math.abs(requiredLc(14, 700) - requiredLc(18, 400)) < 3);
assert.ok(requiredLc(14, 700) < requiredLc(14, 400), "bold asks for less than regular");
});
test("it never runs off either end", () => {
assert.equal(requiredLc(4, 400), 90);
assert.equal(requiredLc(200, 900), 45);
});
});
describe("what the page's own colours measure", () => {
// These are the readings the round was built on. Pinned so that a future change to
// the tokens has to look at them rather than move them by accident.
const PANEL = hex("#1a1a1a");
test("the greys these tokens replaced were below the floor", () => {
assert.ok(Math.abs(lc(hex("#9a9a9a"), PANEL)) < 50, "the old muted grey");
assert.ok(Math.abs(lc(hex("#8a8a8a"), PANEL)) < 40, "the old dim grey");
});
test("and the ones that replaced them clear it", () => {
assert.ok(Math.abs(lc(hex("#c8c8c8"), PANEL)) >= 60, "muted");
assert.ok(Math.abs(lc(hex("#b4b4b4"), PANEL)) >= 58, "dim");
});
test("the three tiers are still three tiers", () => {
// The worry with lifting muted and dim is that they collapse into --text and the
// page flattens. They don't: the spread is wider than it was.
const text = Math.abs(lc(hex("#eeeeee"), PANEL));
const muted = Math.abs(lc(hex("#c8c8c8"), PANEL));
const dim = Math.abs(lc(hex("#b4b4b4"), PANEL));
assert.ok(text > muted && muted > dim, "still ordered");
assert.ok(text - dim > 30, "and still a real spread");
});
});
+175
View File
@@ -0,0 +1,175 @@
// Chips the pot was priced with that nobody actually paid.
//
// There are two ways a contribution gets into this tool. Tapping the action moves
// chips: the bet leaves the stack, sits in front of the player, and is swept into their
// hand total at the end of the street. The other is the "without live betting" panel —
// a base pot amount, or chips counted out on a seat — where the dealer is recording a
// hand they are watching rather than driving, and nothing ever leaves a stack.
//
// The pot wizard prices both the same way and awarding it credits the winner, so in
// that second mode the table simply grew.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Money, Betting, HoldemState, chipsOnTable } = require("./load");
const basePotTable = (base = 50, stack = 200) => {
const state = HoldemState.createDefault();
HoldemState.resizeSeats(state, 4);
state.basePotAmount = base;
["Ann", "Ben", "Cal"].forEach((name, i) => {
state.seats[i].name = name;
state.seats[i].inHand = true;
state.seats[i].stack = stack;
state.seats[i].buyIns = stack;
});
return state;
};
const pricedPot = (state) => state.seats.reduce(
(sum, p) => sum + HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount), 0);
describe("what nobody paid", () => {
test("comes off the stacks that owe it", () => {
const state = basePotTable();
const collected = Betting.collectAssumedContributions(state);
assert.equal(collected.total, 150);
assert.deepEqual(collected.taken.map(row => row.name), ["Ann", "Ben", "Cal"]);
assert.deepEqual(state.seats.slice(0, 3).map(p => p.stack), [150, 150, 150]);
});
test("and lands where the rest of the tool looks for chips in the middle", () => {
const state = basePotTable();
Betting.collectAssumedContributions(state);
assert.deepEqual(state.seats.slice(0, 3).map(p => p.amountOverride), [50, 50, 50]);
assert.equal(HoldemState.unresolvedContributions(state), 150);
});
test("chips counted out on a seat are money too", () => {
const state = basePotTable(0);
state.seats[0].chipCounts = { red: 4 }; // four $5 chips
const collected = Betting.collectAssumedContributions(state);
assert.equal(collected.total, 20);
assert.equal(state.seats[0].stack, 180);
});
});
describe("what has already been paid", () => {
test("a live bet is left exactly alone", () => {
const state = basePotTable(0);
state.smallBlind = 5;
state.bigBlind = 10;
Betting.beginHand(state);
const before = state.seats.map(p => Betting.stackOf(state, p));
const collected = Betting.collectAssumedContributions(state);
assert.equal(collected.total, 0, "the blinds are already off their stacks");
assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), before);
});
test("nor is a swept street taken a second time", () => {
const state = basePotTable(0);
state.smallBlind = 5;
state.bigBlind = 10;
Betting.beginHand(state);
Betting.sweepToHandTotals(state);
const before = state.seats.map(p => Betting.stackOf(state, p));
assert.equal(Betting.collectAssumedContributions(state).total, 0);
assert.deepEqual(state.seats.map(p => Betting.stackOf(state, p)), before);
});
test("a base pot on top of a live bet only collects the difference", () => {
// The base pot says "assume everyone is in for fifty". Somebody who has already
// put twenty in owes thirty of it, not fifty.
const state = basePotTable(50);
state.seats[0].committed = 20;
state.seats[0].stack = 180;
const collected = Betting.collectAssumedContributions(state);
const ann = collected.taken.find(row => row.name === "Ann");
assert.equal(ann.amount, 30);
assert.equal(state.seats[0].stack, 150, "fifty in the middle, either way");
});
});
describe("what it will not do", () => {
test("take more than a player is holding", () => {
const state = basePotTable(50);
state.seats[1].stack = 20;
Betting.collectAssumedContributions(state);
assert.equal(state.seats[1].stack, 0, "all of it, and no further");
assert.equal(state.seats[1].amountOverride, 20);
});
test("touch an empty chair", () => {
const state = basePotTable();
const before = { ...state.seats[3] };
Betting.collectAssumedContributions(state);
assert.equal(state.seats[3].stack, before.stack);
assert.equal(state.seats[3].amountOverride, before.amountOverride);
});
test("or a seat that isn't in the hand", () => {
// The base pot stands in for "everyone put the same in", and somebody sitting
// out did not.
const state = basePotTable();
state.seats[2].inHand = false;
const collected = Betting.collectAssumedContributions(state);
assert.deepEqual(collected.taken.map(row => row.name), ["Ann", "Ben"]);
assert.equal(state.seats[2].stack, 200);
});
});
describe("the whole hand", () => {
test("conserves the chips on the table, which it did not before", () => {
const state = basePotTable();
const total = chipsOnTable(state);
const pot = pricedPot(state);
Betting.collectAssumedContributions(state);
Betting.creditWinnings(state, [{ id: state.seats[0].id, label: "Ann", amount: pot }]);
HoldemState.startNextHand(state);
assert.equal(chipsOnTable(state), total, "six hundred before, six hundred after");
assert.deepEqual(state.seats.slice(0, 3).map(p => p.stack), [300, 150, 150]);
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
});
test("and the bank check stays balanced all the way through", () => {
const state = basePotTable();
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
Betting.collectAssumedContributions(state);
assert.equal(HoldemState.bankReconciliation(state).balanced, true, "chips are in the middle, not gone");
Betting.creditWinnings(state, [{ id: state.seats[0].id, label: "Ann", amount: 150 }]);
HoldemState.startNextHand(state);
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
});
test("a live-betting hand is unchanged by any of this", () => {
const state = basePotTable(0);
state.smallBlind = 5;
state.bigBlind = 10;
const total = chipsOnTable(state);
Betting.beginHand(state);
[3, 0, 1, 2].forEach(() => { if (state.actingSeatIndex !== null) Betting.call(state, state.actingSeatIndex); });
Betting.sweepToHandTotals(state);
const pot = HoldemState.unresolvedContributions(state);
Betting.collectAssumedContributions(state);
Betting.creditWinnings(state, [{ id: state.seats[0].id, label: "Ann", amount: pot }]);
HoldemState.startNextHand(state);
assert.equal(chipsOnTable(state), total);
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
});
test("collecting twice takes nothing the second time", () => {
// The pot wizard can be opened, cancelled and opened again.
const state = basePotTable();
Betting.collectAssumedContributions(state);
const stacks = state.seats.map(p => p.stack);
const again = Betting.collectAssumedContributions(state);
assert.equal(again.total, 0);
assert.deepEqual(state.seats.map(p => p.stack), stacks);
});
});
+108
View File
@@ -0,0 +1,108 @@
// The sums the contrast check runs on, tested away from a browser.
//
// The check itself needs a page to look at, but the arithmetic underneath it doesn't —
// and the arithmetic is the part that decides whether a real fault is reported or
// waved through. Faded text used to be filed as "not checked" rather than measured;
// this is the compositing that replaced that.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const LayoutAudit = require("./uiAudit");
const WHITE = { r: 255, g: 255, b: 255, a: 1 };
const BLACK = { r: 0, g: 0, b: 0, a: 1 };
const PAGE = { r: 28, g: 28, b: 28, a: 1 }; // the felt page behind everything
const PUCK = { r: 44, g: 44, b: 44, a: 1 }; // a seat's own background
describe("the ratio itself", () => {
test("black on white is the full 21:1, either way round", () => {
assert.ok(Math.abs(LayoutAudit.contrastRatio(BLACK, WHITE) - 21) < 0.01);
assert.ok(Math.abs(LayoutAudit.contrastRatio(WHITE, BLACK) - 21) < 0.01);
});
test("a colour against itself is 1:1", () => {
assert.ok(Math.abs(LayoutAudit.contrastRatio(PUCK, PUCK) - 1) < 0.001);
});
});
describe("text on a box that is being faded", () => {
// 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.
const fade = alpha => ({ alpha, behind: PAGE });
test("at full strength nothing is composited", () => {
const painted = LayoutAudit.asPainted(WHITE, PUCK, { alpha: 1, behind: PAGE });
assert.deepEqual(painted.fg, WHITE);
assert.deepEqual(painted.bg, PUCK);
});
test("fading moves both the text and its background, not just the text", () => {
const painted = LayoutAudit.asPainted(WHITE, PUCK, fade(0.7));
assert.ok(painted.fg.r < WHITE.r, "the text dims");
assert.ok(painted.bg.r < PUCK.r, "and so does what it sits on");
assert.ok(painted.bg.r > PAGE.r, "but not all the way to the page");
});
test("which is what stops a fade being reported as a fault it isn't", () => {
// Dimming light text on a dark puck over a dark page costs contrast, but far
// less than measuring the faded text against the *unfaded* puck would claim.
const honest = LayoutAudit.asPainted(WHITE, PUCK, fade(0.7));
const wrong = { fg: LayoutAudit.over({ ...WHITE, a: 0.7 }, PUCK), bg: PUCK };
assert.ok(LayoutAudit.contrastRatio(honest.fg, honest.bg) >
LayoutAudit.contrastRatio(wrong.fg, wrong.bg));
});
test("a fade all the way out lands everything on the page behind", () => {
const painted = LayoutAudit.asPainted(WHITE, PUCK, fade(0));
assert.deepEqual(painted.fg, PAGE);
assert.deepEqual(painted.bg, PAGE);
});
test("with nothing known to be behind it, the fade is left alone", () => {
// Better to measure the unfaded pair than to invent a backdrop.
const painted = LayoutAudit.asPainted(WHITE, PUCK, { alpha: 0.5, behind: null });
assert.deepEqual(painted.fg, WHITE);
assert.deepEqual(painted.bg, PUCK);
});
});
describe("a fade makes small grey text worse, which is the fault it went looking for", () => {
// Deliberately not asserting a particular CSS colour here: the page's own greys are
// measured in the browser, against the real gradients, where they live. What this
// pins is the direction — that dimming a box costs its text contrast, which is why
// filing faded text as "not checked" hid faults rather than avoiding false ones.
test("the same grey scores lower once its box is dimmed", () => {
const grey = LayoutAudit.parseColour("rgb(138, 138, 138)");
const solid = LayoutAudit.asPainted(grey, PUCK, { alpha: 1, behind: PAGE });
const dimmed = LayoutAudit.asPainted(grey, PUCK, { alpha: 0.7, behind: PAGE });
assert.ok(LayoutAudit.contrastRatio(dimmed.fg, dimmed.bg) <
LayoutAudit.contrastRatio(solid.fg, solid.bg));
});
test("and a lighter grey scores higher at the same fade", () => {
const at = colour => {
const painted = LayoutAudit.asPainted(LayoutAudit.parseColour(colour), PUCK, { alpha: 0.7, behind: PAGE });
return LayoutAudit.contrastRatio(painted.fg, painted.bg);
};
assert.ok(at("rgb(174, 174, 174)") > at("rgb(138, 138, 138)"));
});
});
describe("reading a colour off the page", () => {
test("rgb and rgba both come back as numbers", () => {
assert.deepEqual(LayoutAudit.parseColour("rgb(1, 2, 3)"), { r: 1, g: 2, b: 3, a: 1 });
assert.deepEqual(LayoutAudit.parseColour("rgba(1, 2, 3, 0.5)"), { r: 1, g: 2, b: 3, a: 0.5 });
});
test("a gradient gives up every stop, because text could be over any of them", () => {
const stops = LayoutAudit.gradientStops("linear-gradient(rgb(203, 170, 240), rgb(169, 127, 214))");
assert.equal(stops.length, 2);
assert.deepEqual(stops[0], { r: 203, g: 170, b: 240, a: 1 });
assert.deepEqual(stops[1], { r: 169, g: 127, b: 214, a: 1 });
});
test("no background image, no stops", () => {
assert.deepEqual(LayoutAudit.gradientStops("none"), []);
});
});
+224
View File
@@ -0,0 +1,224 @@
// The banker's job, which is a different job from the dealer's: every chip on the
// table is a promise to pay cash for it later, and somebody has to be able to check
// that the promise can be kept before the last two players try to cash out.
const { test, describe } = require("node:test");
const assert = require("node:assert/strict");
const { Betting, HoldemState, Money, makeTable } = require("./load");
const seat = (state, index, name, cash, chips) => {
const p = state.seats[index];
p.name = name; p.inHand = true; p.buyIns = cash; p.stack = chips;
return p;
};
describe("everyone with money in the game is accounted for", () => {
test("a player who has bought in but played no logged hand still appears", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 0 });
seat(state, 0, "Ann", 100, 100);
seat(state, 1, "Ben", 100, 100);
seat(state, 2, "Cal", 200, 200); // just sat down
state.seats[0].amountOverride = 10;
state.seats[1].amountOverride = 10;
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "Ann", amount: 20 }]);
state.seats[0].amountOverride = null;
state.seats[1].amountOverride = null;
const names = HoldemState.sessionStandings(state).map(r => r.name);
assert.ok(names.includes("Cal"), `Cal handed over $200 and is missing: ${names.join(", ")}`);
});
test("their row reads as no hands played, not as a loss", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1], stack: 0 });
seat(state, 0, "Ann", 100, 100);
seat(state, 1, "Ben", 200, 200);
const cal = HoldemState.sessionStandings(state).find(r => r.name === "Ben");
assert.equal(cal.in, 0);
assert.equal(cal.won, 0);
assert.equal(cal.net, 0);
assert.equal(cal.boughtIn, 200);
assert.equal(cal.cashPosition, 0, "level: they've bought chips and still hold them");
});
test("an empty chair is not dragged into the settle-up", () => {
const state = makeTable({ seatCount: 9, seated: [0], stack: 0 });
seat(state, 0, "Ann", 100, 100);
const rows = HoldemState.sessionStandings(state);
assert.equal(rows.length, 1);
});
test("somebody who has cashed out completely still shows what they took", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1], stack: 0 });
seat(state, 0, "Ann", 100, 0); // handed every chip back
seat(state, 1, "Ben", 100, 200);
const ann = HoldemState.sessionStandings(state).find(r => r.name === "Ann");
assert.equal(ann.cashPosition, -100, "down the whole buy-in");
});
});
describe("does the cash box cover the chips on the table", () => {
test("a straight game balances", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 0 });
seat(state, 0, "Ann", 100, 60);
seat(state, 1, "Ben", 100, 140);
seat(state, 2, "Cal", 100, 100);
const bank = HoldemState.bankReconciliation(state);
assert.equal(bank.cashIn, 300);
assert.equal(bank.chipsInPlay, 300);
assert.equal(bank.difference, 0);
assert.equal(bank.balanced, true);
});
test("chips outrunning the cash box is caught before cash-out", () => {
// The 2am failure: the last players out can't be paid.
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 0 });
seat(state, 0, "Ann", 100, 100);
seat(state, 1, "Ben", 100, 100);
seat(state, 2, "Cal", 100, 260); // took chips for a rebuy nobody wrote down
const bank = HoldemState.bankReconciliation(state);
assert.equal(bank.cashIn, 300);
assert.equal(bank.chipsInPlay, 460);
assert.equal(bank.difference, 160);
assert.equal(bank.balanced, false);
});
test("cash taken for chips never handed over is caught too", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1], stack: 0 });
seat(state, 0, "Ann", 200, 100); // paid 200, only got 100 in chips
seat(state, 1, "Ben", 100, 100);
const bank = HoldemState.bankReconciliation(state);
assert.equal(bank.difference, -100);
assert.equal(bank.balanced, false);
});
test("chips in the middle still count as chips in play", () => {
// Money in the pot is somebody's; it just hasn't been won yet. Leaving it out
// would report a shortfall every time a hand was open — which is nearly always.
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], dealerSeatIndex: 0 });
state.seats.forEach(p => { if (p.inHand) p.buyIns = 100; });
const before = HoldemState.bankReconciliation(state);
assert.equal(before.balanced, true);
Betting.beginHand(state); // blinds go in
Betting.raiseTo(state, 0, 25); // and a raise
Betting.sweepToHandTotals(state); // banked into the pot
const during = HoldemState.bankReconciliation(state);
assert.equal(during.balanced, true, "an open pot is not a discrepancy");
assert.equal(during.chipsInPlay, before.chipsInPlay);
});
test("a whole session of play never knocks the books out", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
state.seats.forEach(p => { if (p.inHand) p.buyIns = 100; });
for (let hand = 0; hand < 6; hand++) {
Betting.beginHand(state);
Betting.liveSeats(state).forEach(s => {
if (Betting.toCall(state, s.player) > 0) Betting.call(state, s.index);
});
Betting.sweepToHandTotals(state);
const pot = HoldemState.totalPot(state);
const winner = Betting.liveSeats(state)[0];
const payouts = [{ id: winner.player.id, label: "w", amount: pot }];
HoldemState.recordHand(state, payouts);
Betting.creditWinnings(state, payouts);
HoldemState.startNextHand(state);
assert.equal(HoldemState.bankReconciliation(state).balanced, true, `books off after hand ${hand + 1}`);
}
});
test("a rebuy keeps the books straight, a chip grab doesn't", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1], stack: 0 });
seat(state, 0, "Ann", 100, 100);
seat(state, 1, "Ben", 100, 100);
// A proper rebuy: cash to the banker, chips to the player.
state.seats[0].buyIns += 100;
state.seats[0].stack += 100;
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
// Chips taken without paying.
state.seats[1].stack += 50;
const bank = HoldemState.bankReconciliation(state);
assert.equal(bank.balanced, false);
assert.equal(bank.difference, 50);
});
test("nothing to reconcile before anybody has bought in", () => {
const state = makeTable({ seatCount: 6, seated: [], stack: 0 });
const bank = HoldemState.bankReconciliation(state);
assert.equal(bank.cashIn, 0);
assert.equal(bank.chipsInPlay, 0);
assert.equal(bank.balanced, true);
});
test("float dust is not a discrepancy", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1], stack: 0 });
seat(state, 0, "Ann", 0.1 + 0.2, 0.3);
seat(state, 1, "Ben", 10, 10);
assert.equal(HoldemState.bankReconciliation(state).balanced, true);
});
});
describe("the number the banker counts out", () => {
// The standings had two signed columns and neither was an amount to pay. A player
// up $140 on a $340 stack read "$140" under a heading saying "Cash out", at the
// exact moment somebody was counting money onto the table.
test("chips in front is what they are holding, not what they are up", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 340 });
state.seats[0].name = "Ann";
state.seats[0].buyIns = 200;
state.seats[1].buyIns = 200;
state.seats[1].stack = 60;
const rows = HoldemState.sessionStandings(state);
const ann = rows.find(r => r.name === "Ann");
assert.equal(ann.chipsInFront, 340, "the cash to count out");
assert.equal(ann.cashPosition, 140, "and separately, that they are up $140");
});
test("a player who is down still has chips to be handed", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 60 });
state.seats[0].name = "Cal";
state.seats.forEach(p => { p.buyIns = 200; });
const cal = HoldemState.sessionStandings(state).find(r => r.name === "Cal");
assert.equal(cal.chipsInFront, 60, "they leave with $60 of chips");
assert.equal(cal.cashPosition, -140, "having lost $140 of the $200 they brought");
});
test("chips still out in the middle are counted as theirs", () => {
// Cashing out mid-hand isn't a thing, but the panel is read mid-hand constantly
// and a stack that drops by the size of a bet looks like money going missing.
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 200 });
state.seats[0].name = "Dee";
state.seats.forEach(p => { p.buyIns = 200; });
Betting.beginHand(state);
Betting.raiseTo(state, 0, 50);
const dee = HoldemState.sessionStandings(state).find(r => r.name === "Dee");
assert.equal(dee.chipsInFront, 200, "stack plus what's in the middle for them");
assert.equal(dee.cashPosition, 0, "so they are still level");
});
test("chips and up/down always differ by exactly the buy-in", () => {
const state = makeTable({ seatCount: 9, seated: [0, 1, 2, 3], stack: 100 });
[340, 150, 60, 250].forEach((chips, i) => {
state.seats[i].stack = chips;
state.seats[i].buyIns = 200 + i * 50;
});
HoldemState.sessionStandings(state).forEach(row => {
assert.equal(Money.round(row.chipsInFront - row.boughtIn), row.cashPosition,
`${row.name}'s two columns should tell one story`);
});
});
});
+473
View File
@@ -0,0 +1,473 @@
// Where a player's bet sits on the felt.
//
// The chips were the last thing on the table nobody had measured. They were laid out on
// a ring of their own at a fixed 27/44 of the seat's reach — a fraction picked when the
// seat ring was itself fixed — and the choice between that ring and clinging to the
// puck was made by asking how wide the *window* was. Neither of those is a question
// about the felt. A phone held sideways is 844px across and gives the felt 117px of
// height: it passed the width test, took the ring, and every chip on it was painted
// behind a seat. The seats carry `z-index: 1` and the bets layer carries none, so a
// chip under a puck is not dimmed or half-hidden — it is gone, and with it the only
// number saying what that player has in.
//
// Everything below is geometry, so it runs without a browser. The felts are the ones
// measured on real screens: 692x117 is a phone on its side, 300x216 a phone upright.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Render } = require("./load");
const RADII = { rx: 44, ry: 42 };
const PUCK = { width: 96, height: 64 };
const CHIP = { width: 44, height: 22 };
const felt = (width, height) => ({ width, height });
const spacingOn = (box, puck = PUCK, chip = CHIP) =>
({ box, puck, chip, clearance: Render.BET_CLEARANCE_PX });
// A chip's centre in pixels on a given felt.
const chipPixels = (index, count, box, spacing, radii = RADII) => {
const pos = Render.betPosition(index, count, box.width / box.height, radii, spacing);
return { x: (pos.left / 100) * box.width, y: (pos.top / 100) * box.height };
};
const seatPixels = (index, count, box, radii = RADII) => {
const pos = Render.seatPosition(index, count, box.width / box.height, radii);
return { x: (pos.left / 100) * box.width, y: (pos.top / 100) * box.height };
};
// Two axis-aligned boxes, by their centres. The same test the seats settle among
// themselves — see seatFootprint.test.js — because it is the same question.
const boxesOverlap = (a, b, sizeA, sizeB) =>
Math.abs(a.x - b.x) < (sizeA.width + sizeB.width) / 2 - 0.5 &&
Math.abs(a.y - b.y) < (sizeA.height + sizeB.height) / 2 - 0.5;
describe("reaching the edge of a box", () => {
test("straight out the side is half the width, straight up half the height", () => {
assert.equal(Render.edgeReach(96, 64, 1, 0), 48);
assert.equal(Render.edgeReach(96, 64, 0, -1), 32);
assert.equal(Render.edgeReach(96, 64, -1, 0), 48);
});
test("a corner is whichever edge the ray meets first", () => {
// 45° out of a 96x64 box: the top and bottom are nearer than the sides, so the
// ray leaves through one of those. A single radius — half the diagonal, 57.7 —
// would put a chip 25px further out than it needs to be, which on a 117px felt
// is the difference between clear of the puck and off the table.
const diagonal = Render.edgeReach(96, 64, Math.SQRT1_2, Math.SQRT1_2);
assert.ok(Math.abs(diagonal - 32 / Math.SQRT1_2) < 1e-9, `got ${diagonal}`);
assert.ok(diagonal < 48, "and never further than the wider half-edge");
});
test("a square is the same in every direction it is asked about", () => {
const straight = Render.edgeReach(50, 50, 0, 1);
assert.equal(Render.edgeReach(50, 50, 1, 0), straight);
});
});
describe("getting one box clear of another", () => {
const PUCK96 = { width: 96, height: 88 };
const CHIP50 = { width: 50, height: 21 };
// Travel `t` along `u` from the puck's centre, then ask whether the two boxes are
// apart. This is the question clearanceReach is supposed to answer, asked the long
// way round so the answer can be checked rather than restated.
const apart = (t, ux, uy) =>
Math.abs(t * ux) >= (PUCK96.width + CHIP50.width) / 2 - 1e-9 ||
Math.abs(t * uy) >= (PUCK96.height + CHIP50.height) / 2 - 1e-9;
test("straight up and down it is what adding the two half-heights gives", () => {
assert.equal(Render.clearanceReach(PUCK96, CHIP50, 0, -1), (88 + 21) / 2);
assert.equal(Render.clearanceReach(PUCK96, CHIP50, 1, 0), (96 + 50) / 2);
});
test("on a diagonal it is more than the two reaches added together", () => {
// The bug this replaced. Two axis-aligned boxes are clear when they are apart on
// one axis, and the travel that achieves that is the reach of the box you get by
// adding their widths and their heights — which is never less, and on a diagonal
// is more, than adding the two boxes' own reaches. At 768x1024 the difference was
// 11px, and 11px is how far inside its own player's puck the chip came to rest.
const dirs = [[0.83, 0.56], [0.6, 0.8], [0.9, 0.44], [Math.SQRT1_2, Math.SQRT1_2]];
let shortfalls = 0;
dirs.forEach(([ux, uy]) => {
const summed = Render.edgeReach(PUCK96.width, PUCK96.height, ux, uy) +
Render.edgeReach(CHIP50.width, CHIP50.height, ux, uy);
const proper = Render.clearanceReach(PUCK96, CHIP50, ux, uy);
assert.ok(proper >= summed - 1e-9, `${ux},${uy}: ${proper} < ${summed}`);
if (proper <= summed + 1e-9) return;
// Where they differ, the old answer was not merely smaller — it was wrong:
// travelling that far leaves the two boxes still on top of each other.
shortfalls++;
assert.ok(!apart(summed, ux, uy),
`${ux},${uy}: adding the reaches gave ${summed.toFixed(1)}, which is enough after all`);
});
assert.ok(shortfalls >= 2, "expected the two to actually differ on some of these");
});
test("and it is exactly enough — a hair less is not", () => {
const dirs = [[0, -1], [1, 0], [0.83, 0.56], [0.6, -0.8], [-0.9, 0.44]];
dirs.forEach(([ux, uy]) => {
const t = Render.clearanceReach(PUCK96, CHIP50, ux, uy);
assert.ok(apart(t, ux, uy), `${ux},${uy}: ${t}px still overlaps`);
assert.ok(!apart(t - 0.5, ux, uy), `${ux},${uy}: ${t}px is further than needed`);
});
});
});
describe("a chip on the ring", () => {
test("with nothing measured it keeps the proportions the ring always used", () => {
// The unit suite and the very first render both call it this way. 27/44 and
// 25/42 of the seat's own reach, so the chips move with the ring rather than
// with the felt.
const seat = Render.seatPosition(3, 9, 1.4, RADII);
const chip = Render.betPosition(3, 9, 1.4, RADII, null);
assert.ok(Math.abs((chip.left - 50) - (seat.left - 50) * (27 / 44)) < 1e-9);
assert.ok(Math.abs((chip.top - 50) - (seat.top - 50) * (25 / 42)) < 1e-9);
});
test("measured, it sits just clear of its own puck rather than at a fixed fraction", () => {
const box = felt(560, 400);
const spacing = spacingOn(box);
for (let i = 0; i < 9; i++) {
const seat = seatPixels(i, 9, box);
const chip = chipPixels(i, 9, box, spacing);
assert.ok(!boxesOverlap(seat, chip, PUCK, CHIP),
`seat ${i + 1}'s own chip is under its own puck`);
}
});
test("and never wanders into the pot badge in the middle", () => {
// The floor: 38% of the way out. Without it a chip walked in past a puck that
// is 96px wide on a felt whose reach is 90px would land on the pot total, which
// is the same fault at the other end of the spoke.
const box = felt(300, 216);
const spacing = spacingOn(box, { width: 200, height: 150 });
for (let i = 0; i < 6; i++) {
const seat = seatPixels(i, 6, box);
const chip = chipPixels(i, 6, box, spacing);
const seatReach = Math.hypot(seat.x - box.width / 2, seat.y - box.height / 2);
const chipReach = Math.hypot(chip.x - box.width / 2, chip.y - box.height / 2);
assert.ok(chipReach >= seatReach * 0.38 - 0.5,
`chip ${i + 1} is ${chipReach.toFixed(1)}px out of a possible ${seatReach.toFixed(1)}`);
}
});
test("a seat sitting dead centre gets its chip put somewhere, not NaN", () => {
const box = felt(400, 300);
const pos = Render.betPosition(0, 1, box.width / box.height, { rx: 0, ry: 0 }, spacingOn(box));
assert.ok(Number.isFinite(pos.left) && Number.isFinite(pos.top), JSON.stringify(pos));
});
});
describe("deciding whether a ring of chips fits at all", () => {
// The shape the layout works in: one entry per chip on the felt, carrying its own
// measured size, because a chip is as wide as the number printed on it.
const tableOf = (count, chip = CHIP) => {
Render.state = { seatCount: count, seats: Array.from({ length: count }, () => ({})) };
return Array.from({ length: count }, (_, i) =>
({ index: i, width: chip.width, height: chip.height }));
};
test("a desktop felt has room for one", () => {
const box = felt(700, 500);
const all = tableOf(9);
assert.equal(Render.chipsClearTheSeats(all, box.width / box.height, RADII, spacingOn(box)), true);
});
test("a phone on its side has not, whatever the window's width says about it", () => {
// 692x117, measured on an 844px-wide phone in landscape — a viewport that sails
// past a `max-width: 700px` test. The ring is a slot: the 38% floor puts a chip
// on the seat across the table from it.
const box = felt(692, 117);
const all = tableOf(9);
assert.equal(Render.chipsClearTheSeats(all, box.width / box.height, RADII, spacingOn(box)), false,
"this is the felt where every chip was painted behind a puck");
});
test("its answer is the one the geometry gives", () => {
// Not a restatement of the two cases above: whatever it returns, it has to agree
// with laying the chips out and measuring them against every puck on the felt.
const shapes = [felt(700, 500), felt(692, 117), felt(300, 216), felt(1000, 620), felt(480, 300)];
shapes.forEach(box => {
const all = tableOf(9);
const spacing = spacingOn(box);
const aspect = box.width / box.height;
const clear = all.every(({ index: i }) => {
const chip = chipPixels(i, 9, box, spacing);
return all.every(({ index: j }) => !boxesOverlap(chip, seatPixels(j, 9, box), CHIP, PUCK));
});
assert.equal(Render.chipsClearTheSeats(all, aspect, RADII, spacing), clear,
`${box.width}x${box.height}`);
});
});
test("each chip is asked about at its own size", () => {
// A chip is as wide as the number on it: at 768x1024, four players' chips were
// 40px and one holding a four-figure bet was 47. Answering the question once, at
// the widest, told the whole felt it had no room for a ring — and dropped all
// five chips onto their own players' stacks to solve a problem four of them
// did not have.
// The felt and the pucks are the iPad's own, measured: 672x672, pucks 96x88.
const box = felt(672, 672);
const narrow = { width: 40, height: 20 };
const huge = { width: 420, height: 380 };
const spacing = { box, puck: { width: 96, height: 88 }, clearance: Render.BET_CLEARANCE_PX };
const aspect = box.width / box.height;
assert.equal(Render.chipsClearTheSeats(tableOf(9, narrow), aspect, RADII, spacing), true,
"a felt this size has room for a ring of ordinary chips");
assert.equal(Render.chipsClearTheSeats(tableOf(9, huge), aspect, RADII, spacing), false,
"and none at all for a ring of chips that size");
// Now one of each. The answer has to come from the sizes in the list, not from
// one size standing in for all of them: with one huge chip the ring is off, and
// taking that chip out of the question puts it back.
const mixed = tableOf(9, narrow);
mixed[0] = { index: 0, ...huge };
assert.equal(Render.chipsClearTheSeats(mixed, aspect, RADII, spacing), false);
assert.equal(Render.chipsClearTheSeats(mixed.slice(1), aspect, RADII, spacing), true,
"the eight ordinary ones were never the problem");
});
test("and at the chips against each other", () => {
// The ring is a smaller circle than the seats' one and holds the same number of
// things, so it is the tighter fit of the two. Measured on a Pixel 7 with twelve
// players, two $10s on the ring sat on top of each other while every puck on the
// felt was clear — so a check that only looks at the pucks says yes to a layout
// that hides one player's bet behind another's.
// The Pixel 7's own numbers, measured: a 300x336 felt on a ring of 44 by 40.84,
// pucks 44x55, a pot badge 64x53, and the seven players who had put chips in.
const box = felt(300, 336);
const radii = { rx: 44, ry: 40.84 };
const puck = { width: 44, height: 55 };
const pot = { x: 150, y: 168, width: 64, height: 53 };
const spacing = { box, puck, pot, clearance: Render.BET_CLEARANCE_PX };
const aspect = box.width / box.height;
Render.state = { seatCount: 12, seats: Array.from({ length: 12 }, () => ({})) };
const bettors = [2, 3, 4, 5, 6, 7, 8];
const all = bettors.map(index => ({ index, width: index === 2 ? 44.1 : 49.7, height: 21 }));
const chips = all.map(entry => ({
...entry,
...chipPixels(entry.index, 12, box,
{ ...spacing, chip: { width: entry.width, height: entry.height } }, radii),
}));
const seats = [...Array(12).keys()].map(i => seatPixels(i, 12, box, radii));
assert.equal(chips.some(chip => seats.some(seat => boxesOverlap(chip, seat, chip, puck))), false,
"this table is chosen so the pucks are not the problem");
assert.equal(chips.some(chip => boxesOverlap(chip, pot, chip, pot)), false,
"nor the pot");
assert.ok(chips.some((chip, i) => chips.some((other, j) => i !== j &&
boxesOverlap(chip, other, chip, other))), "and so the chips are");
assert.equal(Render.chipsClearTheSeats(all, aspect, radii, spacing), false);
});
test("it looks at every puck, not just the chip's own", () => {
// The rule this replaced walked each chip in until it was clear of its *own*
// seat and stopped there, which says nothing about the eight other pucks on the
// felt. 560x190 is a felt where that rule is satisfied and the ring is still no
// good: every chip clears the puck it belongs to, and one of them is sitting on
// a stranger's.
const box = felt(560, 190);
const all = tableOf(9);
const spacing = spacingOn(box);
const chips = all.map(({ index: i }) => chipPixels(i, 9, box, spacing));
all.forEach(({ index: i }) => assert.ok(!boxesOverlap(chips[i], seatPixels(i, 9, box), CHIP, PUCK),
`chip ${i + 1} is under its own puck, so this felt does not make the point`));
assert.ok(all.some(({ index: i }) => all.some(({ index: j }) => i !== j &&
boxesOverlap(chips[i], seatPixels(j, 9, box), CHIP, PUCK))),
"expected a chip on somebody else's puck — what the old rule could not see");
assert.equal(Render.chipsClearTheSeats(all, box.width / box.height, RADII, spacing), false);
});
});
describe("a chip clinging to its puck", () => {
// The fallback when there is no room for a ring: the chip hangs off the puck on the
// side facing the empty middle of the felt.
//
// The felts the app actually draws, with the puck size each one settles on — read
// off the running site at each viewport rather than guessed here, because the tight
// felts shrink the puck too, and a test that holds the desktop's 96x64 puck against
// a 528x83 felt is describing a table the app never draws.
const REAL = [
["Galaxy S8 on its side", felt(528, 83), { width: 44, height: 55 }],
["iPhone on its side", felt(692, 113), { width: 72, height: 55 }],
["Galaxy S8 upright, six-handed", felt(248, 280), { width: 78, height: 88 }],
["Galaxy S8 upright, packed", felt(248, 280), { width: 44, height: 55 }],
];
// Chips are 21x17 everywhere they are anchored — the amount is the only thing on
// one at that size.
const TIGHT = { puck: { width: 44, height: 55 }, chip: { width: 21, height: 17 } };
// What each chip is told to do, worked out for the whole table at once.
const placeAll = (count, box, puck, chip = TIGHT.chip, radii = RADII) => {
Render.state = { seatCount: count, seats: Array.from({ length: count }, () => ({})) };
const indices = [...Array(count).keys()];
const entries = indices.map(i => ({ index: i, width: chip.width, height: chip.height }));
const spacing = { box, puck, clearance: Render.BET_CLEARANCE_PX };
const offs = Render.anchoredChipTravel(entries, count, box.width / box.height, RADII, spacing);
return indices.map(i => {
const seat = Render.seatPosition(i, count, box.width / box.height, radii);
const at = { x: (seat.left / 100) * box.width, y: (seat.top / 100) * box.height };
const off = offs[i];
return { seat: at, chip: { x: at.x + off.x, y: at.y + off.y }, off, puck };
});
};
test("on its own, a chip asks to sit just clear of its puck", () => {
const box = felt(692, 113);
const puck = { width: 72, height: 55 };
const seat = Render.seatPosition(0, 9, box.width / box.height, RADII);
const off = Render.anchoredChipOffset(seat, puck, TIGHT.chip, box, RADII);
const travel = Math.hypot(off.x, off.y);
const ux = off.x / travel, uy = off.y / travel;
assert.ok(Math.abs(travel -
(Render.clearanceReach(puck, TIGHT.chip, ux, uy) + Render.BET_CLEARANCE_PX)) < 1e-6, `travelled ${travel}`);
});
test("and gets it, where nothing else is in the way", () => {
// 692x113 is the roomier of the two landscape felts. Every chip clears its own
// puck and none of them has to give ground.
placeAll(9, felt(692, 113), { width: 72, height: 55 }).forEach((row, i) => {
assert.ok(!boxesOverlap(row.seat, row.chip, row.puck, TIGHT.chip),
`seat ${i + 1}'s chip is inside its own puck, and nothing made it go there`);
});
});
test("it goes off the rim, not down the spoke", () => {
// On a felt 692x113 the two are nowhere near the same direction. A seat on the
// top rail has a spoke pointing diagonally inward — which along a slot this flat
// runs sideways into the next seat — while off the rim is very nearly straight
// down, into the empty middle. This is the seam that put two chips on top of
// each other on a Galaxy S8.
const box = felt(692, 113);
const rows = placeAll(9, box, { width: 72, height: 55 });
const top = rows.find(r => r.seat.y < box.height * 0.3 && r.seat.x > box.width * 0.7);
assert.ok(top, "expected a seat up on the right of the top rail");
assert.ok(Math.abs(top.off.y) > Math.abs(top.off.x) * 2,
`chip went ${top.off.x.toFixed(1)},${top.off.y.toFixed(1)} — that is along the rail, not off it`);
assert.ok(top.off.y > 0, "and downward, toward the middle of the felt");
});
test("no two chips on a full table land on each other", () => {
// Four sides gave three seams. There are no sides now, and this is what that is
// worth — on every felt the app actually draws, at every table size.
REAL.forEach(([name, box, puck]) => {
[6, 9, 12].forEach(count => {
const rows = placeAll(count, box, puck);
for (let a = 0; a < rows.length; a++) {
for (let b = a + 1; b < rows.length; b++) {
assert.ok(!boxesOverlap(rows[a].chip, rows[b].chip, TIGHT.chip, TIGHT.chip),
`${name}, ${count} seats: chips ${a + 1} and ${b + 1} overlap`);
}
}
});
});
});
test("a chip only gives ground when it is standing on something", () => {
// The point of pulling chips back one at a time rather than by a rule that
// applies to all of them. Stopping every chip at the felt's midline, or scaling
// them all by one factor, takes travel from the seats at the ends of a long
// rail — measured at 692x113, that parked two chips on their own player's stack
// to solve a collision that did not exist.
//
// So: every chip either got everything it asked for, or putting the last step
// back reintroduces a collision it is part of. The step goes back for all of
// them at once, because a facing pair gives ground together and the pass that
// finally separates such a pair moves both ends of it.
const step = Render.CHIP_PULLBACK_STEP_PX;
REAL.forEach(([name, box, puck]) => {
[6, 9, 12].forEach(count => {
const rows = placeAll(count, box, puck).map(row => {
const travel = Math.hypot(row.off.x, row.off.y);
const dir = travel < 1e-6 ? { x: 0, y: 0 } : { x: row.off.x / travel, y: row.off.y / travel };
const asked = travel < 1e-6 ? 0 :
Render.clearanceReach(puck, TIGHT.chip, dir.x, dir.y) +
Render.BET_CLEARANCE_PX;
const back = Math.min(asked, travel + step);
return { ...row, travel, asked,
undone: { x: row.seat.x + dir.x * back, y: row.seat.y + dir.y * back } };
});
rows.forEach((row, i) => {
if (row.travel < 1e-6 || row.travel >= row.asked - 1e-6) return;
const onSomething = rows.some((other, j) => j !== i && (
boxesOverlap(row.undone, other.undone, TIGHT.chip, TIGHT.chip) ||
boxesOverlap(row.undone, other.seat, TIGHT.chip, puck)));
assert.ok(onSomething,
`${name}, ${count} seats: chip ${i + 1} stopped ${(row.asked - row.travel).toFixed(1)}px short of nothing`);
});
});
});
});
test("nobody is pushed further out than they asked to go", () => {
// The pull-back only ever takes travel away. A chip pushed past its clearance
// would be floating out on the felt with nothing to say whose it is.
REAL.forEach(([name, box, puck]) => {
placeAll(12, box, puck).forEach((row, i) => {
const travel = Math.hypot(row.off.x, row.off.y);
if (travel < 1e-6) return;
const ux = row.off.x / travel, uy = row.off.y / travel;
const asked = Render.clearanceReach(puck, TIGHT.chip, ux, uy) +
Render.BET_CLEARANCE_PX;
assert.ok(travel <= asked + 0.5,
`${name}: chip ${i + 1} went ${travel.toFixed(1)}px when ${asked.toFixed(1)} was asked for`);
});
});
});
test("the direction turns smoothly all the way round the ring", () => {
// The property a set of discrete sides cannot have, and the reason there are
// none: neighbouring seats get neighbouring directions, with no step anywhere.
const box = felt(560, 400);
const angles = Array.from({ length: 24 }, (_, i) => {
const seat = Render.seatPosition(i, 24, box.width / box.height, RADII);
const off = Render.anchoredChipOffset(seat, PUCK, CHIP, box, RADII);
return Math.atan2(off.y, off.x);
});
angles.forEach((angle, i) => {
const next = angles[(i + 1) % angles.length];
let turn = Math.abs(next - angle);
if (turn > Math.PI) turn = 2 * Math.PI - turn;
assert.ok(turn < Math.PI / 4,
`between seats ${i + 1} and ${i + 2} the chip jumps ${(turn * 180 / Math.PI).toFixed(0)}°`);
});
});
test("it stops short of the pot total as well", () => {
// The other thing in the middle of the felt, and the one number on the table
// that belongs to everybody. A seat on the middle of a rail sends its chip
// straight at it: on a Galaxy S8 on its side, seat 1's $10 was sitting on
// "Pot $65". The badge is measured rather than assumed to be at the centre —
// it lives on the table, not on either overlay, and the two are not concentric.
const box = felt(528, 83);
const pot = { x: 264, y: 40, width: 96, height: 34 };
Render.state = { seatCount: 12, seats: Array.from({ length: 12 }, () => ({})) };
const indices = [...Array(12).keys()];
const entries = indices.map(i =>
({ index: i, width: TIGHT.chip.width, height: TIGHT.chip.height }));
const spacing = { box, puck: TIGHT.puck, clearance: Render.BET_CLEARANCE_PX, pot };
const offs = Render.anchoredChipTravel(entries, 12, box.width / box.height, RADII, spacing);
indices.forEach(i => {
const seat = Render.seatPosition(i, 12, box.width / box.height, RADII);
const chip = { x: (seat.left / 100) * box.width + offs[i].x,
y: (seat.top / 100) * box.height + offs[i].y };
assert.ok(!boxesOverlap(chip, pot, TIGHT.chip, pot),
`chip ${i + 1} is on the pot total`);
});
});
test("a seat with no rim to be off still gets an answer", () => {
const box = felt(400, 300);
const off = Render.anchoredChipOffset({ left: 50, top: 50 }, PUCK, CHIP, box, RADII);
assert.ok(Number.isFinite(off.x) && Number.isFinite(off.y), JSON.stringify(off));
assert.ok(off.y < 0, "upward, which is where a puck's own writing is not");
});
});
+198
View File
@@ -0,0 +1,198 @@
const { test, describe } = require("node:test");
const assert = require("node:assert/strict");
const { Betting, makeTable, chipsOnTable } = require("./load");
describe("Betting.beginHand", () => {
test("blinds are posted and the seat after the big blind acts first", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0, smallBlind: 1, bigBlind: 2 });
Betting.beginHand(state);
assert.equal(state.seats[1].committed, 1, "small blind");
assert.equal(state.seats[2].committed, 2, "big blind");
assert.equal(state.currentBet, 2);
assert.equal(state.actingSeatIndex, 3, "under the gun");
});
test("heads-up, the button posts the small blind and acts first pre-flop", () => {
const state = makeTable({ seatCount: 6, seated: [2, 5], dealerSeatIndex: 2 });
Betting.beginHand(state);
assert.equal(state.seats[2].committed, 1, "the button is the small blind");
assert.equal(state.seats[5].committed, 2);
assert.equal(state.actingSeatIndex, 2, "the small blind acts first pre-flop heads-up");
});
test("heads-up, the big blind acts first once the flop is out", () => {
const state = makeTable({ seatCount: 6, seated: [2, 5], dealerSeatIndex: 2 });
Betting.beginHand(state);
Betting.call(state, 2);
Betting.check(state, 5);
Betting.closeStreet(state);
assert.equal(state.street, "flop");
assert.equal(state.actingSeatIndex, 5, "the big blind is first to act after the flop");
});
test("a blind bigger than a stack puts that player all-in without stalling the action", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1], dealerSeatIndex: 0, smallBlind: 50, bigBlind: 100 });
state.seats[0].stack = 10; // can't cover the small blind
Betting.beginHand(state);
assert.equal(state.seats[0].stack, 0, "all-in for what they had");
assert.equal(state.seats[0].committed, 10);
assert.notEqual(state.actingSeatIndex, 0, "a player with no chips has nothing to decide");
});
test("posting blinds never invents or destroys chips", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
const before = chipsOnTable(state);
Betting.beginHand(state);
assert.equal(chipsOnTable(state), before);
});
});
describe("Betting street completion", () => {
test("a street ends once everyone has matched the bet and had a turn", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.call(state, 3);
Betting.call(state, 0);
Betting.call(state, 1);
assert.equal(Betting.isStreetComplete(state), false, "the big blind still has an option");
Betting.check(state, 2);
assert.equal(Betting.isStreetComplete(state), true);
assert.equal(state.actingSeatIndex, null);
});
test("a raise reopens the action for everyone who had already called", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.call(state, 3);
Betting.call(state, 0);
assert.equal(state.seats[3].hasActed, true);
Betting.raiseTo(state, 1, 10);
assert.equal(state.seats[3].hasActed, false, "seat 3 owes a response again");
assert.equal(state.seats[0].hasActed, false);
assert.equal(state.currentBet, 10);
assert.equal(Betting.isStreetComplete(state), false);
});
test("the action skips a player who is all-in but still in the hand", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
state.seats[3].stack = 5;
Betting.beginHand(state);
Betting.allIn(state, 3);
assert.equal(state.seats[3].stack, 0);
assert.notEqual(state.actingSeatIndex, 3);
// They are still live and can still win the pot.
assert.ok(Betting.liveSeats(state).some(s => s.index === 3));
});
test("closing a street sweeps chips into hand totals and resets the bet", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.call(state, 3);
Betting.call(state, 0);
Betting.call(state, 1);
Betting.check(state, 2);
const before = chipsOnTable(state);
Betting.closeStreet(state);
assert.equal(state.currentBet, 0);
assert.equal(state.street, "flop");
assert.equal(chipsOnTable(state), before, "sweeping moves chips, it doesn't change the total");
state.seats.forEach(p => assert.equal(p.committed, 0));
assert.equal(Betting.handTotal(state.seats[2]), 2);
});
});
describe("Betting all-in run-outs", () => {
test("with everyone all-in there is nobody left to ask", () => {
const state = makeTable({ seatCount: 3, seated: [0, 1, 2], stack: 20, dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.allIn(state, 2);
Betting.allIn(state, 0);
Betting.allIn(state, 1);
assert.equal(Betting.canStillBet(state), false);
assert.equal(Betting.isRunningOut(state), true, "the board just gets dealt out");
Betting.closeStreet(state);
assert.equal(state.actingSeatIndex, null, "no phantom action on the flop");
});
test("one player with chips against an all-in cannot bet alone", () => {
const state = makeTable({ seatCount: 3, seated: [0, 1, 2], dealerSeatIndex: 0 });
state.seats[1].stack = 5;
Betting.beginHand(state);
Betting.fold(state, 2);
Betting.allIn(state, 0);
Betting.allIn(state, 1);
assert.equal(Betting.canStillBet(state), false);
assert.equal(Betting.isHandOver(state), false, "two players are still live for the showdown");
});
test("everyone folding to one player ends the hand", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.fold(state, 3);
Betting.fold(state, 0);
Betting.fold(state, 1);
assert.equal(Betting.isHandOver(state), true);
});
});
describe("Betting money handling", () => {
test("a player can never put in more than they have", () => {
const state = makeTable({ seatCount: 3, seated: [0, 1, 2], stack: 30, dealerSeatIndex: 0 });
Betting.beginHand(state);
const moved = Betting.putIn(state, 0, 500);
assert.equal(moved, 30);
assert.equal(state.seats[0].stack, 0);
});
test("raise-to is what a dealer says out loud, not raise-by", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.raiseTo(state, 3, 10);
assert.equal(state.seats[3].committed, 10, "their street total is 10, not 10 on top");
assert.equal(state.seats[3].stack, 90);
});
test("the minimum raise has to match the last one", () => {
const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 });
Betting.beginHand(state);
assert.equal(Betting.minRaiseTo(state), 4, "a big blind of 2 makes the first raise 4");
Betting.raiseTo(state, 3, 10);
assert.equal(Betting.minRaiseTo(state), 18, "an 8-chip raise means the next one is to 18");
});
test("winnings land back in front of the player", () => {
const state = makeTable({ seatCount: 3, seated: [0, 1, 2], dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.creditWinnings(state, [{ id: state.seats[0].id, amount: 42.5 }]);
assert.equal(state.seats[0].stack, 142.5, "the button posts nothing three-handed");
});
test("fractional blinds don't drift into floating-point dust", () => {
const state = makeTable({ seatCount: 3, seated: [0, 1, 2], stack: 10, smallBlind: 0.1, bigBlind: 0.2 });
Betting.beginHand(state);
Betting.call(state, 0); // button limps
Betting.call(state, 1); // small blind completes
Betting.check(state, 2); // big blind is happy
Betting.closeStreet(state);
assert.equal(chipsOnTable(state), 30, "0.1 + 0.2 arithmetic didn't lose a chip");
assert.equal(Betting.potSoFar(state), 0.6);
state.seats.forEach(p => assert.equal(p.stack, 9.8));
});
});
+118
View File
@@ -0,0 +1,118 @@
// The community cards, as a thing the table remembers.
//
// The board is the one part of a hand this tool never had a place for: hole cards go on
// a seat, chips go in the middle, and the five cards everybody plays lived only on the
// felt in front of the dealer. It is opt-in, because most nights nobody wants to enter
// cards at all — and everything that reads it has to behave exactly as it always did
// when it is off.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Betting, HoldemState, makeTable } = require("./load");
const dealt = (over = {}) => {
const state = { ...makeTable({ seatCount: 6, stack: 200, smallBlind: 1, bigBlind: 2 }), ...over };
Betting.beginHand(state);
return state;
};
describe("a new table", () => {
test("has five empty slots and is not tracking them", () => {
const state = HoldemState.createDefault();
assert.deepEqual(state.board, ["", "", "", "", ""]);
assert.equal(state.trackBoard, false,
"off by default: a dealer who never asked for this should not be asked for cards");
});
});
describe("how many cards are out", () => {
test("none, three, four, five", () => {
assert.equal(Betting.boardCardsFor("preflop"), 0);
assert.equal(Betting.boardCardsFor("flop"), 3);
assert.equal(Betting.boardCardsFor("turn"), 4);
assert.equal(Betting.boardCardsFor("river"), 5);
});
test("and nothing at all for a street that isn't one", () => {
assert.equal(Betting.boardCardsFor("showdown"), 0);
assert.equal(Betting.boardCardsFor(undefined), 0);
});
test("it matches the streets the betting engine actually deals", () => {
// If a street is ever added, this is the table that has to hear about it —
// a street with no entry silently draws no cards.
Betting.STREETS.forEach(street => {
assert.ok(Object.prototype.hasOwnProperty.call(Betting.BOARD_CARDS, street),
`${street} has no card count`);
});
});
});
describe("clearing it", () => {
test("the next hand takes the board with it", () => {
const state = dealt();
state.board = ["AH", "KD", "9C", "4S", "2H"];
HoldemState.startNextHand(state);
assert.deepEqual(state.board, ["", "", "", "", ""]);
});
test("so does a misdeal", () => {
const state = dealt();
state.board = ["AH", "KD", "9C", "", ""];
Betting.takeHandBack(state);
assert.deepEqual(state.board, ["", "", "", "", ""]);
});
test("and so does dealing a hand, however the table got here", () => {
// Dealing twice off the same button — after a misdeal, or a table set up and
// then dealt again — would otherwise rank the last hand's board against this
// hand's cards, and it would look exactly like a real answer.
const state = dealt();
state.board = ["AH", "KD", "9C", "4S", "2H"];
Betting.beginHand(state);
assert.deepEqual(state.board, ["", "", "", "", ""]);
});
test("but turning the tracking off does not", () => {
// A dealer who taps the toggle mid-hand can tap it back; wiping the flop on
// the way past would cost them cards they had already entered.
const state = dealt({ trackBoard: true });
state.board = ["AH", "KD", "9C", "", ""];
state.trackBoard = false;
assert.deepEqual(state.board, ["AH", "KD", "9C", "", ""]);
});
});
describe("a board off a file", () => {
const migrated = board => HoldemState.migrate({
seats: [HoldemState.makePlayer()], board,
}).board;
test("a table saved before boards existed gets an empty one, not tracked", () => {
const parsed = HoldemState.migrate({ seats: [HoldemState.makePlayer()] });
assert.deepEqual(parsed.board, ["", "", "", "", ""]);
assert.equal(parsed.trackBoard, false);
});
test("it is always five slots, however many arrived", () => {
assert.equal(migrated(["AH"]).length, 5);
assert.equal(migrated(["AH", "KD", "9C", "4S", "2H", "3D", "5S"]).length, 5);
assert.equal(migrated("not an array").length, 5);
assert.equal(migrated(null).length, 5);
});
test("anything that isn't a card becomes an empty slot", () => {
assert.deepEqual(migrated(["AH", "ZZ", 7, null, "10H"]), ["AH", "", "", "", ""]);
});
test("and a real one keeps its place, upper-cased", () => {
assert.deepEqual(migrated(["ah", " kd ", "9c", "", ""]), ["AH", "KD", "9C", "", ""]);
});
test("tracking is only on if the file said so, in so many words", () => {
const on = HoldemState.migrate({ seats: [HoldemState.makePlayer()], trackBoard: true });
const truthy = HoldemState.migrate({ seats: [HoldemState.makePlayer()], trackBoard: "yes" });
assert.equal(on.trackBoard, true);
assert.equal(truthy.trackBoard, false, "a string is a file that has been edited, not a yes");
});
});
+148
View File
@@ -0,0 +1,148 @@
// Dealing around a player who busted and hasn't bought back in.
//
// A home game produces one of these about once an hour. The table used to deal them
// in anyway, promise a blind from a seat with nothing to post it with, take it from
// nobody, and then sit on a hand that could never finish.
const test = require("node:test");
const assert = require("node:assert");
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
const postedBy = state =>
state.seats
.map((player, index) => (player.committed > 0 ? { seat: index + 1, amount: player.committed } : null))
.filter(Boolean);
test("a seat with no chips and nothing in the pot is broke", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.seats[2].stack = 0;
assert.deepEqual(Betting.brokeSeats(state), [2]);
});
test("an all-in player is not broke — they have money riding on the hand", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.seats[2].stack = 12;
Betting.beginHand(state);
Betting.allIn(state, 2);
assert.equal(Betting.stackOf(state, state.seats[2]), 0);
assert.equal(Betting.isAllIn(state, state.seats[2]), true);
assert.deepEqual(Betting.brokeSeats(state), []);
});
test("a seat with no stack recorded falls back to the table's and isn't broke", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.defaultStack = 200;
state.seats[2].stack = null;
assert.deepEqual(Betting.brokeSeats(state), []);
});
test("a chair nobody is sitting in isn't broke, it's empty", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
assert.deepEqual(Betting.brokeSeats(state), []);
});
test("dealing sits the broke seat out and leaves everything else about it alone", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
state.seats[2].stack = 0;
state.seats[2].name = "Cal";
state.seats[2].buyIns = 100;
Betting.beginHand(state);
assert.equal(state.seats[2].inHand, false);
assert.equal(state.seats[2].folded, false, "sat out, not folded — they didn't muck anything");
assert.equal(state.seats[2].name, "Cal");
assert.equal(state.seats[2].buyIns, 100);
});
test("the blinds skip the broke seat and land on players who can post", () => {
// Button on seat 1, so the blinds would be seats 2 and 3. Seat 2 is busted.
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
state.seats[1].stack = 0;
Betting.beginHand(state);
assert.deepEqual(postedBy(state), [
{ seat: 3, amount: state.smallBlind },
{ seat: 4, amount: state.bigBlind },
]);
});
test("the preview promises what the deal produces", () => {
// The fault this whole step exists to prevent: the preview once read a table the
// deal was about to change underneath it.
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100, dealerSeatIndex: 0 });
state.seats[1].stack = 0;
state.bettingOpen = false;
const preview = Betting.blindPreview(state);
assert.deepEqual(preview.satOut, [1]);
Betting.beginHand(state);
const posted = postedBy(state);
assert.equal(posted.find(p => p.amount === state.smallBlind).seat, preview.sbIndex + 1);
assert.equal(posted.find(p => p.amount === state.bigBlind).seat, preview.bbIndex + 1);
});
test("asking for the preview doesn't sit anybody out on its own", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.seats[1].stack = 0;
Betting.blindPreview(state);
assert.equal(state.seats[1].inHand, true, "the preview is a view, not a change");
});
test("with only one player left holding chips there is nothing to preview", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.seats[1].stack = 0;
state.seats[2].stack = 0;
assert.equal(Betting.blindPreview(state), null);
});
test("no chips move when the broke seats are dealt around", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
state.seats[2].stack = 0;
const before = chipsOnTable(state);
Betting.beginHand(state);
assert.equal(chipsOnTable(state), before);
});
test("the board isn't running out before the hand is dealt", () => {
// Two busted players and one with chips looks exactly like a table of all-ins to
// the run-out test, and the bar was announcing one over an empty felt.
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.seats[1].stack = 0;
state.seats[2].stack = 0;
state.bettingOpen = false;
assert.equal(Betting.isRunningOut(state), false);
});
test("buying back in puts them in the next hand", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.seats[2].stack = 0;
Betting.beginHand(state);
assert.equal(state.seats[2].inHand, false);
// What the dealer does when they hand over cash: chips, and back in.
state.seats[2].stack = 100;
state.seats[2].inHand = true;
HoldemState.startNextHand(state);
Betting.beginHand(state);
assert.equal(state.seats[2].inHand, true);
assert.equal(Betting.liveSeats(state).length, 3);
});
test("the table dealt around them counts the right number of players", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
state.seats[3].stack = 0;
assert.equal(Betting.liveSeats(state).length, 4);
assert.equal(Betting.liveSeats(Betting.tableAsDealt(state)).length, 3);
assert.equal(state.seats[3].inHand, true, "the view didn't touch the table");
});
+76
View File
@@ -0,0 +1,76 @@
// Whether handing a seat chips also puts them back in the game.
const test = require("node:test");
const assert = require("node:assert");
const { Betting, makeTable } = require("./load");
test("a busted player who has been sat out comes back in", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.seats[1].stack = 0;
Betting.beginHand(state); // the deal sits them out
assert.equal(state.seats[1].inHand, false);
assert.equal(Betting.buyInDealsThemIn(state.seats[1]), true);
});
test("a chair being bought into for the first time comes in", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
state.seats[4].name = "Eve";
state.seats[4].stack = null;
assert.equal(Betting.buyInDealsThemIn(state.seats[4]), true);
});
test("a seat sat out while holding chips stays out", () => {
// A dealer sat them out on purpose and is topping them up. Dealing them back in
// would be the tool guessing at something it wasn't told.
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.seats[2].inHand = false;
assert.equal(Betting.buyInDealsThemIn(state.seats[2]), false);
});
test("somebody already in the hand is left exactly where they are", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
assert.equal(Betting.buyInDealsThemIn(state.seats[0]), false);
// Including a player who is in but out of chips — they're all-in, not sat out.
state.seats[2].stack = 0;
assert.equal(state.seats[2].inHand, true);
assert.equal(Betting.buyInDealsThemIn(state.seats[2]), false);
});
test("a blank stack field reads as nothing recorded, not as a number", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
state.seats[3].name = "Dee";
state.seats[3].stack = "";
assert.equal(Betting.buyInDealsThemIn(state.seats[3]), true);
});
test("it doesn't ask the table what a missing stack would have been", () => {
// stackOf substitutes the table's starting stack for a seat that has none, which
// would make a seat with nothing recorded look like it was holding chips.
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
state.defaultStack = 500;
state.seats[4].name = "Eve";
state.seats[4].stack = null;
assert.equal(Betting.stackOf(state, state.seats[4]), 500);
assert.equal(Betting.buyInDealsThemIn(state.seats[4]), true);
});
test("asking doesn't change anything", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 });
state.seats[2].stack = 0;
state.seats[2].inHand = false;
const before = JSON.stringify(state);
Betting.buyInDealsThemIn(state.seats[2]);
assert.equal(JSON.stringify(state), before);
});
test("nothing to decide about a seat that isn't there", () => {
assert.equal(Betting.buyInDealsThemIn(null), false);
assert.equal(Betting.buyInDealsThemIn(undefined), false);
});
+129
View File
@@ -0,0 +1,129 @@
// Calling the clock on a player.
//
// The bar already had a stopwatch saying how long somebody had been thinking. This is
// what a dealer does about it: a procedure with a shape — a set time, a countdown said
// out loud, and a ruling at the end that depends on what the player was facing.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { ActionBar, Betting, makeTable } = require("./load");
// The bar rebuilds the DOM when a clock starts; none of the rules below need it.
const onTable = (state) => {
ActionBar.state = state;
ActionBar.render = () => {};
ActionBar.stopClock();
return ActionBar;
};
const dealt = () => {
const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 1, bigBlind: 2, dealerSeatIndex: 0 });
Betting.beginHand(state);
return state;
};
describe("starting one", () => {
test("it runs on the player who is actually on the clock", () => {
const state = dealt();
const bar = onTable(state);
assert.equal(bar.clockRunning(), false, "nothing runs until it is called");
bar.callClock();
assert.equal(bar.clockRunning(), true);
assert.equal(bar.clockSeatIndex, state.actingSeatIndex);
});
test("with nobody to act there is nothing to call", () => {
const state = dealt();
state.actingSeatIndex = null;
const bar = onTable(state);
bar.callClock();
assert.equal(bar.clockRunning(), false);
assert.equal(bar.clockLeft(), null);
});
test("the dealer can put it away again", () => {
const bar = onTable(dealt());
bar.callClock();
bar.stopClock();
assert.equal(bar.clockRunning(), false);
assert.equal(bar.clockLeft(), null);
});
});
describe("the count", () => {
const at = (bar, seconds) => bar.clockLeft(bar.clockStartedAt + seconds * 1000);
test("is the player's own time and then a countdown", () => {
const bar = onTable(dealt());
bar.callClock();
assert.equal(at(bar, 0), bar.CLOCK_THINK_S + bar.CLOCK_COUNTDOWN_S);
assert.equal(at(bar, 30), bar.CLOCK_COUNTDOWN_S, "thirty gone, the countdown starts");
assert.equal(at(bar, 35), 5);
});
test("stops at zero rather than going negative", () => {
const bar = onTable(dealt());
bar.callClock();
assert.equal(at(bar, 40), 0);
assert.equal(at(bar, 600), 0, "ten minutes later it is still just up");
});
});
describe("what happens at zero", () => {
test("facing a bet, the hand is dead", () => {
const state = dealt();
const bar = onTable(state);
// Whoever is up pre-flop is facing the big blind.
const ruling = bar.clockRuling();
assert.equal(ruling.verb, "fold");
assert.match(ruling.label, /hand is dead/);
assert.match(ruling.say, /facing \$2/);
});
test("facing nothing, it is checked — slowness doesn't fold a free hand", () => {
const state = dealt();
// Everyone calls round to the big blind, who has nothing to call.
[3, 4, 5, 0, 1].forEach(seat => Betting.call(state, seat));
const bar = onTable(state);
assert.equal(state.actingSeatIndex, 2, "the big blind is up");
const ruling = bar.clockRuling();
assert.equal(ruling.verb, "check");
assert.match(ruling.label, /check for/i);
});
test("the ruling names the player, because the dealer has to say it out loud", () => {
const state = dealt();
state.seats[state.actingSeatIndex].name = "Priya";
const ruling = onTable(state).clockRuling();
assert.match(ruling.label, /Priya/);
assert.match(ruling.say, /Priya/);
});
test("with nobody to act there is no ruling to make", () => {
const state = dealt();
state.actingSeatIndex = null;
assert.equal(onTable(state).clockRuling(), null);
});
});
describe("what ends it", () => {
test("the player acting", () => {
const state = dealt();
const bar = onTable(state);
bar.callClock();
const wasOn = state.actingSeatIndex;
Betting.call(state, wasOn);
assert.notEqual(state.actingSeatIndex, wasOn, "the action has moved");
assert.equal(bar.clockRunning(), false, "so the clock is no longer this player's");
});
test("and it doesn't follow the action to the next player", () => {
// A clock is called on one decision. Left running it would put a countdown on
// somebody nobody asked about.
const state = dealt();
const bar = onTable(state);
bar.callClock();
Betting.call(state, state.actingSeatIndex);
assert.equal(bar.clockLeft(), null);
});
});

Some files were not shown because too many files have changed in this diff Show More