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
103 changed files with 5233 additions and 9669 deletions
+3
View File
@@ -369,3 +369,6 @@ FodyWeavers.xsd
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);
+215 -373
View File
@@ -26,10 +26,7 @@
</div>
<div id="holdemHeaderActions">
<button id="misdealBtn" class="holdemBtn" title="Take this hand back — every chip returns to the player who put it in, and the same button deals again" hidden>Misdeal</button>
@* Both the label and the title are rewritten every render from whether a hand
is out — see Render.renderHeaderActions. What is here is what is true before
the first render: no hand, so pressing it only moves the button. *@
<button id="nextHandBtn" class="holdemBtn" title="Pass the button on again without dealing — for a hand nobody played">Move Button ⟳</button>
<button id="nextHandBtn" class="holdemBtn" title="Move the button, small blind and big blind to the next occupied seat">Next Hand ⟳</button>
@* Not the accent any more. It is a shortcut to a sheet, available all night,
and it is never the next step — the bar always has that, and at showdown
the bar says so in as many words. See .actionAdvance in holdem.css. *@
@@ -47,7 +44,7 @@
Not an `*Overlay` id, deliberately — Modal watches those and would take the
sidebar panel this becomes on a desktop for a dialog. It is driven entirely by
body.seatSheetOpen, and it is `display: none` above 900px where the panel is
body.seatSheetOpen, and it is `display: none` above 700px where the panel is
just a panel. *@
<div id="seatSheetScrim"></div>
@@ -92,59 +89,11 @@
half-way through a night. *@
<details class="sidebarPanel" id="tableSetupPanel">
<summary><h2>Table Setup</h2> <span class="panelGlance" id="glanceTableSetup" hidden></span></summary>
@* The numbers first, and only the numbers.
This panel is the one thing that is open when the tool is first
opened, and it was 886px of a 667px phone — the two figures its own
summary glances at, the blinds, sat below 218px of prose explaining
three switches a dealer may never touch. The three have gone into
"While you deal" below, with the paragraphs that explain them, which
is where House take and Without live betting already were: the panel
had the vocabulary for this and was only using it for money. *@
@* Every field below takes digits and none of them is type="number".
That is deliberate, and it is the second half of a fix — the first
half is Render.setField, which stopped a re-render writing over the
text somebody was still typing.
type="number" was costing more than it paid here. It silently drops
characters it dislikes with no feedback to the person or to assistive
tech; it reports a half-typed "0." as "0", which is what let the
decimal point be written away; it does not expose selectionStart, so
nothing can put a caret back where it was; it draws a spin button
into a 54px-wide blind field, where five digits already need 45 of
the 52 available; and it is the control GOV.UK dropped from their
number patterns after user testing, for the same reasons plus Dragon
and NVDA.
inputmode is what actually summons the keypad on a phone, and it is
the better keypad: "decimal" carries the separator for the reader's
locale, which is the key a dealer needs to type 0.25 and the one
type="number" leaves them hunting for on iOS.
min/max/step go with it. Nothing relied on them: the seat count is
clamped to 2..24 in main.js before it is used, and the money fields
are read through a finite-number check that a spinner never provided
anyway. *@
<div class="panelBody">
<label class="fieldRow">
<span>Seats at table</span>
<input type="text" id="seatCountInput" inputmode="numeric" autocomplete="off" value="10" />
<input type="number" id="seatCountInput" min="2" max="24" value="10" />
</label>
<div class="blindsRow">
<label class="fieldRow">
<span>Small blind</span>
<input type="text" id="smallBlindInput" inputmode="decimal" autocomplete="off" />
</label>
<label class="fieldRow">
<span>Big blind</span>
<input type="text" id="bigBlindInput" inputmode="decimal" autocomplete="off" />
</label>
</div>
<label class="fieldRow">
<span>Starting stack ($)</span>
<input type="text" id="defaultStackInput" inputmode="decimal" autocomplete="off" placeholder="e.g. 200" />
</label>
<p class="fieldHint">Blinds post themselves each hand. Seats without their own stack start here.</p>
<label class="fieldRow">
<span>Blinds move</span>
<select id="directionSelect">
@@ -152,37 +101,42 @@
<option value="counterclockwise">Counterclockwise</option>
</select>
</label>
@* All three change what the tool does rather than what the table is,
and all three are set once and then left alone. The glance says so
when one of them is not on its default, because a switch you cannot
see is a switch you cannot remember turning on. *@
<details class="subPanel">
<summary>While you deal <span class="panelGlance subPanelGlance" id="glanceWhileYouDeal" hidden></span></summary>
<div class="subPanelBody">
<label class="fieldRow">
<span>Dedicated dealer (not a seat)</span>
<input type="checkbox" id="dedicatedDealerToggle" />
</label>
<p class="fieldHint">Turn this on if a person outside the game deals — no seat shows the dealer button, but blinds still rotate automatically each hand.</p>
<label class="fieldRow">
<span>Ask me for the cards as they come out</span>
<input type="checkbox" id="trackBoardToggle" />
</label>
@* The label used to lead with "Track the board", which is the feature's name
rather than what pressing it does to your evening. What it does is put a
card sheet in front of you at four moments: each street, and the showdown. *@
<p class="fieldHint">Every time you deal a street, a sheet asks which cards came off the deck, and puts them on the felt. At the showdown it asks for each hand that turns over &mdash; then Resolve Pot reads the winner and the amount instead of asking you. Close the sheet at any point to go back to picking the winner yourself.</p>
<label class="fieldRow">
<span>Keep the screen on during a hand</span>
<input type="checkbox" id="keepScreenAwakeToggle" />
</label>
@* On by default, and the only setting here that is. A dealer has to
opt in to the tool doing more than they asked; they should not have
to opt in to it staying readable. *@
<p class="fieldHint">Stops the phone dimming and locking between the flop and the turn. It only holds while betting is open, so the screen sleeps as usual during breaks &mdash; and the tool says so the first time it takes hold.</p>
</div>
</details>
<label class="fieldRow">
<span>Dedicated dealer (not a seat)</span>
<input type="checkbox" id="dedicatedDealerToggle" />
</label>
<p class="fieldHint">Turn this on if a person outside the game deals — no seat shows the dealer button, but blinds still rotate automatically each hand.</p>
<label class="fieldRow">
<span>Ask me for the cards as they come out</span>
<input type="checkbox" id="trackBoardToggle" />
</label>
@* The label used to lead with "Track the board", which is the feature's name
rather than what pressing it does to your evening. What it does is put a
card sheet in front of you at four moments: each street, and the showdown. *@
<p class="fieldHint">Every time you deal a street, a sheet asks which cards came off the deck, and puts them on the felt. At the showdown it asks for each hand that turns over &mdash; then Resolve Pot reads the winner and the amount instead of asking you. Close the sheet at any point to go back to picking the winner yourself.</p>
<label class="fieldRow">
<span>Keep the screen on during a hand</span>
<input type="checkbox" id="keepScreenAwakeToggle" />
</label>
@* On by default, and the only setting here that is. A dealer has to
opt in to the tool doing more than they asked; they should not have
to opt in to it staying readable. *@
<p class="fieldHint">Stops the phone dimming and locking between the flop and the turn. It only holds while betting is open, so the screen sleeps as usual during breaks &mdash; and the tool says so the first time it takes hold.</p>
<div class="blindsRow">
<label class="fieldRow">
<span>Small blind</span>
<input type="number" id="smallBlindInput" min="0" step="0.5" />
</label>
<label class="fieldRow">
<span>Big blind</span>
<input type="number" id="bigBlindInput" min="0" step="0.5" />
</label>
</div>
<label class="fieldRow">
<span>Starting stack ($)</span>
<input type="number" id="defaultStackInput" min="0" step="1" placeholder="e.g. 200" />
</label>
<p class="fieldHint">Blinds post themselves each hand. Seats without their own stack start here.</p>
<details class="subPanel">
<summary>House take</summary>
@@ -190,11 +144,11 @@
<div class="blindsRow">
<label class="fieldRow">
<span>Rake (%)</span>
<input type="text" id="rakePercentInput" inputmode="decimal" autocomplete="off" placeholder="0" />
<input type="number" id="rakePercentInput" min="0" max="100" step="0.5" placeholder="0" />
</label>
<label class="fieldRow">
<span>Cap ($)</span>
<input type="text" id="rakeCapInput" inputmode="decimal" autocomplete="off" placeholder="none" />
<input type="number" id="rakeCapInput" min="0" step="1" placeholder="none" />
</label>
</div>
<label class="fieldRow">
@@ -215,7 +169,7 @@
<div class="subPanelBody">
<label class="fieldRow">
<span>Base pot amount ($)</span>
<input type="text" id="basePotInput" inputmode="decimal" autocomplete="off" placeholder="e.g. 500" />
<input type="number" id="basePotInput" min="0" step="1" placeholder="e.g. 500" />
</label>
<p class="fieldHint">For tracking a hand by hand instead of tapping the action: everyone is assumed to have put in this much, unless their seat says otherwise.</p>
</div>
@@ -321,36 +275,16 @@
<details class="sidebarPanel">
<summary><h2>Chip Legend</h2> <span class="panelGlance" id="glanceChips" hidden></span></summary>
@* The panel was 793px on an iPhone and 420 of them were the colour form:
five rows of swatch, value box and a red delete block, then an add
button and a paragraph of instructions — all of it above "Stacks in
chips", which is the part a dealer opens this panel *during* a game to
read. Setting the colours is a thing you do once, on the first night,
and it was sitting on top of the thing you do at every colour-up.
So the order is the other way round now, and the form is behind a
disclosure, which is the shape Table Setup already uses for House take
and Without live betting. The legend line stays out in the open: it is
what the panel is named for, it is two lines rather than four hundred
pixels, and it answers the other question a dealer opens this for —
what is the green one worth. *@
<div class="panelBody">
<ul id="chipLegendStrip" class="chipLegendStrip"></ul>
<div id="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>
<details class="subPanel">
<summary>Colours and values</summary>
<div class="subPanelBody">
<div id="chipLegendList"></div>
<button id="addChipColorBtn" class="holdemBtn holdemBtnSmall">+ Add chip color</button>
<p class="fieldHint">Give each chip color a value, then count chips on a seat instead of typing a total.</p>
</div>
</details>
</div>
</details>
@@ -440,29 +374,19 @@
<span>Name</span>
<input type="text" class="seatNameInput" placeholder="Seat" maxlength="24" />
</label>
@* One question with three answers, not two boxes with four corners.
A seat is sitting out, in the hand, or folded — and "folded" only means
anything while the seat is in. So the two checkboxes here could be ticked
into a state that means nothing, and the code that stopped them doing it
`disabled` the Fold box whenever In hand was clear. Measured: that left the
label at the same opacity and the same colour with a `title` on it, so a
dealer pressing Fold on a seat that was sitting out got no movement and no
reason — the exact thing round 16 went through the tool to remove, hiding
from the rule that finds it because the title sat on the label rather than
on the control.
Three named states cannot contradict each other and none of them is ever
unavailable, so there is nothing left to dim. The radiogroup is the same
shape the card picker's suits use. *@
<div class="seatState" role="radiogroup" aria-label="This seat">
<button type="button" class="seatStateOption" data-seat-state="out" role="radio" aria-checked="false">Sitting out</button>
<button type="button" class="seatStateOption" data-seat-state="in" role="radio" aria-checked="false">In the hand</button>
<button type="button" class="seatStateOption" data-seat-state="folded" role="radio" aria-checked="false">Folded</button>
<div class="seatCardToggles">
<label class="inHandToggle" title="This seat is playing the current hand">
<input type="checkbox" class="inHandCheckbox" />
<span>In hand</span>
</label>
<label class="foldToggle" title="Folded">
<input type="checkbox" class="foldCheckbox" />
<span>Fold</span>
</label>
</div>
<label class="fieldRow stackRow">
<span>Stack</span>
<input type="text" class="stackInput" inputmode="decimal" autocomplete="off" placeholder="table default" />
<input type="number" class="stackInput" min="0" step="1" placeholder="table default" />
</label>
<div class="seatBuyIns" hidden></div>
@@ -486,7 +410,7 @@
<div class="chipCounts"></div>
<label class="fieldRow amountOverrideRow">
<span>$ amount</span>
<input type="text" class="amountOverrideInput" inputmode="decimal" autocomplete="off" placeholder="auto" />
<input type="number" class="amountOverrideInput" min="0" step="1" placeholder="auto" />
</label>
<div class="seatTotal">Total: <span class="seatTotalAmount">$0</span></div>
</div>
@@ -505,7 +429,7 @@
<template id="chipLegendRowTemplate">
<div class="chipLegendRow">
<input type="color" class="chipColorInput" aria-label="Chip colour" />
<input type="text" class="chipValueInput" inputmode="decimal" autocomplete="off" placeholder="value" aria-label="Chip value" />
<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>
@@ -518,7 +442,7 @@
<label class="chipCountRow">
<span class="chipSwatch"></span>
<span class="chipCountValue"></span>
<input type="text" class="chipCountInput" inputmode="numeric" autocomplete="off" value="0" />
<input type="number" class="chipCountInput" min="0" step="1" value="0" />
</label>
</template>
@@ -556,7 +480,7 @@
<div id="addChipsSheet" role="dialog" aria-labelledby="addChipsTitle">
<div class="seatSheetGrip" aria-hidden="true"></div>
<div class="addChipsHeader">
<h2 id="addChipsTitle" tabindex="-1" data-modal-focus></h2>
<h2 id="addChipsTitle"></h2>
<span id="addChipsSubtitle"></span>
</div>
<div id="addChipsQuick"></div>
@@ -573,13 +497,9 @@
<div id="rulingsOverlay" hidden>
<div id="rulingsSheet" role="dialog" aria-labelledby="rulingsTitle">
@* The grip belongs inside the header, not above it, because the header is stuck
to the top of the scrollport — anything above a sticky thing is something the
list scrolls out through. The seat menu's header is built the same way, and
for the same reason. *@
<div class="seatSheetGrip" aria-hidden="true"></div>
<div class="rulingsHeader">
<div class="seatSheetGrip" aria-hidden="true"></div>
<h2 id="rulingsTitle" tabindex="-1" data-modal-focus>Common rulings</h2>
<h2 id="rulingsTitle">Common rulings</h2>
<button id="closeRulingsBtn" data-modal-close class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
</div>
@@ -589,190 +509,162 @@
not during it.
</p>
@* Twelve rulings, shut.
<nav id="rulingsIndex" class="rulingsIndex" aria-label="Jump to a ruling"></nav>
They used to be twelve headings with all their prose under them — 2,480px of
it on a phone, of which 1,754 was below the fold — and a row of twelve chips
across the top to jump between them. The chips were an index of the twelve
headings printed directly above the same twelve headings, which is the same
list twice: 172px of navigation before the first word, and the dealer who
used it landed in the middle of a scroll with eleven rulings they didn't ask
for still wrapped around them.
<section class="ruling" data-short="Misdeal">
<h3>Redeal it — misdeal</h3>
<ul>
<li>The first card goes to the wrong seat.</li>
<li>A seat entitled to a hand gets dealt out, or a seat that isn't entitled gets dealt in.</li>
<li>Two or more boxed cards turn up on the initial deal.</li>
<li>Either of the first two cards is exposed by dealer error.</li>
</ul>
</section>
The headings are the index now. NN/g's rule for this shape — a list of
independent topics somebody opens one of — is an accordion, shut by default,
and half of these headings answer the question without being opened at all:
"A short all-in doesn't reopen the betting" is the ruling, not the topic.
<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,
that card is out of play for the round. Finish the betting, then burn and
turn what would have been the fifth card in the fourth card's place.
</p>
</section>
Not exclusive (no `name` attribute): closing one because another opened moves
everything under the dealer's finger, which is the disorientation the
accordion is here to avoid. *@
<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.
Doing it repeatedly is what earns a penalty.
</p>
</section>
@* Six and six, down one column and then the next. Two boxes rather than one
list so that landscape can lay them side by side without the columns
sharing rows: opening a ruling in one of them moves what is under it in
that column and nothing else. Everywhere but landscape the wrappers are
`display: contents` and the twelve are one column, exactly as before. *@
<div class="rulingsList">
<div class="rulingsColumn">
<details class="ruling">
<summary><h3>Redeal it — misdeal</h3></summary>
<ul>
<li>The first card goes to the wrong seat.</li>
<li>A seat entitled to a hand gets dealt out, or a seat that isn't entitled gets dealt in.</li>
<li>Two or more boxed cards turn up on the initial deal.</li>
<li>Either of the first two cards is exposed by dealer error.</li>
</ul>
</details>
<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>
<details class="ruling">
<summary><h3>A board card turned too early</h3></summary>
<p>
If the fourth board card gets turned before the betting round is finished,
that card is out of play for the round. Finish the betting, then burn and
turn what would have been the fifth card in the fourth card's place.
</p>
</details>
<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>
<details class="ruling">
<summary><h3>Acting out of turn</h3></summary>
<p>
A one-off is usually just backed up if no one has acted behind them.
Doing it repeatedly is what earns a penalty.
</p>
</details>
<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>
<details class="ruling">
<summary><h3>Calling the clock on a player</h3></summary>
<p>
Any player may ask for a clock once a decision is taking unreasonably
long; it is the dealer, not the table, who grants it. The player then
gets a set time and a countdown out loud — thirty seconds and ten is the
usual form.
</p>
<p>
If the count reaches zero with no action, the hand is <em>dead</em> if
they were facing a bet, and <em>checked</em> if they were not: nobody is
folded for slowness when checking was free. The action bar runs the
count once you tap <em>Call the clock</em>, and offers the ruling as one
button when it ends.
</p>
</details>
<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>
<details class="ruling">
<summary><h3>A straddle is a blind raise</h3></summary>
<p>
The player left of the big blind may put out twice it before the cards,
without looking. It is a blind bet, not a raise made in turn, so it does
three things at once: everyone else acts first, the minimum raise becomes
twice the straddle, and the straddler still has the option when the
action comes back — they may raise even if nobody raised into them.
</p>
<p>
Re-straddles carry on to the left, each doubling again, and stop before
the small blind. Tap <em>Straddle</em> on the deal prompt and the tool
posts it, puts the right player on the clock and prices the raise;
the seat wears an <em>STR</em> badge for the hand.
</p>
</details>
<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>
<details class="ruling">
<summary><h3>A short all-in doesn't reopen the betting</h3></summary>
<p>
Somebody shoving in less than a full raise — say $3 more when the last
raise was $8 — does not give the players behind them another go. Anyone
who has already acted owes the difference or folds; they don't get to
raise again because a short stack happened to land between them.
</p>
<p>
A player who hadn't acted yet keeps full rights and may still raise, and
the minimum raise stays measured off the last <em>full</em> raise, not
off the short one. The action bar follows this for you.
</p>
</details>
</div>
<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>
<div class="rulingsColumn">
<details class="ruling">
<summary><h3>Chips nobody called come back</h3></summary>
<p>
Bet more than anyone was able to call and the excess is returned to you
before the showdown — it was never contested, so it isn't part of the
pot. The pot breakdown shows it as "uncalled" rather than pretending it's
a side pot you could win.
</p>
</details>
<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>
<details class="ruling">
<summary><h3>One big chip, nothing said</h3></summary>
<p>
The most common argument at a home table. A player facing a bet who
pushes out a single oversized chip without saying anything has
<em>called</em> — not raised, however big the chip is. To raise with one
chip you have to say "raise" before it lands; say it without an amount
and the raise is the most that chip allows.
</p>
<p>
Facing no bet, the same silent chip is a bet of the whole chip. And a
clear verbal declaration in turn is binding either way — if words and
chips disagree, whichever came first is the action.
</p>
</details>
<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>
<details class="ruling">
<summary><h3>A raise has to be a full raise</h3></summary>
<p>
At least as much again as the last raise, or the big blind if there
hasn't been one. Anything between a call and that minimum isn't a raise
a player can make — it's a call, unless they're putting their last chips
in. The raise pad says so if you type one, and won't stop you: your
ruling beats the arithmetic.
</p>
</details>
<details class="ruling">
<summary><h3>Who turns their hand over first</h3></summary>
<p>
If there was a bet on the river, the last player to bet or raise shows
first. If the river was checked all the way round, it's the first live
player left of the button. Nobody has to be asked twice — a player may
always turn their hand over early if they want to.
</p>
<p>
When everyone is all-in before the river there was no final round to
lead, so it falls to whoever last put money in, on whichever street that
was. The action bar names the seat and the reason at every showdown, so
the call is already made before anyone asks.
</p>
</details>
<details class="ruling">
<summary><h3>The odd chip in a split pot</h3></summary>
<p>
When a chopped pot won't divide evenly, the odd chip goes to the first
player left of the button, not to whoever the dealer happened to tick
first. Pot payouts here order the winners that way, so the leftover lands
on the right seat every time.
</p>
</details>
<details class="ruling">
<summary><h3>Betting in one motion — the string bet</h3></summary>
<p>
A bet is whatever goes in on the first trip to the stack, unless the
amount was said out loud first. Reaching back for more chips without
having said "raise" is a string bet, and only the first motion stands.
Saying the number is always enough on its own: "raise to sixty" binds
whatever order the chips arrive in.
</p>
<p>
It's the rule home games invoke most and enforce least, and both are
fine — the point is that a player can't watch the table react and then
decide how much to bet. Say it, or push it all at once.
</p>
</details>
</div>
</div>
<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
@@ -783,65 +675,26 @@
<div id="seatMenuOverlay" hidden>
<div id="seatMenuSheet" role="dialog" aria-labelledby="seatMenuTitle">
@* The grip was the only thing at the top of this sheet, and NN/g is explicit
that a grab handle is not a close affordance — a sheet wants a real one. It
earns its place twice over here: this sheet is the one that can outgrow the
screen, and with the header stuck to the top the way out never scrolls away.
Third job, the quiet one: Escape finds this close button rather than the one
at the foot, so Escape closes the sheet while Back steps back inside it.
It used to carry a fourth. Modal focused a dialog's first control and the
first control here was Fold, so putting a close button above the list was
also what moved the landing spot off the destructive choice. That was the
right half of the answer — Roselli is clear that a close button is a landing
spot for a sentence, not for a sheet long enough to scroll, where a stray
Space dismisses the thing the dealer just opened. The heading below names
the spot outright now, and it says which seat. *@
<div class="seatSheetGrip" aria-hidden="true"></div>
<div class="seatMenuHeader">
<div class="seatSheetGrip" aria-hidden="true"></div>
<div class="seatMenuHeading">
<h2 id="seatMenuTitle" tabindex="-1" data-modal-focus></h2>
<span id="seatMenuSubtitle"></span>
</div>
<button id="seatMenuCloseBtn" data-modal-close class="seatMenuClose" type="button" aria-label="Close this menu">×</button>
<h2 id="seatMenuTitle"></h2>
<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>
<div id="cardPickerOverlay" hidden>
@* `data-why-note` is this sheet claiming the job of saying why a card can't be
picked. The toast that answers everywhere else is fixed to the bottom of the
window, and this sheet stands on top of it. *@
<div id="cardPickerSheet" role="dialog" aria-labelledby="cardPickerTitle" data-why-note="cardPickerNote">
<div id="cardPickerSheet" role="dialog" aria-labelledby="cardPickerTitle">
<div class="seatSheetGrip" aria-hidden="true"></div>
<div class="cardPickerHeader">
@* Not the first control any more. Modal used to land on whatever came
first in here, and what came first in here was Clear: the sheet a
dealer opens to write a card down opened on the button that rubs one
out. The heading says which seat and which card, which is the thing
worth hearing before any of the fifty-two. *@
<h2 id="cardPickerTitle" tabindex="-1" data-modal-focus></h2>
<h2 id="cardPickerTitle"></h2>
<button id="cardPickerClearBtn" class="holdemBtn holdemBtnSmall" type="button">Clear</button>
<button id="cardPickerCloseBtn" data-modal-close class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
</div>
<div id="cardPickerSlots"></div>
@* Empty until a dealt card is pressed, and holding its line the whole time so
that filling it doesn't shove fifty-two cards down the screen under a thumb
that is already reaching for one. *@
<p id="cardPickerNote" class="pickerNote" role="status"></p>
@* The first of the two questions a card is. A radiogroup rather than a tablist:
the grid below is not a different panel of content, it is the same thirteen
controls filtered, and single-select filtering is what a radio group is for.
The suits are a fixed set written by paint(), not here, so that the one place
that knows what a suit looks like stays CardPicker.SUITS. *@
<div id="cardPickerSuits" role="radiogroup" aria-label="Suit"></div>
@* Which suit's thirteen these are, said once for a screen reader rather than
left to be inferred from the chosen radio two elements up. *@
<span id="cardPickerGridLabel" class="visuallyHidden"></span>
<div id="cardPickerGrid" role="group" aria-labelledby="cardPickerGridLabel"></div>
<div id="cardPickerGrid"></div>
</div>
</div>
@@ -863,14 +716,8 @@
<div id="confirmModal" role="alertdialog" aria-labelledby="confirmTitle" aria-describedby="confirmMessage" tabindex="-1">
<h2 id="confirmTitle"></h2>
<p id="confirmMessage"></p>
@* The one sheet that lands on a control rather than its heading, and the one
where the APG asks for it by name: this is a question with two answers and
one of them cannot be taken back, so the dealer starts on the answer that
leaves them no worse off. It is short enough to be read at a glance, which is
the case where landing on a control is the right thing. Said out loud here
rather than left to depend on which button happens to be written first. *@
<div class="confirmActions">
<button id="confirmCancelBtn" data-modal-close data-modal-focus class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
<button id="confirmCancelBtn" data-modal-close class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
<button id="confirmAcceptBtn" class="holdemBtn holdemBtnDanger" type="button">Confirm</button>
</div>
</div>
@@ -879,13 +726,8 @@
<div id="potWizardOverlay" hidden>
<div id="potWizardModal" role="dialog" aria-labelledby="potWizardTitle">
<div class="potWizardHeader">
<h2 id="potWizardTitle" tabindex="-1" data-modal-focus>Resolve the Pot</h2>
@* Named, and named for what leaving does rather than "Close". The glyph is
the whole of this control, so unnamed it announced as "button" — on the
one dialog in the tool that divides money, where the difference between
backing out and confirming is somebody's stack. *@
<button id="closePotWizardBtn" type="button" data-modal-close class="holdemBtn holdemBtnGhost"
aria-label="Close without paying the pot">✕</button>
<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
+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
+1
View File
@@ -20,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>();
@@ -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();
}
File diff suppressed because it is too large Load Diff
@@ -1,15 +1,6 @@
/* Quick Split is phone-first: sized for one thumb at the table, then allowed to
breathe on a laptop. Palette matches the dealer table so the two read as one tool. */
/* And matches it in the half a stylesheet does not paint, for the same reason: this
page is #0a0a0a and had never told the browser so, which leaves the scrollbars, the
caret, the selection and the autofill wash drawn for a light page. It has no
checkboxes to go white, but a dealer who opens it from the felt should not find a
light scrollbar down the side of the darker of the two pages. */
:root {
color-scheme: dark;
}
body {
background: radial-gradient(ellipse at 50% -10%, #1c1c1c 0%, #101010 55%, #0a0a0a 100%);
min-height: 100vh;
@@ -526,30 +517,20 @@ button,
font-weight: 600;
}
/* As wide as the three things in it, and no wider.
This sits in the same wrapping flex row as the chips, where it has never once fitted
on the first line of a phone. `flex: 1 1 auto` then handed it the whole of the second
line: measured on an iPhone, a 358px bar with 252px of nothing in the middle and the
− and + pushed 304px apart — the two ends of a control a thumb is meant to work with
one hand. 136px now, its two ends 121px apart. */
.waysStepper {
display: flex;
align-items: center;
gap: 0.35rem;
flex: 0 0 auto;
flex: 1 1 auto;
min-width: 150px;
background: #191919;
border: 1px solid #333;
border-radius: 11px;
padding: 0.2rem 0.4rem;
}
/* 44 both ways, not 40x44. It had been 40px wide since the steppers were written, and
the sweep never said so because the only two steppers on the site are on this screen
and this screen was only ever audited in its all-in mode, where neither is on display
until a side pot has more than two contenders. The first sweep that opened the even
mode reported `hittable 39x43` on both ends, on every touch viewport. */
.stepBtn {
width: 44px;
width: 40px;
height: 44px;
border: none;
border-radius: 8px;
@@ -561,26 +542,17 @@ button,
cursor: pointer;
}
/* The floor and the ceiling look like the floor and the ceiling. */
.stepBtn:disabled {
opacity: 0.32;
cursor: default;
}
@media (hover: hover) {
.stepBtn:not(:disabled):hover {
.stepBtn:hover {
background: #333;
}
}
/* The count itself, now that no chip beside it is also holding one — so it is painted
the way a selected chip is painted, because that is what it is standing in for. */
.waysReadout {
min-width: 2.5ch;
flex: 1;
text-align: center;
color: #f0d9a8;
font-size: 1rem;
font-weight: 600;
color: #999;
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
}
@@ -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;
}
}
@@ -16,14 +16,8 @@ const ActionBar = {
state.actingSeatIndex !== null && state.actingSeatIndex !== undefined
? state.actingSeatIndex
: null;
// watchHeight first, because render() may reach for publishHeight; then publish
// once there is a bar to measure. The ResizeObserver does come round on its own
// when the markup's `hidden` comes off — but it arrives a frame later at best,
// and measured on a cold load with nine seats to lay out it was 875ms behind.
// The felt is drawn inside that gap.
this.watchHeight();
this.render();
this.publishHeight();
},
// The bar is fixed to the bottom of the screen, so everything else has to know how
@@ -37,40 +31,18 @@ const ActionBar = {
if (!host || this.heightWatcher) return;
const publish = () => {
const visible = !host.hidden;
const rail = this.isRail(host);
// A bar nobody has drawn yet is not a bar of zero height. It is a bar whose
// height nobody knows, and saying zero is answering the question wrongly
// rather than declining it: the felt reads this to decide how tall to be,
// and zero tells it the whole window is free.
//
// It is the state this bar starts in — `<div id="actionBar" hidden>` in the
// markup, taken out by the first render() — and watchHeight() used to
// publish from inside that window, because mount() called it before render.
// Measured at 360x640 nine-handed: the table came up 336x504 against a
// published 525px of room, then collapsed to 336x374 sixty milliseconds
// later when the real 151px bar was finally measured. 1280x900 lost 13px
// the same way. A table that is drawn, read, and then redrawn at a
// different size is the layout shift this measurement exists to prevent.
//
// A rail is different, and still publishes: it is a real bar that genuinely
// takes none of the bottom, because it is off to the side.
if (host.hidden && !rail) return;
// On a phone held sideways the bar is a rail down the right-hand side, so it
// takes no height at all — and `--actionBarHeight` is what four other rules
// and the felt measurement all mean by "how much of the bottom is spoken
// for". Publishing the rail's height there would reserve the whole window.
const height = rail ? 0 : host.getBoundingClientRect().height;
const height = visible && !rail ? host.getBoundingClientRect().height : 0;
document.documentElement.style.setProperty("--actionBarHeight", `${Math.round(height)}px`);
// What is under the felt is the bar in one layout and the two links in the
// other. Either way it is the same question — how much of the bottom is
// already spoken for — so it is the same argument.
//
// Except that the felt is asked a slightly different question from everything
// else that reads --actionBarHeight. The toast and the scroll padding want to
// know what is covering the bottom of the window *right now*; the table wants
// to know how big to be, and being a different size for the few seconds a pad
// is open over it is not an answer, it is a jolt. See restingBarHeight.
this.publishFeltRoom(rail ? this.roomBelowFelt() : this.restingBarHeight(height));
this.publishFeltRoom(rail ? this.roomBelowFelt() : height);
};
// Kept so anything that moves the table without touching the bar can ask for
@@ -86,42 +58,6 @@ const ActionBar = {
publish();
},
// How tall the bar is when it is only a bar.
//
// The raise pad is 57px taller than the row it replaces, and the felt was sized
// against it. Measured with five players nine-handed, pressing Raise:
//
// 1280x900 felt 738x532 -> 669x481, pucks 120 -> 109, worst seat moved 73px
// 360x640 felt 283x329 -> 283x279, pucks 44 -> 48, worst seat moved 50px
// 390x844 felt 309x463 -> 309x451, worst seat moved 11px
//
// The whole table jumped, re-ran the ten-candidate width search, redrew at a
// different size — and then undid all of it when the dealer pressed Raise to. Every
// raise, all night, at the one moment they are reading the felt to decide what to
// type. A phone held sideways already did the right thing here and scored 0px: the
// rail is a column down the side, so its height never was the table's business.
//
// A pad over the bar is a transient surface, and the thing a transient surface must
// not do is take the context away from underneath itself. So the felt is sized
// against the bar the thumb is coming back to, and the pad grows up over the bottom
// of the felt for as long as it is open.
//
// Only the felt's question is answered this way. --actionBarHeight stays what it has
// always been — what is actually covering the bottom of the window — because that is
// what the toast has to clear and what the page has to scroll past.
//
// The remembered height goes stale if the window changes size while the pad is open,
// which on a phone means turning it over mid-raise. The next publish after the pad
// closes is a measurement, so it puts itself right; a stale felt for the seconds in
// between is the same trade being made everywhere else here.
restingBarHeight(measured) {
if (!this.raiseOpen) {
this.barHeightAtRest = measured;
return measured;
}
return this.barHeightAtRest === undefined ? measured : this.barHeightAtRest;
},
// Is the bar a rail down the side rather than a bar across the bottom?
//
// Asked of the stylesheet rather than answered again here. The layout is chosen by
@@ -266,31 +202,9 @@ const ActionBar = {
}
this.syncTankTimer();
// After the tank timer, because whether the stopwatch and the clock button are
// on the acting line is what decides how much room the name has left.
this.fitActingLine();
this.fitUndoLabel();
this.showRailEnd(host);
this.announce();
},
// A phone held sideways puts this bar down the right-hand edge as a rail, anchored
// low: the buttons sit at the bottom, where a thumb rests rather than where a hand
// has to reach up. The rail scrolls when what it holds outgrows it, which the raise
// pad does on a 360px-tall screen the moment the dealer has their text turned up —
// measured at 640x360 with 20px of root text, 407px of pad in a 358px rail.
//
// And a scroll container starts at its top. So the overflow came off the bottom,
// which is exactly where the two buttons that end a raise are: Cancel and Raise to
// were half under the edge of the screen with the hand number and the pot — the two
// things a dealer had already read — holding the room above them. Anchored low, the
// rail should open at the end for the same reason it is anchored low at all.
showRailEnd(host) {
if (!this.isRail(host)) return;
if (host.scrollHeight <= host.clientHeight) return;
host.scrollTop = host.scrollHeight;
},
// The empty table. One sentence and one button, in the place every other prompt in
// this tool appears, so a dealer who has never opened it before is looking at the
// next step rather than at ten chairs labelled EMPTY.
@@ -422,66 +336,20 @@ const ActionBar = {
row.appendChild(runout);
}
// The second line of this block, held as a line rather than left to whatever the
// wrapping happens to produce.
//
// The row wraps, and flex decides its line breaks before it decides what to
// shrink — so a name too long for the space left beside the clock and the undo
// pill did not give way, it pushed them onto a third line. That is 22px, and the
// bar is fixed to the bottom, so it came off the felt: measured at 360x640
// mid-street, "Dee · $600 behind" left the table 283x329 and "Christopher ·
// $1,200 behind" made it 283x310, with every seat moving 24px between one player
// and the next. Whose turn it was decided how big the table was.
//
// On its own line the three of them are a row that can shrink instead of a row
// that can wrap, and the block is two lines whoever is acting.
const acting = document.createElement("div");
acting.className = "actionActing";
const index = this.state.actingSeatIndex;
if (index !== null && index !== undefined && !Betting.isHandOver(this.state)) {
const player = this.state.seats[index];
// Two spans rather than one sentence, because only one half of it is
// allowed to give way. This row wraps, and a name long enough to push the
// clock and the undo pill onto a third line takes 22px off the felt — so
// whose turn it is decided how big the table was. Measured at 360x640:
// "Dee · $600 behind" left the bar 151px tall and "Christopher · $1,200
// behind" made it 173, and the felt resized between one player and the next.
//
// So the name shrinks and the number does not. A name cut to "Christophe…"
// is still the player the dealer is looking at; a stack cut to "$1,2…" is a
// figure they would have to go and find somewhere else.
const who = document.createElement("span");
who.className = "actionWho";
who.textContent = `${HoldemState.displayName(player, index)} · ${Money.format(Betting.stackOf(this.state, player))} behind`;
row.appendChild(who);
const whoName = document.createElement("span");
whoName.className = "actionWhoName";
whoName.textContent = HoldemState.displayName(player, index);
// The whole of it, for the one name on the narrowest phone that still has to
// give up a few characters. Same arrangement the seat pucks have.
whoName.title = whoName.textContent;
who.appendChild(whoName);
const whoStack = document.createElement("span");
whoStack.className = "actionWhoStack";
// No leading space: this is a flex item and its own would be collapsed away
// at the edge. .actionWho holds the two of them apart instead.
whoStack.textContent = `· ${Money.format(Betting.stackOf(this.state, player))} behind`;
who.appendChild(whoStack);
acting.appendChild(who);
// Built every render and shown by the tick, the same way the clock button
// beside it is: a stopwatch reading "0:02" is 50px of the narrowest line in
// the tool spent on a number nobody acts on. Starts hidden so a decision
// that has only just landed on somebody never flashes one up for a frame.
const tank = document.createElement("span");
tank.className = "actionTank";
tank.id = "actionTank";
tank.title = "How long this player has been thinking";
tank.textContent = "0:00";
tank.hidden = true;
acting.appendChild(tank);
row.appendChild(tank);
// Rendered every time and shown by the tick once the wait is long enough
// to be worth it, so a clock never costs a re-render at the moment the
@@ -507,7 +375,7 @@ const ActionBar = {
clock.title = `${this.CLOCK_THINK_S} seconds, then a ${this.CLOCK_COUNTDOWN_S}-second countdown`;
clock.addEventListener("click", () => this.callClock());
}
acting.appendChild(clock);
row.appendChild(clock);
}
// A standing control rather than a toast per action: a dealer taps this bar
@@ -519,50 +387,16 @@ const ActionBar = {
undo.type = "button";
undo.className = "actionUndo";
undo.title = `Undo ${undoLabel}`;
// And said out loud, not only hung off a hover. The visible label is the
// short form and every short form is a substring of this one, which is what
// Label in Name asks: a speech user saying what they can see still hits it,
// and a screen reader gets the qualifier the pill had no room for. Without
// this the name was the visible text — on a phone, "↶ hand killed U".
undo.setAttribute("aria-label", `Undo ${undoLabel}`);
// The label goes in its own element rather than straight onto the button.
// The pill is a fixed width — it names the last action, so its text changes
// on every tap, and a control that resizes under the dealer's thumb reads
// as the interface twitching. Fixed width means long labels have to give
// something up, and only a real element can: as a bare text node the label
// as the interface twitching. Fixed width means long labels have to
// ellipsize, and only a real element can: as a bare text node the label
// truncated *through* the keycap below it, which then sat squashed against
// the cut-off text looking like a rendering fault.
//
// Three elements, because what it gives up matters. Measured on a phone,
// the label has 78px and every label with an actor on it wants more:
// "↶ Mike from work's fold" 128, "↶ Christopher's fold" 109. Widening it is
// not available — the room it would take is the room the acting name beside
// it needs, and that is what fitActingLine is already spending. So the actor
// comes off, whole, and the act stays; see fitUndoLabel, and undoParts for
// what that requires of an act.
const label = document.createElement("span");
label.className = "actionUndoLabel";
const glyph = document.createElement("span");
glyph.className = "actionUndoGlyph";
glyph.textContent = "↶";
// Decoration, and hidden so it stays out of the name above — "left arrow
// over hand killed" is not what this control is called.
glyph.setAttribute("aria-hidden", "true");
label.appendChild(glyph);
const parts = this.undoStep();
if (parts.lead) {
const lead = document.createElement("span");
lead.className = "actionUndoLead";
lead.textContent = parts.lead;
label.appendChild(lead);
}
const act = document.createElement("span");
act.className = "actionUndoAct";
act.textContent = parts.act;
label.appendChild(act);
label.textContent = `↶ ${undoLabel}`;
undo.appendChild(label);
// U undoes, and nothing said so — this control was the only place a dealer
// could have learned it, and it was the one action bar control without its
@@ -574,15 +408,11 @@ const ActionBar = {
// floated above the bar. Here the key belongs inline, after the label.
key.className = "actionUndoKey";
key.textContent = this.KEY_HINTS.actionUndo;
key.setAttribute("aria-hidden", "true");
undo.appendChild(key);
undo.addEventListener("click", () => this.hooks.undoLast());
acting.appendChild(undo);
row.appendChild(undo);
}
// Nothing to act on and nothing to take back — no second line at all.
if (acting.childElementCount) row.appendChild(acting);
return row;
},
@@ -712,59 +542,12 @@ const ActionBar = {
}
},
// Whether this decision has been going on long enough to be worth saying anything
// about — which is the one question behind both the stopwatch and the button beside
// it, so they ask it once and answer it together.
//
// The stopwatch used to be there from the first second of every decision, and for
// the first half-minute it reads "0:07" in grey: a number nobody acts on, holding
// 50px of a line that on a 390px phone has 349 to spend between a name, a stack, a
// clock button and the undo pill. It is not a readout the dealer is watching; it is
// a prompt, and a prompt that is always there is not one. See fitActingLine for
// what the room it gives back is spent on.
tankWorthShowing() {
if (this.clockRunning()) return true;
if (this.tankStartedAt === null) return false;
// Nothing to time from — the action was already here when the page loaded. The
// dash and its explanation are the answer to "why is there no clock on this
// one", so it has to be visible to be asked.
if (this.tankUnknownFor !== null && this.tankSeatIndex === this.tankUnknownFor) return true;
return this.tankSeconds() >= this.NUDGE_AFTER_S;
},
tankSeconds() {
return Math.floor((Date.now() - this.tankStartedAt) / 1000);
},
// Updates only the timer's own text, so the seconds ticking over never rebuilds the
// buttons under the dealer's thumb.
paintTank() {
const el = document.getElementById("actionTank");
if (!el || this.tankStartedAt === null) return;
const nudge = document.getElementById("actionClockBtn");
const wasTank = el.hidden;
const wasNudge = nudge ? nudge.hidden : null;
const worth = this.tankWorthShowing();
el.hidden = !worth;
// The button that starts a clock is offered once somebody has been thinking
// long enough for the table to start asking. Before that it is a control nobody
// needs, and this bar is docked over the seats. While a clock is already running
// the button says "Stop the clock" and renderStatus decides whether it is there
// at all — once the count has expired there is nothing left to stop.
if (nudge && !this.clockRunning()) nudge.hidden = !worth;
if (!this.paintTankText(el)) return;
// Two things just left the line or joined it, so what is left has a different
// amount of room than it did a second ago.
if (el.hidden !== wasTank || (nudge && nudge.hidden !== wasNudge)) this.fitActingLine();
},
// The readout itself. Returns false when it has asked for a full render instead, so
// that nothing downstream measures a bar which is about to be rebuilt.
paintTankText(el) {
// A clock takes the readout over: the number the table is waiting on is no
// longer how long this has taken but how long is left. At zero the bar has to
// change shape — a ruling and the button that carries it out — so that one
@@ -774,95 +557,39 @@ const ActionBar = {
if (left === 0 && !this.clockExpiredShown) {
this.clockExpiredShown = true;
this.render();
return false;
return;
}
el.textContent = left <= this.CLOCK_COUNTDOWN_S ? `${left}` : this.formatTank(left);
el.title = "Time left before the hand is decided";
el.classList.toggle("tankNudge", left > this.CLOCK_COUNTDOWN_S);
el.classList.toggle("tankSlow", left <= this.CLOCK_COUNTDOWN_S);
return true;
return;
}
// The button that starts a clock is offered once somebody has been thinking
// long enough for the table to start asking — which is the same threshold the
// stopwatch already marks. Before that it is a control nobody needs, and this
// bar is docked over the seats.
const nudge = document.getElementById("actionClockBtn");
if (nudge && !this.clockRunning()) {
const waited = Math.floor((Date.now() - this.tankStartedAt) / 1000);
nudge.hidden = this.tankUnknownFor === this.tankSeatIndex
? false
: waited < this.NUDGE_AFTER_S;
}
if (this.tankUnknownFor !== null && this.tankSeatIndex === this.tankUnknownFor) {
el.textContent = "—";
el.title = "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.";
el.classList.remove("tankNudge", "tankSlow");
return true;
return;
}
const seconds = this.tankSeconds();
const seconds = Math.floor((Date.now() - this.tankStartedAt) / 1000);
el.title = "How long this player has been thinking";
el.textContent = this.formatTank(seconds);
el.classList.toggle("tankNudge", seconds >= this.NUDGE_AFTER_S && seconds < this.SLOW_AFTER_S);
el.classList.toggle("tankSlow", seconds >= this.SLOW_AFTER_S);
return true;
},
// Which half of "Christopher · $1,200 behind" survives when the line cannot hold
// both of them.
//
// The name was set up to be the half that gives way, on the rule the seat pucks
// keep: a number read wrong is a worse failure than a name read short. That rule is
// right and it stays. What was missing under it was a floor. Flex shrinks the only
// item that *can* shrink until the line fits, and with the clock button on the line
// there is no fitting to be had — measured at 360x640 and again at 390x844, both
// "Christopher" and "Alexandra" were drawn 0px wide, so the bar read
//
// · $1,200 behind
//
// a dangling separator and a figure, at the exact moment the dealer is looking at
// the bar to say whose turn it is out loud. The ellipsis never appeared: there were
// no characters left to put one after.
//
// So the stack comes off the line before the name gives up a single character. It is
// not lost — it is on that player's own seat puck, a couple of inches up the same
// screen, which is where a dealer counting somebody down is looking anyway. The name
// is the thing the bar alone is saying.
// The last action in the two halves the pill draws it in. The history is main.js's,
// because undo pops it — remembering the halves here would name the step that was
// just taken back rather than the one now on top. A caller that has not been given
// the halves gets the label whole, with nothing to drop, which is what it had before.
undoStep() {
const label = this.hooks.lastActionLabel();
const parts = this.hooks.lastActionParts && this.hooks.lastActionParts();
return parts || { lead: "", act: label };
},
// Which half of "Christopher's fold" the pill can hold.
//
// The actor comes off whole rather than being cut down: "Christo…'s fold" is a worse
// sentence than "fold", and the question a hand later is what came back rather than
// whose it was — the dealer has the seat in front of them. The whole of it stays on
// the button's title and aria-label, which is what the guidance asks of a control
// that has had to shorten its own name.
fitUndoLabel() {
const label = document.querySelector("#actionBar .actionUndoLabel");
const lead = label && label.querySelector(".actionUndoLead");
const act = label && label.querySelector(".actionUndoAct");
if (!lead || !act) return;
// The act is what gets asked, not the label. The label cannot overflow — the act
// inside it is the item with `overflow: hidden`, so it swallows the excess and
// the row it sits in measures as though everything fitted. Asking the label was
// the first way this was written and it read "↶ dealing the hand" with "the
// hand" quietly cut off inside it, on the one label short enough that the actor
// alone still fitted.
//
// Measured with the actor back on, so the answer is a property of the pill
// rather than of whatever the last render left behind.
lead.hidden = false;
if (act.scrollWidth > act.clientWidth + 1) lead.hidden = true;
},
fitActingLine() {
const name = document.querySelector("#actionBar .actionWhoName");
const stack = document.querySelector("#actionBar .actionWhoStack");
if (!name || !stack) return;
// Always measured with the stack back on, so the answer is a property of the
// line rather than of whatever the last render happened to leave behind.
stack.hidden = false;
// What the name wants against what flex gave it. A cut name reads as an
// overflowing one even when the overflow is the whole word.
if (name.scrollWidth > name.clientWidth + 1) stack.hidden = true;
},
// "0:07", "4:01", and past the hour "1:05:03" rather than "65:03" — a table left
@@ -983,48 +710,12 @@ const ActionBar = {
// would actually take back.
if (typeof Toast !== "undefined" && Toast.dismiss) Toast.dismiss();
const step = this.undoParts(label);
this.hooks.beginAction(step.long, step);
this.hooks.beginAction(label);
mutate();
this.hooks.save(this.state);
this.hooks.rerender();
},
// An action's name in the parts the undo pill needs it in.
//
// The pill is a fixed width — it names the last action, so its text changes on every
// tap, and a control that resizes under a thumb reads as the interface twitching.
// Measured on a phone, that leaves the label 78px, and given the halves it can drop
// one. The actor is the half that goes, because the dealer has the seat in front of
// them and the question a hand later is *what* came back, not who it belonged to;
// and because "Christo…'s fold" is a worse sentence than "fold".
//
// Two rules follow, and both were being broken:
//
// `act` has to name the action on its own, because it is what survives. Four
// labels put a verb in the lead instead of an actor — "dealing" + "the hand",
// "calling" + "the straddle off" — so dropping the lead left the pill reading
// "↶ the hand" after every deal of the night, and "↶ the straddle off", neither
// of which names anything. Those are re-split; the ones with no actor at all
// hand over a single string and have nothing to drop.
//
// `act` has to fit, because the box ellipsises whatever is left. Measured at
// 390x844 with a thumb: "hand killed on the clock" wanted 125px of 78, "check on
// the clock" 99, "waiting for the blind" 103, "posting to come in" 97 — all four
// came out cut mid-phrase. The qualifier moves into `long` and the verb stays on
// the pill.
//
// `long` is the whole sentence, for the title and the accessible name and for the
// toast the bar's own Undo raises. Every short form is a substring of it — that is
// what WCAG's Label in Name asks of a control that has shortened its own name, and
// it is why `long` can be the accessible name rather than only a title.
undoParts(label) {
const lead = typeof label === "string" ? "" : (label.lead || "");
const act = typeof label === "string" ? label : label.act;
const spelt = typeof label === "string" ? "" : (label.long || "");
return { lead, act, long: spelt || (lead ? `${lead} ${act}` : act) };
},
renderActions() {
const index = this.state.actingSeatIndex;
const player = this.state.seats[index];
@@ -1053,13 +744,7 @@ const ActionBar = {
expired.verb === "fold" ? `Kill ${expired.who}'s hand` : `Check for ${expired.who}`,
"actionEnforceClock",
() => this.commit(
// The ruling is what the sentence is for; the pill keeps the verb.
// "hand killed on the clock" wants 125px of the 78 the pill has and
// came out "hand killed on the cl…"; "check on the clock" wanted 99.
{ lead: `${expired.who}'s`,
act: expired.verb === "fold" ? "hand killed" : "check",
long: `${expired.who}'s ${expired.verb === "fold"
? "hand killed on the clock" : "check on the clock"}` },
expired.verb === "fold" ? `${expired.who}'s hand killed on the clock` : `${expired.who}'s check on the clock`,
() => {
this.stopClock();
if (expired.verb === "fold") Betting.fold(this.state, index);
@@ -1071,16 +756,16 @@ const ActionBar = {
const row = document.createElement("div");
row.className = "actionButtons";
row.appendChild(this.button("Fold", "actionFold", () => this.commit({ lead: `${who}'s`, act: "fold" }, () => Betting.fold(this.state, index))));
row.appendChild(this.button("Fold", "actionFold", () => this.commit(`${who}'s fold`, () => Betting.fold(this.state, index))));
if (owed <= 0) {
row.appendChild(this.button("Check", "actionCheck", () => this.commit({ lead: `${who}'s`, act: "check" }, () => Betting.check(this.state, index))));
row.appendChild(this.button("Check", "actionCheck", () => this.commit(`${who}'s check`, () => Betting.check(this.state, index))));
} else if (owed >= stack) {
// Calling would put them all-in, so say that rather than quoting a number
// they can't cover.
row.appendChild(this.button("Call all-in", "actionCall", () => this.commit({ lead: `${who}'s`, act: "all-in" }, () => Betting.allIn(this.state, index)), Money.format(stack)));
row.appendChild(this.button("Call all-in", "actionCall", () => this.commit(`${who}'s all-in`, () => Betting.allIn(this.state, index)), Money.format(stack)));
} else {
row.appendChild(this.button("Call", "actionCall", () => this.commit({ lead: `${who}'s`, act: "call" }, () => Betting.call(this.state, index)), Money.format(owed)));
row.appendChild(this.button("Call", "actionCall", () => this.commit(`${who}'s call`, () => Betting.call(this.state, index)), Money.format(owed)));
}
if (stack > owed) {
@@ -1178,13 +863,9 @@ const ActionBar = {
const target = Math.max(0, Math.min(Number(this.raiseDraft) || 0, maxTo));
this.raiseOpen = false;
const who = HoldemState.displayName(player, index);
// The amount stays in the half that survives: "did I raise, and to what"
// is one question. It is the only act long enough to still need an ellipsis
// on the narrowest phone, and only past four figures — "raise to $60" is
// 55px of the 59 the label has once the arrow is drawn, "raise to $1,200" is
// 69. The whole of it is on the button's own title and aria-label either way.
const label = { lead: `${who}'s`,
act: raising ? `raise to ${Money.format(target)}` : `bet of ${Money.format(target)}` };
const label = raising
? `${who}'s raise to ${Money.format(target)}`
: `${who}'s bet of ${Money.format(target)}`;
this.commit(label, () => Betting.raiseTo(this.state, index, target));
}));
@@ -1291,12 +972,12 @@ const ActionBar = {
buttons.push(this.button(
on === 0 ? `Straddle ${nextAmount}` : `Re-straddle ${nextAmount}`,
"actionStraddle",
() => this.commit({ lead: "the", act: on === 0 ? "straddle" : "re-straddle" },
() => this.commit(on === 0 ? "the straddle" : "the re-straddle",
() => { this.state.straddleCount = on + 1; })));
}
if (on > 0) {
buttons.push(this.button("No straddle", "actionStraddle actionStraddleOff",
() => this.commit({ lead: "the straddle", act: "called off" },
() => this.commit("calling the straddle off",
() => { this.state.straddleCount = 0; })));
}
return buttons;
@@ -1384,7 +1065,7 @@ const ActionBar = {
aside.appendChild(this.button(
benched.length === 1 ? "Deal them in" : `Deal in all ${benched.length}`,
"dealPromptDebtBtn",
() => this.commit({ lead: "everybody", act: "dealt in" }, () => {
() => this.commit("dealing everybody in", () => {
benched.forEach(index => {
this.state.seats[index].inHand = true;
this.state.seats[index].folded = false;
@@ -1473,10 +1154,7 @@ const ActionBar = {
posting ? "Don't post" : `Post ${Money.format(preview.bigBlind)}${each}`,
"dealPromptDebtBtn",
() => this.commit(
// Both of these wanted around 100px of 78 and came out mid-phrase.
{ lead: who,
act: posting ? "waiting" : "posting",
long: `${who} ${posting ? "waiting for the blind" : "posting to come in"}` },
posting ? `${who} waiting for the blind` : `${who} posting to come in`,
() => { this.state.postingIn = posting ? [] : ids; })
));
wrap.appendChild(line);
@@ -1495,10 +1173,7 @@ const ActionBar = {
// browser wants the "keep my data" request to come from — see
// requestDurable() in state.js. Harmless and instant after the first time.
if (this.hooks.keepStorage) this.hooks.keepStorage();
// No actor to drop, so no half of this can go missing: "dealing" was the
// lead, and dropping a lead that is a verb left the pill reading "↶ the
// hand" after every deal of the night.
this.commit({ act: "the deal" }, () => Betting.beginHand(this.state));
this.commit("dealing the hand", () => Betting.beginHand(this.state));
}));
wrap.appendChild(row);
@@ -1559,8 +1234,7 @@ const ActionBar = {
this.toShowdown();
return;
}
const street = Betting.streetLabel(Betting.STREETS[Betting.STREETS.indexOf(this.state.street) + 1]).toLowerCase();
this.commit({ act: `the ${street}` }, () => Betting.closeStreet(this.state));
this.commit(`dealing the ${Betting.streetLabel(Betting.STREETS[Betting.STREETS.indexOf(this.state.street) + 1]).toLowerCase()}`, () => Betting.closeStreet(this.state));
// The cards that just came off the deck. Asked for here rather than left
// for the dealer to remember, because the moment they are holding them is
// the only moment they know what they are without turning the board round.
@@ -41,17 +41,7 @@ const AddChips = {
];
},
// Choosing a preset redraws the three of them so the chosen one can light up, which
// meant the button the dealer had just pressed was deleted while it held focus —
// and a deleted element doesn't hand focus on, it drops it on the body. Measured:
// Space on "Buy-in" left focus nowhere, inside a modal, with the amount changed and
// nothing to say so. See Modal.preservingFocus; the key is the amount, because that
// is what makes one of these three buttons itself.
render() {
Modal.preservingFocus(document.getElementById("addChipsSheet"), () => this.paint());
},
paint() {
const player = this.state.seats[this.seatIndex];
document.getElementById("addChipsTitle").textContent =
`Add chips — ${HoldemState.displayName(player, this.seatIndex)}`;
@@ -69,7 +59,6 @@ const AddChips = {
btn.type = "button";
btn.className = "addChipsQuickBtn";
btn.classList.toggle("selected", Number(this.draft) === option.value);
btn.dataset.focusKey = `quick-${option.value}`;
const label = document.createElement("span");
label.className = "addChipsQuickLabel";
@@ -2,51 +2,18 @@
//
// Typing "AH" into a two-character box is fine at a desk and miserable at a table:
// it needs a keyboard, it accepts nonsense like "ZZ", and it happily lets the same
// card land in two players' hands. The picker greys out cards already dealt somewhere
// else at the table and stores exactly the same two-character string the rest of the
// tool has always read.
//
// It used to put all fifty-two on the screen at once, four suits across and thirteen
// ranks down, and that grid is 646px tall. Measured with the board tracked and seven
// cards out:
//
// 640x360 816px of content in a 317px sheet — 40 of the 52 below the fold
// 667x375 816 in 330 — 40 below
// 844x390 774 in 343 — 36 below
// 375x667 816 in 587 — 20 below
// 1280x900 774 in 775 — none
//
// So on every phone the tool is actually used on, entering the flop began with a
// scroll, and a dealer looking for the nine of diamonds was reading down a column.
// Fifty-two 44px targets do not fit a phone in any arrangement: thirteen across is
// 572px of grid before gaps, which is wider than any phone in portrait.
//
// A card is not one choice out of fifty-two. It is two: a suit and a rank. So the sheet
// asks them in that order — four suits on a row, then the thirteen ranks of the one
// chosen — which is seventeen targets instead of fifty-two, all of them on the screen
// at once on every phone, and none of them smaller than they were. The suit stays put
// between picks, so a second card of the same suit is still a single tap.
// card land in two players' hands. The picker is one tap per card, greys out cards
// already dealt somewhere else at the table, and stores exactly the same two-character
// string the rest of the tool has always read.
const CardPicker = {
RANKS: ["A", "K", "Q", "J", "T", "9", "8", "7", "6", "5", "4", "3", "2"],
SUITS: [
{ code: "S", symbol: "♠", name: "Spades", red: false },
{ code: "H", symbol: "♥", name: "Hearts", red: true },
{ code: "D", symbol: "♦", name: "Diamonds", red: true },
{ code: "C", symbol: "♣", name: "Clubs", red: false },
{ code: "S", symbol: "♠", red: false },
{ code: "H", symbol: "♥", red: true },
{ code: "D", symbol: "♦", red: true },
{ code: "C", symbol: "♣", red: false },
],
// Which card in the grid the arrows are holding, as an index into the thirteen
// ranks on show. Kept across a redraw so the grid doesn't send the dealer back to
// the ace every time they pick something — and kept across a change of suit, which
// is the same rank in a different suit rather than a new place to be.
cursor: 0,
// Which suit's thirteen are on the grid. Deliberately not reset when the sheet
// opens: a dealer entering a flush board, or two suited hole cards, picks the suit
// once. Where the slot being filled already holds a card, that card's suit wins —
// the sheet should open showing what it is about to replace.
suit: "S",
state: null,
hooks: null,
// Which set of slots is being filled: a seat's two hole cards, or the five
@@ -63,8 +30,6 @@ const CardPicker = {
this.target = "seat";
this.seatIndex = seatIndex;
this.cardIndex = cardIndex;
this.cursor = 0;
this.aimSuit();
this.render();
document.getElementById("cardPickerOverlay").hidden = false;
},
@@ -79,8 +44,6 @@ const CardPicker = {
this.seatIndex = null;
this.boardLimit = Math.max(1, Math.min(5, limit));
this.cardIndex = Math.min(cardIndex, this.boardLimit - 1);
this.cursor = 0;
this.aimSuit();
this.render();
document.getElementById("cardPickerOverlay").hidden = false;
},
@@ -175,153 +138,10 @@ const CardPicker = {
return ["Flop 1", "Flop 2", "Flop 3", "Turn", "River"][slot] || `Card ${slot + 1}`;
},
// Every card in the deck, ace down to two, each rank's four suits together. The
// grid shows one suit's worth of this at a time — see gridCards — and the duplicate
// check reads the whole of it, so the order is written once here rather than
// rebuilt by two nested loops that have to agree.
deck() {
const cards = [];
this.RANKS.forEach(rank => {
this.SUITS.forEach(suit => cards.push({ rank, suit, card: `${rank}${suit.code}` }));
});
return cards;
},
suitOf(code) {
return this.SUITS.find(suit => suit.code === code) || this.SUITS[0];
},
// The thirteen on the grid: one suit's worth of deck(), in deck order, so the ranks
// read A down to 2 exactly as they always have.
gridCards() {
return this.deck().filter(entry => entry.suit.code === this.suit);
},
// Open on the card that is already there. A slot holding the nine of diamonds
// should show the diamonds with the nine under the cursor — the dealer came here to
// change that card, and hunting for where it lives is work the sheet can do. An
// empty slot changes nothing, which is what keeps the suit across a flush board.
aimSuit() {
const current = (this.slotValues()[this.cardIndex] || "").toUpperCase();
if (!/^[AKQJT2-9][SHDC]$/.test(current)) return;
this.suit = current.slice(1);
const rank = this.RANKS.indexOf(current.slice(0, 1));
if (rank >= 0) this.cursor = rank;
},
// Changing suit is not moving: the cursor is a rank, and the same rank in another
// suit is the card directly beside the one you were on. Coming back with the cursor
// reset to the ace would undo the only thing the dealer had told the sheet.
setSuit(code, { focus = false } = {}) {
if (this.suit === code) return;
this.suit = code;
this.render();
if (focus) {
const chosen = document.querySelector(`#cardPickerSuits [data-suit="${code}"]`);
if (chosen) chosen.focus({ preventScroll: true });
}
},
// Arrow keys along the suits, the way the APG's radio group asks for: one Tab stop
// for the set, arrows to move within it, and moving is choosing.
onSuitKey(event) {
const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[event.key];
if (step === undefined) return;
event.preventDefault();
const at = this.SUITS.findIndex(suit => suit.code === this.suit);
const to = (at + step + this.SUITS.length) % this.SUITS.length;
this.setSuit(this.SUITS[to].code, { focus: true });
},
// How far Down and Up move, asked of the grid rather than assumed. The row is as
// many ranks as the width holds — five on a 360px phone, nine on a phone held
// sideways — so a constant here would be a lie on most screens, and the last time
// this number disagreed with the layout the cursor slid into a column nobody asked
// for. Outside a browser there is nothing to measure and nothing to move.
gridStride() {
const grid = typeof document !== "undefined" && document.getElementById("cardPickerGrid");
if (!grid || typeof getComputedStyle !== "function") return this.RANKS.length;
const columns = getComputedStyle(grid).gridTemplateColumns.split(/\s+/).filter(Boolean).length;
return columns > 0 ? columns : this.RANKS.length;
},
// Where the arrows can go: one card, in the direction asked for, and nowhere if
// that runs off the end of the grid. Wrapping would put the dealer on a different
// rank without them asking to move rank.
//
// This used to hunt past dealt cards, because a `disabled` button cannot take focus
// — and hunting did the very thing the no-wrap rule was written to prevent. Right
// from A♠ with the ace of hearts dealt landed on A♦, so the column silently became
// the one to its right, and the next press of Down walked K♦ instead of K♥. A dealt
// card is `aria-disabled` now, so it holds focus like every other cell and the grid
// tells the truth about its own shape; the APG asks for exactly that inside a
// composite widget, and pressing one is answered rather than ignored.
nextCursor(from, step) {
const count = document.querySelectorAll("#cardPickerGrid .pickerCard").length;
const to = from + step;
return to >= 0 && to < count ? to : from;
},
// One Tab stop for fifty-two cards.
//
// Every card used to be its own stop, which made this sheet fifty-six of them:
// reaching the seven of clubs meant something like forty presses of the only key
// that moves at all without a mouse. The APG's answer for a set of controls like
// this is the roving tabindex — one of them is in the Tab order at a time, and the
// arrows move between them — so the grid now costs one press to enter and one to
// leave, and the arrows bring the row into view on the way, which forty Tab presses
// through a scrolling sheet never reliably did.
//
// No new role is claimed for the container. They are buttons, they announce as
// buttons, and the only thing that changed is how far away they are.
aimCursor(index, { focus = false } = {}) {
const buttons = [...document.querySelectorAll("#cardPickerGrid .pickerCard")];
if (!buttons.length) return;
this.cursor = Math.max(0, Math.min(buttons.length - 1, index));
buttons.forEach((btn, i) => {
btn.tabIndex = i === this.cursor ? 0 : -1;
});
if (focus) buttons[this.cursor].focus({ preventScroll: false });
},
onGridKey(event) {
const stride = this.gridStride();
const step = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: stride, ArrowUp: -stride }[event.key];
if (step !== undefined) {
event.preventDefault();
this.aimCursor(this.nextCursor(this.cursor, step), { focus: true });
return;
}
if (event.key !== "Home" && event.key !== "End") return;
event.preventDefault();
const count = document.querySelectorAll("#cardPickerGrid .pickerCard").length;
this.aimCursor(event.key === "Home" ? this.nextCursor(-1, 1) : this.nextCursor(count, -1), { focus: true });
},
// A redraw is not a reason to lose your place. Every button in here is thrown away
// and rebuilt on each one, so whatever held focus was being deleted out from under
// the dealer — see Modal.preservingFocus. The card just picked is the one key that
// can't come back, because it is a dealt card now: the slot it went into is the
// useful place to be instead.
render() {
const sheet = document.getElementById("cardPickerSheet");
Modal.preservingFocus(sheet, () => this.paint(), () => {
const slot = sheet.querySelector("#cardPickerSlots .pickerSlot.active");
if (slot) slot.focus({ preventScroll: true });
});
},
paint() {
const used = this.cardsInUse();
const values = this.slotValues();
// Whatever the line was answering has been answered. Cleared here rather than
// on a timer, because a paint is exactly the set of things that make the last
// answer stale — a card went in, or the sheet moved to a different slot — and
// nothing else in this sheet redraws.
const note = document.getElementById("cardPickerNote");
if (note) note.textContent = "";
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}`;
@@ -336,17 +156,8 @@ const CardPicker = {
btn.classList.toggle("active", slot === this.cardIndex);
const value = values[slot] || "";
btn.textContent = value || "—";
// What it is called out loud. On the eye it is a card or an em dash, and an
// em dash is what a screen reader was being given as the whole name of the
// control: "dash, button", twice, above fifty-two more. The slot knows which
// slot it is and what is in it, so it says both.
const which = this.onBoard() ? this.boardSlotLabel(slot) : `Card ${slot + 1}`;
btn.setAttribute("aria-label", value ? `${which}: ${value}` : `${which}: empty`);
if (this.onBoard()) btn.title = which;
if (this.onBoard()) btn.title = this.boardSlotLabel(slot);
btn.classList.toggle("cardRed", /[HD]$/i.test(value));
// The name this control answers to after a redraw. A slot is always the
// same slot — flop 2 stays flop 2 — so its number is the whole of it.
btn.dataset.focusKey = `slot-${slot}`;
btn.addEventListener("click", () => {
this.cardIndex = slot;
this.render();
@@ -354,78 +165,40 @@ const CardPicker = {
slots.appendChild(btn);
});
// The first question. Four of them, always in the same four places, so the one
// the dealer wants is somewhere their thumb already knows.
const suits = document.getElementById("cardPickerSuits");
suits.innerHTML = "";
this.SUITS.forEach(suit => {
const chosen = suit.code === this.suit;
const btn = document.createElement("button");
btn.type = "button";
btn.className = "pickerSuitTab";
btn.classList.toggle("cardRed", suit.red);
btn.classList.toggle("chosen", chosen);
btn.textContent = suit.symbol;
btn.setAttribute("role", "radio");
btn.setAttribute("aria-checked", chosen ? "true" : "false");
// The glyph is the whole of the control on the eye, and "♦, radio button"
// is not a suit out loud. The word is.
btn.setAttribute("aria-label", suit.name);
btn.dataset.suit = suit.code;
// One Tab stop for the four, like the grid below it has one for thirteen.
btn.tabIndex = chosen ? 0 : -1;
btn.dataset.focusKey = `suit-${suit.code}`;
btn.addEventListener("click", () => this.setSuit(suit.code, { focus: true }));
btn.addEventListener("keydown", event => this.onSuitKey(event));
suits.appendChild(btn);
});
const gridLabel = document.getElementById("cardPickerGridLabel");
if (gridLabel) gridLabel.textContent = `${this.suitOf(this.suit).name} — pick a rank`;
const grid = document.getElementById("cardPickerGrid");
grid.innerHTML = "";
this.gridCards().forEach(({ rank, suit, card }) => {
const takenBy = used.get(card);
this.RANKS.forEach(rank => {
this.SUITS.forEach(suit => {
const card = `${rank}${suit.code}`;
const takenBy = used.get(card);
const btn = document.createElement("button");
btn.type = "button";
btn.className = "pickerCard";
btn.classList.toggle("cardRed", suit.red);
btn.dataset.focusKey = card;
// Named in the sentence rather than left as "this one", because the sentence
// is read above the grid and the card that was pressed can be eight rows
// down it. Short by measurement, not by taste — see .pickerNote.
const label = `${rank}${suit.symbol}`;
Unavailable.set(btn, !!takenBy, takenBy === "the board"
? `${label} — face up on the board`
: `${label} — dealt to ${takenBy}`);
// Still the tooltip, and now also the accessible description — `title` only
// reaches a screen reader on an element it is allowed to land on, which is
// the other half of what `disabled` was costing here.
if (takenBy) btn.title = Unavailable.reason(btn);
else btn.removeAttribute("title");
if ((values[this.cardIndex] || "").toUpperCase() === card) {
btn.classList.add("selected");
}
const btn = document.createElement("button");
btn.type = "button";
btn.className = "pickerCard";
btn.classList.toggle("cardRed", suit.red);
btn.disabled = !!takenBy;
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");
}
const rankEl = document.createElement("span");
rankEl.className = "pickerRank";
rankEl.textContent = rank;
const suitEl = document.createElement("span");
suitEl.className = "pickerSuit";
suitEl.textContent = suit.symbol;
btn.appendChild(rankEl);
btn.appendChild(suitEl);
const rankEl = document.createElement("span");
rankEl.className = "pickerRank";
rankEl.textContent = rank;
const suitEl = document.createElement("span");
suitEl.className = "pickerSuit";
suitEl.textContent = suit.symbol;
btn.appendChild(rankEl);
btn.appendChild(suitEl);
btn.addEventListener("click", () => this.assign(card));
btn.addEventListener("keydown", event => this.onGridKey(event));
grid.appendChild(btn);
btn.addEventListener("click", () => this.assign(card));
grid.appendChild(btn);
});
});
// Hand the Tab order back exactly one of them. Done after the grid is built,
// because which card can hold the cursor depends on which are dealt already.
this.aimCursor(this.cursor);
},
};
@@ -7,20 +7,7 @@
// (in practice: Undo). Only one is ever on screen; a second replaces the first.
const Toast = {
DEFAULT_DURATION_MS: 8000,
// What the clock is set back to when the dealer lets go of a toast.
//
// Pausing while it is touched or focused is only half the job. Let go at 7.9
// seconds of 8 and the message goes while the thumb is still travelling: the
// timer that matters once somebody has reached for a toast is the time to act
// on it, not what happened to be left of the time to notice it.
RESUME_MS: 2500,
timer: null,
endsAt: 0,
remaining: 0,
// Where focus was standing when this arrived.
returnFocusTo: null,
show(message, options = {}) {
const host = document.getElementById("toastHost");
@@ -30,11 +17,7 @@ const Toast = {
const toast = document.createElement("div");
toast.className = "toast";
// No role on the toast itself. #toastHost is the live region, and it is in the
// markup — which is what makes it observed. A region that arrives already
// carrying its text has not been watched by anything yet, and is routinely
// never spoken at all; one nested inside a region that is watched only buys a
// second reading of the same sentence.
toast.setAttribute("role", "status");
const text = document.createElement("span");
text.className = "toastText";
@@ -46,12 +29,6 @@ const Toast = {
action.type = "button";
action.className = "toastAction";
action.textContent = options.actionLabel;
// Which keyboard shortcut, if any, presses this. Measured on a desktop,
// this button is the 67th of the page's 68 tab stops, and it is gone in
// eight seconds: reaching it by Tab is not a thing anybody can do. Only
// the toasts that say so opt in — "Turn off" and "Reload" are not things
// a stray keypress should be able to reach.
if (options.shortcut) action.dataset.shortcut = options.shortcut;
action.addEventListener("click", () => {
this.dismiss();
options.onAction();
@@ -67,27 +44,6 @@ const Toast = {
close.addEventListener("click", () => this.dismiss());
toast.appendChild(close);
// Read after dismiss() above has had its chance to put focus back, so that a
// toast replacing another remembers where the dealer actually is.
const active = document.activeElement;
this.returnFocusTo = active && active !== document.body ? active : null;
// A message with a button in it is on a timer the reader has to beat, which is
// what WCAG's Timing Adjustable is about. Touching it or tabbing into it stops
// the clock; letting go starts it again.
toast.addEventListener("pointerenter", () => this.hold());
toast.addEventListener("focusin", event => {
// Where focus came *from* beats where it was when this appeared: a dealer
// who moves on and then tabs back into the toast should be put back where
// they had got to, not where they were eight seconds ago.
if (event.relatedTarget && !toast.contains(event.relatedTarget)) {
this.returnFocusTo = event.relatedTarget;
}
this.hold();
});
["pointerleave", "focusout"].forEach(type =>
toast.addEventListener(type, () => this.resume()));
host.appendChild(toast);
// Next frame, so the entrance transition actually runs.
requestAnimationFrame(() => {
@@ -95,51 +51,10 @@ const Toast = {
this.publishHeight();
});
this.arm(options.duration || this.DEFAULT_DURATION_MS);
},
arm(ms) {
if (this.timer) clearTimeout(this.timer);
this.endsAt = Date.now() + ms;
this.timer = setTimeout(() => this.dismiss(), ms);
},
// Is the dealer holding this open — a finger on it, or the keyboard inside it?
heldOpen() {
if (typeof document === "undefined") return false;
const host = document.getElementById("toastHost");
const toast = host && host.firstElementChild;
if (!toast) return false;
if (document.activeElement && toast.contains(document.activeElement)) return true;
// :hover rather than a flag of our own, so a pointer that left the window
// without a pointerleave — or one that never sent pointerenter because the
// toast appeared underneath it — is answered by the same question either way.
try {
return typeof toast.matches === "function" && toast.matches(":hover");
} catch (err) {
return false;
}
},
hold() {
if (!this.timer) return;
clearTimeout(this.timer);
this.timer = null;
this.remaining = Math.max(0, this.endsAt - Date.now());
},
resume() {
// `focusout` arrives before the new focus lands, so "is anything in here still
// focused?" has no true answer until the next tick. pointerleave is deferred
// with it rather than against it: leaving with the keyboard still inside must
// not restart the clock.
setTimeout(() => {
if (this.timer || this.heldOpen()) return;
if (typeof document === "undefined") return;
const host = document.getElementById("toastHost");
if (!host || !host.firstElementChild) return;
this.arm(Math.max(this.remaining, this.RESUME_MS));
}, 0);
this.timer = setTimeout(
() => this.dismiss(),
options.duration || this.DEFAULT_DURATION_MS
);
},
dismiss() {
@@ -147,25 +62,8 @@ const Toast = {
clearTimeout(this.timer);
this.timer = null;
}
this.remaining = 0;
// The same guard publishHeight below already carries, for the same reason: the
// bar dismisses a toast on the way into every action it commits, and that path
// is otherwise plain bookkeeping that answers without a document.
if (typeof document === "undefined") return;
const host = document.getElementById("toastHost");
if (host) {
// Never leave the keyboard standing on nothing. A control removed while it
// has focus hands focus to <body>, and on this page that is 66 tab stops
// back from where the dealer was — so whoever pressed Undo, or ✕, or was
// simply reading when a second toast arrived, goes back where they were.
const active = document.activeElement;
const goBack = host.contains(active) ? this.returnFocusTo : null;
host.innerHTML = "";
if (goBack && goBack.isConnected && typeof goBack.focus === "function") {
goBack.focus();
}
}
this.returnFocusTo = null;
if (host) host.innerHTML = "";
this.publishHeight();
},
@@ -233,80 +131,6 @@ const Dialog = {
},
};
// A control that is on the screen and cannot act, and the reason it can't.
//
// `disabled` is the wrong attribute the moment there is something to say. It takes the
// control out of the tab order, so a screen reader never lands on it and never hears
// the `title` hung off it; and it eats the tap on a touch screen, where there is no
// hover to show a `title` to begin with. Measured on an iPhone with three hands dealt:
// seven of the card picker's fifty-two cards were dead, none of them could hold focus,
// and tapping one changed nothing whatsoever — no toast, no text, not a byte of DOM.
// The tool's answer to "who has the ace of spades?" existed, and was reachable only
// with a mouse.
//
// `aria-disabled` announces the same state and leaves the control reachable, so the
// press arrives and can be answered instead. Nothing enforces it for you — that is the
// whole point — so a press is intercepted once, centrally, in `answerPress` below.
const Unavailable = {
// Which control is being told off, if any. A press can land on the text inside a
// button rather than the button, so the answer is looked for upwards.
from(target) {
if (!target || typeof target.closest !== "function") return null;
return target.closest('[aria-disabled="true"]');
},
reason(el) {
return (el && el.dataset && el.dataset.whyNot) || "";
},
blocked(el) {
return !!el && el.getAttribute("aria-disabled") === "true";
},
// Both directions in one call, because every caller works out the condition anyway
// and the two halves have to stay in step — a control left marked after the reason
// stopped being true is worse than one that was never marked.
set(el, blocked, reason) {
if (!el) return;
if (!blocked) {
el.removeAttribute("aria-disabled");
delete el.dataset.whyNot;
return;
}
el.setAttribute("aria-disabled", "true");
el.dataset.whyNot = reason || "";
},
// Where the answer is written.
//
// The toast is fixed to the bottom of the window, and a sheet that stands 88% of
// the screen tall is sitting on top of it — so a control inside one answers inside
// it instead. A sheet opts in by naming its own line: `data-why-note="someId"`.
// Anything else falls through to the toast, which is where the table says things.
say(el, why) {
const host = el && typeof el.closest === "function" ? el.closest("[data-why-note]") : null;
const note = host ? document.getElementById(host.dataset.whyNote) : null;
if (note) {
note.textContent = why;
return;
}
Toast.show(why);
},
// One listener for the whole table, in the capture phase so it arrives before the
// control's own handler and can stop it. Registered once, by main.js.
listen(root) {
root.addEventListener("click", event => {
const el = this.from(event.target);
const why = this.reason(el);
if (!why) return;
event.preventDefault();
event.stopPropagation();
this.say(el, why);
}, true);
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = { Toast, Dialog, Unavailable };
module.exports = { Toast, Dialog };
}
+48 -81
View File
@@ -5,13 +5,6 @@ document.addEventListener("DOMContentLoaded", () => {
// `hidden` attribute they all already toggle.
Modal.mount();
// A press on a control that can't act is answered rather than swallowed. One
// listener for the whole table, because the alternative is remembering to guard
// every click handler that might one day be attached to a dimmed control — and the
// one that gets forgotten is a dead tap with no explanation, which is the bug this
// is here to end. See Unavailable in feedback.js.
Unavailable.listen(document);
// ---- A scroll is not an edit ----
//
// A focused `<input type="number">` is a spinbutton, and a browser lets the mouse
@@ -61,7 +54,6 @@ document.addEventListener("DOMContentLoaded", () => {
Toast.show(message, {
actionLabel: "Undo",
shortcut: "undo",
onAction: () => {
restoreState(snapshot);
Toast.show("Undone.", { duration: 2500 });
@@ -350,11 +342,6 @@ document.addEventListener("DOMContentLoaded", () => {
// the right panel to have open on a table nobody has touched, and the wrong one to
// have open on the fourth hand of a night.
//
// That was half the answer. The other half was what the panel held while it was
// open — 886px of it, on a 667px phone — and it has since given three switches and
// the paragraphs explaining them to "While you deal". The panel this opens is 529px
// now, and the two figures its own summary glances at are the first two rows of it.
//
// Decided once, here, rather than on every render — a panel that reopened itself
// whenever the table emptied would fight the dealer who had just shut it.
(() => {
@@ -420,18 +407,13 @@ document.addEventListener("DOMContentLoaded", () => {
Render.bringIntoView(panel, { block: "nearest" });
},
// `parts` is the same name split into the actor and the act — see
// ActionBar.undoParts. It rides along with the label rather than replacing it,
// because the toast below wants the sentence and the pill wants the halves.
beginAction: (label, parts) => {
actionHistory.push({ label, parts, snapshot: snapshotState() });
beginAction: label => {
actionHistory.push({ label, snapshot: snapshotState() });
if (actionHistory.length > MAX_UNDO_STEPS) actionHistory.shift();
},
lastActionLabel: () => (actionHistory.length ? actionHistory[actionHistory.length - 1].label : null),
lastActionParts: () => (actionHistory.length ? actionHistory[actionHistory.length - 1].parts : null),
undoLast: () => {
const step = actionHistory.pop();
if (!step) return;
@@ -454,24 +436,6 @@ document.addEventListener("DOMContentLoaded", () => {
return true;
};
// The toast's Undo, pressed the same way the bar's is: by pressing the button that
// is actually on the screen, not by running a second copy of what it does.
//
// It has to be reachable. Every whole-step undo — next hand, resolve pot, a seat
// edited — clears the per-action history on its way past, so the bar has no pill to
// press and this button is the only way back. Measured on a desktop, it is the 67th
// of the page's 68 tab stops and it is gone in eight seconds, so U was the key the
// sidebar legend advertised and nothing answered.
//
// Only a button that named this shortcut, so the wake lock's "Turn off" and the
// other-tab warning's "Reload" are not things a stray U can reach.
const pressToastAction = shortcut => {
const btn = document.querySelector(`#toastHost .toastAction[data-shortcut="${shortcut}"]`);
if (!btn) return false;
btn.click();
return true;
};
const quickRaiseButton = label =>
[...document.querySelectorAll("#actionBar .raiseQuickBtn")]
.find(b => b.querySelector(".actionBtnLabel").textContent === label);
@@ -492,10 +456,7 @@ document.addEventListener("DOMContentLoaded", () => {
// Dealing is an advance too — Enter should carry a dealer through setup, deal,
// streets and showdown without reaching for the trackpad.
advance: () => pressInBar(".raiseConfirm") || pressInBar(".actionAdvance") || pressInBar(".actionDeal"),
// The toast first, because it is the more recent of the two. They can both be
// up at once — a seat edited mid-hand leaves the bar's pill alone — and the one
// the dealer just caused is the one on the screen saying what it will take back.
undo: () => pressToastAction("undo") || pressInBar(".actionUndo"),
undo: () => pressInBar(".actionUndo"),
cancel: () => pressInBar(".raiseCancel"),
});
@@ -515,7 +476,6 @@ document.addEventListener("DOMContentLoaded", () => {
Toast.show(message, {
actionLabel: "Undo",
shortcut: "undo",
onAction: () => {
restoreState(snapshot);
Toast.show("Undone.", { duration: 2500 });
@@ -563,7 +523,7 @@ document.addEventListener("DOMContentLoaded", () => {
});
document.getElementById("basePotInput").addEventListener("input", e => {
state.basePotAmount = Money.readAmount(e.target.value);
state.basePotAmount = e.target.value === "" ? null : Number(e.target.value);
hooks.save(state);
Render.renderAll();
});
@@ -664,10 +624,6 @@ document.addEventListener("DOMContentLoaded", () => {
// middle took the table from 800 chips to 792, and the toast said nothing
// about it. Chips only ever leave this tool because a dealer meant them to.
const unresolved = HoldemState.unresolvedContributions(state);
// Asked before the mutation, because after it there is never a hand out. This is
// the difference between the two things this one button does: end the hand that
// is running, or pass the button along on a hand nobody played.
const wasLive = !!state.bettingOpen;
if (unresolved > Betting.EPSILON_CHIPS) {
const ok = await Dialog.confirm({
@@ -696,17 +652,7 @@ document.addEventListener("DOMContentLoaded", () => {
? ` ${Money.format(unresolved)} taken off the table.`
: "";
// The toast said "Next hand — ... Seat the table, then deal." every time, including
// the press that followed the pot wizard's own "Start Next Hand" — two lines that
// read as a confirmation of the thing the dealer had already done, when what had
// actually happened was a second rotation. It now reports which of the two it was,
// and the second line is gone: the bar is already holding a green "Post blinds &
// deal", so the toast was spending a line telling a dealer to press it.
const done = wasLive
? `Next hand — ${where}.${discarded}`
: `Button moved on, no hand dealt — ${where}.${discarded}`;
runUndoable(done, () => {
runUndoable(`Next hand — ${where}. Seat the table, then deal.${discarded}`, () => {
// Written down as well as announced. The toast is a moment; the bank check
// is asked at cash-out, and until now it saw only that the chips were gone
// and reported them as missing.
@@ -828,10 +774,7 @@ document.addEventListener("DOMContentLoaded", () => {
["defaultStackInput", "defaultStack"],
].forEach(([id, key]) => {
document.getElementById(id).addEventListener("input", e => {
// Money.readAmount rather than Number(): these boxes are type="text" now, so
// a half-typed "0." or a stray letter reaches here instead of being silently
// eaten by the control, and NaN must not become the blind.
state[key] = Money.readAmount(e.target.value);
state[key] = e.target.value === "" ? null : Number(e.target.value);
hooks.save(state);
Render.renderAll();
});
@@ -931,32 +874,56 @@ document.addEventListener("DOMContentLoaded", () => {
});
}
// The sheet opens on twelve shut rulings, and it opens that way every time.
// An index across the top of the rulings sheet, one chip per ruling.
//
// There was an index of chips here that this built from the headings; the headings
// are the index now, so there is nothing to build. What is left is the other half
// of the job: a dealer who read the odd-chip rule an hour ago should not find it
// still hanging open under the one they came back for. <details> keeps its state,
// so closing the sheet is what forgets it.
const openRulings = () => {
rulingsOverlay.querySelectorAll(".ruling[open]").forEach(r => { r.open = false; });
rulingsOverlay.hidden = false;
// This is opened mid-argument with players waiting, and it was nine rulings of
// prose with no way in: reaching the last one meant scrolling 1.6 screens on a
// desktop, 2.1 on a phone and 3.5 on a phone held sideways. A dealer looking for
// the odd-chip rule had to read past seven others to find it.
//
// 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.
const buildRulingsIndex = () => {
const index = document.getElementById("rulingsIndex");
if (!index || index.childElementCount > 0) return;
[...rulingsOverlay.querySelectorAll(".ruling")].forEach((section, i) => {
const heading = section.querySelector("h3");
if (!heading) return;
if (!section.id) section.id = `ruling${i + 1}`;
const jump = document.createElement("button");
jump.type = "button";
jump.className = "rulingsIndexBtn";
// A short label where the ruling gives one. The headings are sentences —
// "A short all-in doesn't reopen the betting" — and nine of those stacked
// one per row made the index 342px tall on a phone, which is most of the
// sheet before the first ruling. "Short all-in" says the same thing to
// somebody who is looking for it, and three fit on a line.
jump.textContent = section.dataset.short || heading.textContent;
// The full heading is still what it announces, so the short label never
// costs a screen reader the sentence.
if (section.dataset.short) jump.setAttribute("aria-label", heading.textContent);
jump.addEventListener("click", () => {
Render.bringIntoView(section, { block: "start" });
// The heading is what the dealer is now reading, so that is where the
// keyboard and a screen reader should be too.
heading.setAttribute("tabindex", "-1");
heading.focus({ preventScroll: true });
});
index.appendChild(jump);
});
};
document.getElementById("openRulingsBtn").addEventListener("click", openRulings);
document.getElementById("openRulingsBtn").addEventListener("click", () => {
buildRulingsIndex();
rulingsOverlay.hidden = false;
});
document.getElementById("closeRulingsBtn").addEventListener("click", closeRulings);
rulingsOverlay.addEventListener("click", e => {
if (e.target === rulingsOverlay) closeRulings();
});
document.getElementById("seatMenuCloseBtn").addEventListener("click", () => SeatMenu.close());
// The button at the thumb end is the sheet's Back while the sheet has an inside to
// go back to. Opening the move picker used to be one-way: it replaced the action
// list, and the only control left threw the whole menu away.
document.getElementById("seatMenuCancelBtn").addEventListener("click", () => {
if (SeatMenu.mode === "move") SeatMenu.closeMovePicker();
else SeatMenu.close();
});
document.getElementById("seatMenuCancelBtn").addEventListener("click", () => SeatMenu.close());
document.getElementById("seatMenuOverlay").addEventListener("click", e => {
if (e.target.id === "seatMenuOverlay") SeatMenu.close();
});
+12 -76
View File
@@ -14,24 +14,12 @@
const Modal = {
// `:not([hidden])` matters — a sheet that is itself hidden inside an open overlay
// must not be a Tab stop.
//
// `details > summary` is here because a summary is focusable without anyone giving
// it a tabindex, and leaving it out was not a small omission. The rulings sheet is
// twelve summaries and one Done button; this list saw one control in it, so the Tab
// trap below decided Done was both the first stop and the last and bounced every
// press straight back to it. Measured: eighty Tab presses, one element ever
// reached. Twelve rulings, none of them openable without a mouse.
//
// `:not([tabindex="-1"])` belongs on every line, not only the last. A control taken
// out of the Tab order still matches `button` or `input` — and a roving tabindex,
// which is what the card grid runs on now, is fifty-one of them at a time.
FOCUSABLE: [
'a[href]:not([tabindex="-1"])',
'button:not([disabled]):not([tabindex="-1"])',
'input:not([disabled]):not([type="hidden"]):not([tabindex="-1"])',
'select:not([disabled]):not([tabindex="-1"])',
'textarea:not([disabled]):not([tabindex="-1"])',
'details > summary:not([tabindex="-1"])',
"a[href]",
"button:not([disabled])",
'input:not([disabled]):not([type="hidden"])',
"select:not([disabled])",
"textarea:not([disabled])",
'[tabindex]:not([tabindex="-1"])',
].join(","),
@@ -75,22 +63,11 @@ const Modal = {
this.open.push({ overlay, returnFocus: document.activeElement });
// Where the sheet puts the dealer when it opens.
//
// This used to be "the first control", full stop — one answer to a question six
// dialogs answer differently, and on the card picker the first control is Clear.
// The sheet for writing a card down opened on the button that rubs one out. The
// APG's rule is the opposite of that: where an action is hard to take back, land
// on the least destructive thing in the dialog.
//
// So a sheet names its own landing spot with `data-modal-focus`, and on all five
// that name one it is the heading. That is Roselli's advice for a dialog with
// more in it than a sentence — give the reader a chance to orient — and here it
// has a second use, because these headings say *which*: which seat, which card,
// which player's chips. A sheet that names nothing still gets the first control,
// and a sheet with no controls at all still gets focus on itself.
// 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 = overlay.querySelector("[data-modal-focus]") || focusable[0];
const target = focusable[0];
if (target) {
target.focus({ preventScroll: true });
} else if (sheet) {
@@ -116,39 +93,6 @@ const Modal = {
}
},
// Rebuild part of a sheet without losing the dealer's place in it.
//
// Emptying the element that currently holds focus does not move focus — it destroys
// it, and the browser drops the remains on `<body>`. Measured, on three sheets that
// redraw themselves after every tap: choosing a buy-in preset, picking a card,
// switching which slot is being filled. All three left focus on the document body,
// which inside a modal is nowhere: the next Tab had to be rescued by the wrap above.
//
// Controls that get rebuilt say who they are with `data-focus-key`, and whoever held
// focus is handed back the control with the same name. When that name is gone the
// caller is asked instead, because only it knows where the dealer should be next —
// the card just picked is a dealt card now, and dealt cards are disabled.
preservingFocus(root, rebuild, whenGone) {
const active = document.activeElement;
const inside = !!(active && root.contains(active));
const key = inside ? active.dataset.focusKey : undefined;
rebuild();
// Nothing was taken from under the dealer: either they weren't in this sheet, or
// the control they were standing on is still standing, and still has them. Only
// a rebuild that destroyed the focused element has anything to put right, which
// is why Clear — a button in the header that no redraw touches — keeps focus
// through a redraw it asked for.
if (!inside || active.isConnected) return;
const again = key === undefined ? null
: [...root.querySelectorAll("[data-focus-key]")]
.find(el => el.dataset.focusKey === key && !el.disabled);
if (again) again.focus({ preventScroll: true });
else if (whenGone) whenGone();
},
topmost() {
return this.open.length ? this.open[this.open.length - 1] : null;
},
@@ -186,20 +130,12 @@ const Modal = {
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = document.activeElement;
// Where the dealer stands *in the ring*, which is a different question from
// where they are on the screen. Three things inside these sheets are on purpose
// not in the ring: the heading a sheet lands on, every card in the grid bar the
// one the arrows are holding, and — for a moment after a rebuild — nothing at
// all, because focus has been dropped on the body. -1 covers all three, and the
// wrap has to work from there or the first Shift+Tab off a heading walks out of
// the dialog it was supposed to be held inside.
const at = focusable.indexOf(document.activeElement);
if (event.shiftKey && at <= 0) {
if (event.shiftKey && (active === first || !top.overlay.contains(active))) {
event.preventDefault();
last.focus({ preventScroll: true });
} else if (!event.shiftKey && (at === -1 || at === focusable.length - 1)) {
} else if (!event.shiftKey && (active === last || !top.overlay.contains(active))) {
event.preventDefault();
first.focus({ preventScroll: true });
}
@@ -17,31 +17,6 @@ const Money = {
return Math.round((Number(amount) || 0) * 100);
},
// What a typed-in box is worth, or null if it isn't worth anything yet.
//
// The numeric fields are type="text" now, which is the whole point — the browser
// hands over exactly what was typed instead of silently dropping the characters it
// dislikes. The cost of that is that what was typed might not be a number, and
// `Number("abc")` is NaN, which would go into state, get saved, and come back as
// null on reload having taken the blind with it.
//
// So the reading is done in one place: empty is null, anything non-finite is null,
// and a real number comes back rounded to the cent the rest of the tool works in.
// Null rather than 0 because the fields it reads distinguish them — an unset
// starting stack falls back to the table default, a zero one does not.
//
// Deliberately not clamped to >= 0 here. A negative blind is a different mistake
// from a mistyped one, and the callers that care already clamp (the rake to 0..100,
// the seat count to 2..24); folding it in would silently turn a typed "-5" into
// something the dealer did not ask for.
readAmount(text) {
if (text == null) return null;
const trimmed = String(text).trim();
if (trimmed === "") return null;
const value = Number(trimmed);
return Number.isFinite(value) ? this.round(value) : null;
},
fromCents(cents) {
return cents / 100;
},
@@ -174,20 +149,11 @@ const Money = {
//
// 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.
//
// What makes a box a number box is now the inputmode rather than the type: nothing
// in the tool is type="number" any more (see the comment above the blinds in
// TexasHoldem.cshtml). Both inputmodes count. "numeric" is the seat count and the
// chip count — whole-number boxes that open pre-filled exactly like the money ones,
// and which this used to catch through their old type. Reading only "decimal" would
// have quietly dropped them, so it reads both.
selectsOnFocus(field) {
if (!field || field.tagName !== "INPUT") return false;
const type = String(field.type || "").toLowerCase();
if (type === "number") return true;
if (type !== "text") return false;
const mode = String(field.inputMode || "").toLowerCase();
return mode === "decimal" || mode === "numeric";
return type === "text" && String(field.inputMode || "").toLowerCase() === "decimal";
},
// Would a wheel over this field change what it says?
@@ -200,12 +166,6 @@ const Money = {
//
// 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.
//
// As of the move off type="number" this answers false for every field in the tool,
// and that is the point rather than a bug: a text box with an inputmode is not a
// spinbutton, so a wheel over it cannot change it and there is nothing left to
// defend. Kept, tests and all, because it is the guard that makes that safe to rely
// on — reintroduce a type="number" anywhere and this starts firing again on its own.
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,
@@ -24,32 +24,6 @@ const PotCalculator = {
return entries.filter(e => !e.folded && e.remaining > this.EPSILON);
},
// The most this player can be handed if they win the round outright.
//
// resolveRound caps the pool at the winner's own remaining contribution and then
// takes min(remaining, cap) from every entry, dead money included — so the ceiling
// is that sum. It is not the player's own contribution, which is what the wizard
// used to print beside each name under the words "up to": with three players in
// for $250 each the rows all read "up to $250" while the prompt above them said
// $765 was in play, and $765 is what the winner was actually paid. A dealer who
// trusted the row would have short-paid by two thirds.
ceilingFor(entries, id) {
const winner = entries.find(e => e.id === id);
if (!winner || winner.remaining <= this.EPSILON) return 0;
return Money.round(entries.reduce(
(sum, e) => sum + Math.min(e.remaining, winner.remaining), 0));
},
// The same number, but only when it is worth saying: a ceiling equal to everything
// on the table is the prompt's own figure repeated once per row, which is ink that
// carries no data. It appears exactly when somebody is in for less than the rest —
// which is the one time a dealer needs to be told, because it is the hand where
// picking that winner leaves a side pot still to award.
bindingCeilingFor(entries, id) {
const ceiling = this.ceilingFor(entries, id);
return ceiling < this.totalPot(entries) - this.EPSILON ? ceiling : null;
},
// Splits the money on the table into the main pot and any side pots, with who can
// win each. Read-only — this is what a dealer says out loud before the showdown
// ("main pot four-eighty, side pot ninety between seats 3 and 5"), which is the
@@ -108,25 +108,7 @@ const PotWizard = {
requestAnimationFrame(update);
},
// Confirming a round replaces the whole step, buttons and all, and the button it
// replaces first is the Confirm the dealer just pressed — so on a table with side
// pots, paying the main pot dropped focus on the body and the next Tab had to start
// over. There is no keeping them where they were here: the control is gone and the
// question has changed. The new question is where they should be, so focus lands on
// the sentence asking it — "that side pot still leaves $240 unassigned, who's next
// best?" — rather than on the button that answers it.
renderStepInto() {
const modal = document.getElementById("potWizardModal");
Modal.preservingFocus(modal, () => this.paintStep(), () => {
const asking = modal.querySelector("#potWizardBody .potWizardPrompt")
|| document.getElementById("potWizardTitle");
if (!asking) return;
asking.tabIndex = -1;
asking.focus({ preventScroll: true });
});
},
paintStep() {
// Auto-resolve any round where only one player is left standing — nothing to ask.
let contenders = PotCalculator.getContenders(this.entries);
while (contenders.length === 1) {
@@ -236,18 +218,10 @@ const PotWizard = {
}
label.appendChild(text);
// What this row costs if it is the one ticked — but only on the rows where
// that is less than the figure in the prompt above. On a pot everybody
// covered it was the same number three times, and it was the wrong number:
// each player's own stake rather than what winning pays them. See
// PotCalculator.bindingCeilingFor.
const ceiling = PotCalculator.bindingCeilingFor(this.entries, c.id);
if (ceiling !== null) {
const upTo = document.createElement("span");
upTo.className = "potWizardChoiceUpTo";
upTo.textContent = `up to ${Money.format(ceiling)}`;
label.appendChild(upTo);
}
const upTo = document.createElement("span");
upTo.className = "potWizardChoiceUpTo";
upTo.textContent = `up to ${Money.format(c.remaining)}`;
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.
@@ -21,27 +21,6 @@ const QuickSplit = {
mode: "even",
entries: [],
// The first count the preset chips don't cover. Below it the chips are the control;
// at it and above, one chip opens a stepper and the stepper is the control. The
// number is never stated by two of them at once — it used to be stated by three,
// with chip "2" lit gold, "2 ways" in a bar under it, and "Each of 2 players gets"
// in the panel above.
overflowFrom() {
return this.WAY_CHOICES[this.WAY_CHOICES.length - 1] + 1;
},
clampWays(count) {
return Math.max(this.WAY_CHOICES[0], Math.min(this.MAX_WAYS, count));
},
// A pot is chopped between the players who can win it, and there is no such thing
// as chopping it between more of them than there are. This used to read `Math.max`,
// which let a side pot only two players were eligible for be split ten ways at $12
// each — an answer to a question nobody at the table could have asked.
chopCeiling(contenderCount) {
return Math.max(2, Math.min(contenderCount, this.MAX_WAYS));
},
mount() {
this.potInput = document.getElementById("potInput");
this.clearPotBtn = document.getElementById("clearPotBtn");
@@ -107,11 +86,10 @@ const QuickSplit = {
renderWayChoices() {
const row = document.getElementById("waysChoices");
row.innerHTML = "";
const beyond = this.ways >= this.overflowFrom();
this.WAY_CHOICES.forEach(count => {
row.appendChild(
this.makeChoiceButton(String(count), !beyond && this.ways === count, () => {
this.makeChoiceButton(String(count), this.ways === count, () => {
this.ways = count;
this.renderWayChoices();
this.update();
@@ -119,65 +97,34 @@ const QuickSplit = {
);
});
// Beyond a five-way chop it's rare enough not to be worth a chip each. One more
// chip is the door to the rest; it reads "6+" whatever the count behind it is,
// because the stepper is where the count is written. Tapping it while it is
// already open does nothing — it is the fifth option of a group, not a toggle.
const door = this.makeChoiceButton(`${this.overflowFrom()}+`, beyond, () => {
if (beyond) return;
this.ways = this.overflowFrom();
this.renderWayChoices();
this.update();
});
door.setAttribute("aria-label", `${this.overflowFrom()} or more`);
row.appendChild(door);
if (beyond) row.appendChild(this.buildWaysStepper());
},
// Six to twelve, which is exactly what the door promised. The floor is 6 rather
// than 2 because the chips below it are one tap away and already on screen — and
// because stepping down out of the stepper would pull the stepper out from under
// the finger that was pressing it.
buildWaysStepper() {
const stepper = document.createElement("div");
stepper.className = "waysStepper";
stepper.setAttribute("role", "group");
stepper.setAttribute("aria-label", "How many ways");
stepper.appendChild(this.makeStepButton("−", () => this.stepWays(-1), {
label: "One fewer way",
disabled: this.ways <= this.overflowFrom(),
}));
// Beyond a five-way chop it's rare enough to hide behind a stepper rather than
// spend a row of buttons on it.
const more = document.createElement("div");
more.className = "waysStepper";
const minus = this.makeStepButton("−", () => this.stepWays(-1));
const plus = this.makeStepButton("+", () => this.stepWays(1));
const readout = document.createElement("span");
readout.className = "waysReadout";
readout.textContent = String(this.ways);
stepper.appendChild(readout);
readout.textContent = `${this.ways} ways`;
stepper.appendChild(this.makeStepButton("+", () => this.stepWays(1), {
label: "One more way",
disabled: this.ways >= this.MAX_WAYS,
}));
return stepper;
more.appendChild(minus);
more.appendChild(readout);
more.appendChild(plus);
row.appendChild(more);
},
makeStepButton(label, onPick, options) {
makeStepButton(label, onPick) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "stepBtn";
btn.textContent = label;
if (options && options.label) btn.setAttribute("aria-label", options.label);
// A step that can't happen says so. Both ends of both steppers on this screen
// used to look exactly like the middle and swallow the tap in silence.
btn.disabled = !!(options && options.disabled);
btn.addEventListener("click", onPick);
return btn;
},
stepWays(delta) {
this.ways = this.clampWays(this.ways + delta);
this.ways = Math.max(2, Math.min(this.MAX_WAYS, this.ways + delta));
this.renderWayChoices();
this.update();
},
@@ -258,8 +205,7 @@ const QuickSplit = {
// 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) {
const ceiling = this.chopCeiling(contenderCount);
let ways = ceiling;
let ways = contenderCount;
let open = false;
const toggle = document.createElement("button");
@@ -279,24 +225,19 @@ const QuickSplit = {
const result = SplitCalculator.split(amount, ways, this.chip);
detail.innerHTML = "";
// Two eligible players is two ways and nothing else, so the stepper is left
// out rather than shown with both ends dead — which is the common shape, a
// side pot usually coming down to the two who could still call.
if (ceiling > 2) {
const stepper = document.createElement("div");
stepper.className = "unevenChopWays";
stepper.appendChild(this.makeStepButton("−", () => {
if (ways > 2) { ways -= 1; draw(); }
}, { label: "One fewer way", disabled: ways <= 2 }));
const label = document.createElement("span");
label.className = "unevenChopWaysLabel";
label.textContent = `${ways} ways`;
stepper.appendChild(label);
stepper.appendChild(this.makeStepButton("+", () => {
if (ways < ceiling) { ways += 1; draw(); }
}, { label: "One more way", disabled: ways >= ceiling }));
detail.appendChild(stepper);
}
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";
@@ -427,10 +368,4 @@ const QuickSplit = {
},
};
if (typeof document !== "undefined") {
document.addEventListener("DOMContentLoaded", () => QuickSplit.mount());
}
if (typeof module !== "undefined" && module.exports) {
module.exports = QuickSplit;
}
document.addEventListener("DOMContentLoaded", () => QuickSplit.mount());
+124 -491
View File
@@ -21,7 +21,6 @@ const Render = {
this.lastHistorySignature = null;
this.handTimeCache.clear();
this.watchSeatSheetHeight();
this.watchFieldExits();
this.renderAll();
},
@@ -114,79 +113,21 @@ const Render = {
});
},
// ---- Writing a value into a field somebody might be standing in ----
//
// A render says what the state is. It is not entitled to say what somebody is
// half-way through typing, and when it did, it ate the decimal point.
//
// The six setup fields are wired `input` -> save -> renderAll, and renderAll comes
// back round through renderSetupFields and assigns `.value`. Type "0.25" into the
// small blind and the fourth keystroke is not the one that breaks it; the "." is:
//
// "0" -> reads "0" -> state 0 -> written back "0" (no change, fine)
// "0." -> reads "0" -> state 0 -> written back "0" <- the "." is gone
// then -> caret is at 0, because assigning .value to a number input parks it
// at the start (number inputs do not expose selectionStart at all)
// "2" -> "20"
// "5" -> "250"
//
// A dealer setting $0.25/$0.50 blinds got a table playing 250/500, silently, with
// every pot for the rest of the night computed off it. Measured on all six:
// 0.25->250, 0.50->500, 12.50->5012, 2.5->52 (a 52% rake), 7.50->507, 0.75->750.
//
// So: never write into the control that has focus. It is the one place where the
// person knows better than the state does, and the state is a keystroke behind them
// anyway. Everything they type still lands in state through the `input` handler, so
// skipping the write-back costs nothing and is not a deferral of it.
//
// This guards every control rather than the six that were caught, because the next
// field wired to a re-render is the next one to eat a decimal point, and there is
// nothing about it at the call site to warn whoever adds it.
setField(el, value) {
if (!el) return;
if (typeof document !== "undefined" && el === document.activeElement) return;
const next = value == null ? "" : String(value);
// Assigning an identical value still moves the caret in some engines, so the
// compare is part of the guard rather than an optimisation.
if (el.value !== next) el.value = next;
},
setToggle(el, on) {
if (!el) return;
if (typeof document !== "undefined" && el === document.activeElement) return;
if (el.checked !== !!on) el.checked = !!on;
},
// The other half of the rule above: a field that was skipped because somebody was
// standing in it has to catch up when they step out, or it keeps showing what they
// typed after the state has moved on (a blind that got clamped, say). Bound once,
// on the document, because the controls it covers are rebuilt from templates.
watchFieldExits() {
if (this.fieldExitWatcher || typeof document === "undefined") return;
this.fieldExitWatcher = event => {
const el = event.target;
if (!el || !el.matches || !el.matches("input, select, textarea")) return;
// After the blur has settled, so activeElement is no longer this field.
setTimeout(() => this.renderSetupFields(), 0);
};
document.addEventListener("focusout", this.fieldExitWatcher);
},
renderSetupFields() {
this.setField(document.getElementById("seatCountInput"), this.state.seatCount);
this.setField(document.getElementById("directionSelect"), this.state.direction);
this.setField(document.getElementById("basePotInput"), this.state.basePotAmount ?? "");
this.setToggle(document.getElementById("dedicatedDealerToggle"), this.state.dedicatedDealer);
this.setToggle(document.getElementById("trackBoardToggle"), this.state.trackBoard);
this.setToggle(document.getElementById("keepScreenAwakeToggle"), this.state.keepScreenAwake !== false);
this.setField(document.getElementById("smallBlindInput"), this.state.smallBlind ?? "");
this.setField(document.getElementById("bigBlindInput"), this.state.bigBlind ?? "");
this.setField(document.getElementById("defaultStackInput"), this.state.defaultStack ?? "");
document.getElementById("seatCountInput").value = this.state.seatCount;
document.getElementById("directionSelect").value = this.state.direction;
document.getElementById("basePotInput").value = this.state.basePotAmount ?? "";
document.getElementById("dedicatedDealerToggle").checked = !!this.state.dedicatedDealer;
document.getElementById("trackBoardToggle").checked = !!this.state.trackBoard;
document.getElementById("keepScreenAwakeToggle").checked = this.state.keepScreenAwake !== false;
document.getElementById("smallBlindInput").value = this.state.smallBlind ?? "";
document.getElementById("bigBlindInput").value = this.state.bigBlind ?? "";
document.getElementById("defaultStackInput").value = this.state.defaultStack ?? "";
const rake = this.state.rake || {};
this.setField(document.getElementById("rakePercentInput"), rake.percent || "");
this.setField(document.getElementById("rakeCapInput"), rake.cap ?? "");
this.setToggle(document.getElementById("rakeNoFlopInput"), rake.noFlopNoDrop !== false);
document.getElementById("rakePercentInput").value = rake.percent || "";
document.getElementById("rakeCapInput").value = rake.cap ?? "";
document.getElementById("rakeNoFlopInput").checked = rake.noFlopNoDrop !== false;
// What the house has actually taken tonight, said where the rake is set rather
// than only in the bank check — a dealer who changes the percentage mid-session
@@ -220,8 +161,8 @@ const Render = {
// on and stays away otherwise — nobody waiting, no notes, no hands yet — because a
// row reading "Nobody waiting" all night is the clutter this is here to remove, and
// eight rows that always say something are eight rows that say nothing again.
PANEL_GLANCES: ["glanceTableSetup", "glanceWhileYouDeal", "glanceStandings",
"glanceWaiting", "glanceHands", "glanceChips", "glanceNotes"],
PANEL_GLANCES: ["glanceTableSetup", "glanceStandings", "glanceWaiting",
"glanceHands", "glanceChips", "glanceNotes"],
// Pure, so the wording can be tested without a page to hang it on. Every value is
// read off the state the panel below it renders from, never off the panel's own
@@ -263,24 +204,8 @@ const Render = {
// over an empty table would be answering for a count that was never taken.
const nothingBanked = bank.cashIn === 0 && bank.chipsInPlay === 0;
// The three switches behind "While you deal", which is shut unless somebody
// opens it: a switch nobody can see is a switch nobody remembers turning on, and
// a dealer wondering why a card sheet keeps appearing should be told where it
// lives without having to go looking.
//
// One of them, not all three. This row is 44px of a 320px column with a label
// already on it, and the three joined run to 49 characters — which ellipsizes
// into "House dealer · Asking for c…", a row that has told nobody anything. So
// it names the one most likely to be the surprise, and opening the panel shows
// the rest. Keeping the screen on is the only one that ships on, so it is its
// absence that gets said.
const dealing = state.trackBoard ? "Asking for cards"
: state.dedicatedDealer ? "House dealer"
: state.keepScreenAwake === false ? "Screen may sleep" : "";
return {
glanceTableSetup: glance(`${state.seatCount} seats${blinds}`),
glanceWhileYouDeal: glance(dealing),
glanceStandings: nothingBanked ? glance("")
: bank.balanced ? glance("Balanced", "ok")
: glance(`Off by ${Money.format(Math.abs(bank.difference))}`, "warn"),
@@ -321,30 +246,6 @@ const Render = {
if (!misdeal) return;
misdeal.hidden = !this.state.bettingOpen;
// "Next hand" names three different things in this tool: the bar's green button
// deals one, the pot wizard finishes on "Start Next Hand", and this header
// shortcut — the only one of the three that does not start a hand. Between hands
// it moves the button a second time, so the seat that just paid the big blind
// pays it again, and it said so in a toast that opens with the same two words
// the wizard just used. Measured: wizard → hand 6, button seat 2; press this →
// still hand 6, button seat 3, no confirmation asked.
//
// The mechanism is right — a dealer does pass the button along on a hand nobody
// played — so what changes is the name. A command is named for the state the
// system moves into (NN/g, Button States: "Show Tab Bar" / "Hide Tab Bar"), and
// between hands the state it moves into is a moved button, not a new hand.
const nextHand = document.getElementById("nextHandBtn");
if (nextHand) {
const live = !!this.state.bettingOpen;
nextHand.textContent = live ? "Next Hand ⟳" : "Move Button ⟳";
// Written every render, ahead of the borrow-and-hand-back below: that cache
// is filled once from the markup, and a title that depends on the hand would
// freeze on whichever one happened to be true at load.
nextHand.dataset.titleWhenUsable = live
? "End this hand and move the button, small blind and big blind to the next occupied seat"
: "Pass the button on again without dealing — for a hand nobody played";
}
// On a table nobody is sitting at, Next Hand has no button to move and Resolve
// Pot has no pot, so both are visibly not-yet rather than sitting there looking
// ready.
@@ -359,18 +260,12 @@ const Render = {
// here is only the part about what a control can do — which is still worth
// saying, because a shortcut that cannot work yet should not look ready.
//
// Dimmed 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.
//
// Dimmed rather than `disabled`, because there is a reason and it was only ever
// readable with a mouse. `disabled` took both of these out of the tab order, so
// the sentence hung off them never reached a screen reader, and it swallowed the
// tap on the device this tool is built for — leaving a dealer's first screen
// holding two grey buttons that answered nothing when pressed. See Unavailable.
// 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.
const nothingToRunYet = !this.state.seats.some(seat => HoldemState.seatInPlay(seat));
[["resolvePotBtn", "Nothing to resolve yet — seat a player first."],
["nextHandBtn", "Nothing to move on from — seat a player first."]]
[["resolvePotBtn", "Nothing to resolve — nobody is at the table yet"],
["nextHandBtn", "Nothing to move on from — nobody is at the table yet"]]
.forEach(([id, why]) => {
const button = document.getElementById(id);
if (!button) return;
@@ -379,7 +274,7 @@ const Render = {
if (button.dataset.titleWhenUsable === undefined) {
button.dataset.titleWhenUsable = button.getAttribute("title") || "";
}
Unavailable.set(button, nothingToRunYet, why);
button.disabled = nothingToRunYet;
const title = nothingToRunYet ? why : button.dataset.titleWhenUsable;
if (title) button.setAttribute("title", title);
else button.removeAttribute("title");
@@ -736,14 +631,10 @@ const Render = {
SEAT_CLEARANCE_PX: 4,
// And between a puck's inner edge and the chip sitting in front of it.
BET_CLEARANCE_PX: 5,
// How far a marker hangs off the puck it belongs to — the `top` and `bottom` on
// .blindBadge and on .dealerChip in holdem.css, which seatFootprint.test.js holds this
// to. It points away from the table at both ends; what it must not do is reach
// *sideways*, which is the other thing that test checks.
//
// Charged once per seat, not once per marker, and the two markers are why that is
// still right: they share one edge and sit at opposite ends of it, so a seat wearing
// both is no taller than a seat wearing either.
// How far a blind badge hangs off the puck it belongs to — the `top: -9px` and
// `bottom: -9px` in holdem.css. It points away from the table at both ends, so it
// takes no room between two seats; what it must not do is reach *sideways*, which is
// what seatFootprint.test.js holds the stylesheet to.
SEAT_BADGE_OVERHANG_PX: 5,
// How much space two neighbouring seats need between their centres.
@@ -901,36 +792,29 @@ const Render = {
return widths;
},
// What the table has changed about the shape of its pucks — which is now almost
// nothing, and that is the point.
// What the table has changed about the shape of its pucks. Anything that can make a
// name wrap to a second line belongs here, because the height that comes back from
// measuring is what the ring is sized against.
//
// This used to name every player's name, the dealer's seat, whether anybody had hole
// cards and whether anybody had chips out, because each of those could change how
// tall a puck was and the ring is sized against the tallest puck on it. Four things,
// three of which flip during an ordinary hand, and every flip is a ten-candidate
// re-search: ten widths applied and read back, ten forced layouts, on a phone, while
// somebody is waiting to know whether the bet is called.
// The hole-card line and the amount pill belong here for the same reason, now that
// an empty one collapses instead of holding its space: a puck with either of them
// filled is about 15px taller than the same puck without, and a ring still sized
// from the measurement taken before the first chips went in is sized for a puck
// that no longer exists.
//
// A puck's height no longer depends on any of them. The name gets one line whatever
// it says, the amount pill holds its row whether or not there is an amount in it, and
// the button's chip changes a padding rather than a line count — see the row-height
// tokens in holdem.css. What is left is the hole-card row, which still collapses when
// the table isn't tracking cards, because that is a decision a table makes once and
// not a thing that changes under a dealer's thumb.
//
// A boolean over the whole table rather than a list per seat, because that is what
// the measurement is: measuredSeatBox takes the tallest puck on the ring, so what
// matters is whether *anybody* has one.
//
// Being wrong here is a stale ring rather than a wrong one, and it would not stay
// wrong: renderSeats measures the finished pucks against the box the search settled
// on and asks again if they no longer match. That check is what catches a fifth thing
// nobody thought of, and it is what catches the first render of all — where the pucks
// are built empty and the search that used to run against them was measuring nine
// blank boxes and caching the answer under this table's key.
// Booleans over the whole table rather than a list per seat, because that is what
// the measurement is — measuredSeatBox takes the tallest puck on the ring, so what
// matters is whether *anybody* has one. A boolean also flips about twice a hand
// rather than on every action, and each flip costs ten forced layouts, which is the
// expense this cache exists to avoid.
seatShapeSignature() {
if (!this.state) return "";
return this.state.seats.some(p => (p.cards || []).some(Boolean)) ? "c" : "";
const seats = this.state.seats;
const anyCards = seats.some(p => (p.cards || []).some(Boolean));
const anyAmount = seats.some(p => Betting.handTotal(p) > 0 ||
HoldemState.computeContribution(p, this.state.chipLegend, this.state.basePotAmount) > 0);
return `${seats.map(p => p.name || "").join("")}|${this.state.dealerSeatIndex}` +
`|${anyCards ? "c" : ""}${anyAmount ? "a" : ""}`;
},
// Which width the pucks get, and how wide a ring they sit on.
@@ -987,19 +871,14 @@ const Render = {
this.applySeatWidth(layer, width);
const puck = this.measuredSeatBox();
if (!puck) {
// Nothing on the felt to measure at all. Take the widest and say nothing
// about it — no `puck`, so renderSeats' second pass knows this answer was
// not measured and asks again once there is something to measure.
//
// It used to cache this one, on the reasoning that "the resize that
// follows" would put it right. Nothing resizes a phone. A table opened
// upright kept a ring measured before it had any seats on it until the
// dealer turned the phone sideways and back.
return { width, radii: this.seatRingRadii() };
// Nothing laid out yet — the very first render. Take the widest and let
// the resize that follows do this properly.
const radii = this.seatRingRadii();
if (key) this.seatLayoutCache = { key, width, radii };
return { width, radii };
}
candidates.push({
width,
puck,
contained: this.seatRingRadii(puck),
footprint: this.seatFootprint(puck),
tooTall: this.tooTallForTheTable(),
@@ -1017,10 +896,7 @@ const Render = {
rx: candidate.contained.rx + (this.RING_MAX_RX - candidate.contained.rx) * t,
ry: candidate.contained.ry + (this.RING_MAX_RY - candidate.contained.ry) * t,
};
if (fits(candidate, radii)) {
chosen = { width: candidate.width, radii, puck: candidate.puck };
break;
}
if (fits(candidate, radii)) { chosen = { width: candidate.width, radii }; break; }
}
}
@@ -1029,11 +905,7 @@ const Render = {
// so out loud rather than the layout quietly papering over it.
if (!chosen) {
const last = candidates[candidates.length - 1];
chosen = {
width: last.width,
radii: { rx: this.RING_MAX_RX, ry: this.RING_MAX_RY },
puck: last.puck,
};
chosen = { width: last.width, radii: { rx: this.RING_MAX_RX, ry: this.RING_MAX_RY } };
}
this.applySeatWidth(layer, chosen.width);
@@ -1536,7 +1408,7 @@ const Render = {
// these read the layout back, so they are taken once, here, before the loop
// starts writing to it: asked per seat they would force the browser to
// recompute the layout on every iteration.
let { radii, puck } = this.resolveSeatLayout(count);
const { radii } = this.resolveSeatLayout(count);
const aspect = this.feltAspect();
const { dealerIndex, sbIndex, bbIndex, manual: manualBlinds } = HoldemState.getBlindSeats(this.state);
// A button on an empty chair normally means the table has drifted and wants
@@ -1557,10 +1429,12 @@ const Render = {
const dealerChip = node.querySelector(".dealerChip");
const hasButton = index === dealerIndex;
dealerChip.classList.toggle("active", hasButton);
// The seat used to be marked too — `seatHasButton` — so the puck's text could
// keep out from under a chip that sat in its corner. The chip is on the rim
// with the blind badges now and there is nothing to keep out of, so the seat
// no longer needs to know which of nine chairs the chip is on.
// Marked on the seat as well as the chip, so the puck's own text can keep
// out from under it. A name is centred across the whole puck and the chip
// sits in the corner on a layer above: measured, "Christopher" on the
// button lost 20px of its 64px box to the chip and showed no ellipsis,
// because the text believed it had the room it was painted out of.
node.classList.toggle("seatHasButton", hasButton);
dealerChip.classList.toggle("hidden", !!this.state.dedicatedDealer);
// Every one of these announced itself as "D", so a screen reader heard a row
// of nine identical buttons and couldn't tell which seat held the real one.
@@ -1601,34 +1475,8 @@ const Render = {
});
this.fitSeatNames(layer);
this.fitAmounts(this.seatAmountCells(layer));
// The pucks are only now what they will be drawn as, and the ring was sized
// before any of it went in. Usually those agree — a puck's height is a stack of
// rows the stylesheet holds open, and none of the writing above changes it.
//
// Twice they don't. The first render of all builds nine empty pucks and then
// fills them, so a search run against that measured blank boxes; and any
// assumption in seatShapeSignature that turns out to be wrong lands here too.
// Both used to be permanent, because the answer was cached under a key naming a
// table the measurement had never seen: a phone that never resizes kept a ring
// sized for empty seats for the whole session.
//
// So the finished pucks are measured against the ones the ring was built from,
// and if they have moved the search is asked again with the real thing in front
// of it. Once. The second answer is cached and the render after this one reads it
// back, which is what stops this being a loop.
if (this.seatBoxMoved(puck)) {
this.seatLayoutCache = null;
({ radii, puck } = this.resolveSeatLayout(count));
for (let index = 0; index < layer.childElementCount; index++) {
this.placeSeat(layer.children[index], index, aspect, radii);
}
this.fitSeatNames(layer);
// The puck is a different width than the one these were fitted to.
this.fitAmounts(this.seatAmountCells(layer));
}
// The names have just changed how tall some of these pucks are, so this comes
// after them: a two-line name shortened to one line is 15px of height back.
const settled = this.settleSeatsOnTable(layer, aspect, radii);
// Kept so the board can ask for the chips to be laid out again if placing it
@@ -1637,23 +1485,6 @@ const Render = {
this.renderBets(aspect, settled);
},
// Are the pucks on the felt still the puck the ring was measured for?
//
// A pixel of slack, the same as settleSeatsOnTable's, because the two measurements
// are taken at different moments and a sub-pixel difference between them is a
// rounding artefact rather than a table that has changed shape. Without it this
// would ask the search to run again on every render for the rest of the session.
//
// No box at all means the search never measured one — the first render, before the
// pucks were filled in. That is exactly the case worth asking again about.
seatBoxMoved(puck) {
if (!puck) return true;
const now = this.measuredSeatBox();
if (!now) return false;
return Math.abs(now.width - puck.width) > this.SEAT_SETTLE_SLACK_PX ||
Math.abs(now.height - puck.height) > this.SEAT_SETTLE_SLACK_PX;
},
// One seat, onto the ring.
placeSeat(node, index, aspect, radii) {
const pos = this.seatPosition(index, this.state.seatCount, aspect, radii);
@@ -1741,20 +1572,13 @@ const Render = {
//
// So a name that overflows drops whole trailing words until it fits: "Mike from
// work" becomes "Mike from", then "Mike", and stops there with a real word rather
// than a fragment.
// than a fragment. A single word that still doesn't fit is left to the ellipsis it
// always had — there is no honest way to shorten "Christopher" that a dealer
// glancing across a table would read faster than "Christo…".
//
// What is left when there is no word to drop is one word too long for its puck, and
// the ellipsis used to be the end of the story: "Christo…", which is a name only
// until a Christine sits down. The amounts on the same puck have never accepted that
// trade — see fitAmount, which steps a tournament stack's type down rather than
// rounding it off — and a misread name is the same kind of failure as a misread
// stack. So the last resort before the ellipsis is one step of type. Every name that
// has one, not only the ones with a spare word, because "Christopher" is exactly the
// case this is for.
//
// Written as passes over all the seats rather than a loop per seat: each pass writes
// to every puck and then reads back from every puck, so the browser lays the felt out
// a fixed number of times whatever the table's size. Interleaved, nine seats would
// Written as three passes over all the seats rather than a loop per seat: each pass
// writes to every puck and then reads back from every puck, so the browser lays the
// felt out three times whatever the table's size. Interleaved, nine seats would
// have cost nine layouts, and this runs on every render.
fitSeatNames(layer) {
if (!layer) return;
@@ -1765,37 +1589,21 @@ const Render = {
if (!el || !el.textContent) return;
const full = (player.name || "").trim();
// The tooltip is the whole name whether or not this one gets shortened: a
// label clamped to its one line was already hiding some of it.
// label that wraps to two clamped lines was already hiding some of it.
el.title = full;
// Put back whatever a previous run of this took off, so what gets measured
// below is the name against the puck it has now. Normally a no-op —
// updatePuckContent has just written the full name — and not a no-op on the
// second pass of a render that had to resolve the ring twice, which is the
// one time a puck's width changes underneath a name already fitted to it.
if (el.textContent !== full) el.textContent = full;
// Cleared before anything is measured, so a name that fitted last render at
// a smaller size is measured at the size it is meant to be read at. Left on,
// it would be a ratchet: once a puck went small it could never come back.
el.classList.remove("puckNameTight");
labels.push({ el, words: full.split(/\s+/).filter(Boolean), kept: 0 });
const words = full.split(/\s+/).filter(Boolean);
if (words.length > 1) labels.push({ el, words, kept: words.length });
});
if (!labels.length) return;
labels.forEach(label => { label.kept = label.words.length; });
for (let pass = 0; pass < this.SEAT_NAME_PASSES; pass++) {
const shorten = labels.filter(label => label.kept > 1 && this.overflowing(label.el));
if (!shorten.length) break;
if (!shorten.length) return;
shorten.forEach(label => {
label.kept = this.dropAWord(label.words, label.kept);
label.el.textContent = label.words.slice(0, label.kept).join(" ");
});
}
// One read for the whole table, then one write, in that order — the same reason
// the loop above is written in passes.
labels
.filter(label => this.overflowing(label.el))
.forEach(label => label.el.classList.add("puckNameTight"));
},
// Words a name can't end on. Dropping "work" off "Mike from work" leaves "Mike
@@ -1824,9 +1632,9 @@ const Render = {
// four plain words reaches its first in three.
SEAT_NAME_PASSES: 3,
// Past the box in either direction. Width is the one that answers now that a name
// cannot wrap — see .seatPuckName — and height is kept because it costs nothing and
// is the honest question for anything else that ever asks this.
// Past the box in either direction. Both matter: the label clamps to two lines, so
// a name too long runs out of height, while a single word too wide for the puck
// runs out of width without ever reaching the second line.
overflowing(el) {
return el.scrollHeight > el.clientHeight + 1 || el.scrollWidth > el.clientWidth + 1;
},
@@ -1942,27 +1750,11 @@ const Render = {
if (onStrip) {
if (strip && row.parentElement !== strip) strip.appendChild(row);
if (strip) strip.hidden = false;
if (centre) { centre.style.width = ""; centre.style.maxWidth = ""; centre.style.maxHeight = ""; }
if (centre) { centre.style.maxWidth = ""; centre.style.maxHeight = ""; }
} else {
if (centre && row.parentElement !== centre) centre.insertBefore(row, centre.firstChild);
if (strip) strip.hidden = true;
if (centre) {
// Both, and the width is the half that was missing.
//
// `max-width` caps a box; it does not claim anything. The centre is a
// flex column that sizes to its content, so with 463px of measured room
// written into its max-width it went on being 136px wide — and the board
// row inside it is `width: 100%` of that, which is a loop that comes to
// rest on the cards' own `min-width`. Measured with the board on: the
// middle had 294px on an iPad, 463px on a desktop and 542px on a wide
// one, and in all three the five community cards drew 24px across. On
// the strip, where the row simply gets the width of the page, the same
// cards draw 44. They are buttons — a tap on one opens the picker for
// that card — so 24px was a little over half the floor this sweep holds
// every other control on the felt to.
centre.style.width = `${Math.round(width)}px`;
centre.style.maxWidth = `${Math.round(width)}px`;
}
if (centre) centre.style.maxWidth = `${Math.round(width)}px`;
}
// The chips were laid out against whatever was in the middle of the felt a
@@ -2153,11 +1945,7 @@ const Render = {
: HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount);
const totalEl = node.querySelector(".seatPuckTotal");
totalEl.textContent = total > 0 ? Money.format(total) : "";
// The figure as it is meant to be read, kept beside the one that gets drawn.
// fitAmounts may take the separators out of what is on screen, and the pass
// after it has to start from the grouped figure again or the puck can only ever
// get further from it.
totalEl.dataset.figure = totalEl.textContent;
this.fitAmount(totalEl);
// A player with nothing behind used to leave this line blank, so the felt drew
// an all-in exactly like a seat whose stack nobody had entered. On a table with
@@ -2168,7 +1956,7 @@ const Render = {
const allIn = Betting.isAllIn(this.state, player);
stackEl.textContent = empty ? "" : allIn ? "ALL IN" : stack > 0 ? Money.format(stack) : "";
stackEl.classList.toggle("seatPuckAllIn", allIn);
stackEl.dataset.figure = stackEl.textContent;
this.fitAmount(stackEl);
node.classList.toggle("seatAllIn", allIn);
node.classList.toggle("seatActing", index === this.state.actingSeatIndex);
@@ -2178,90 +1966,35 @@ const Render = {
node.classList.toggle("seatLive", player.inHand && !player.folded);
},
// The two steps of type an amount can come down, in order, once taking the
// separators out has not been enough.
AMOUNT_STEPS: ["puckAmountLong", "puckAmountHuge"],
// Puts one amount back to the figure and the size it is meant to be read at, and
// hands back the grouped figure. Everything below measures from here, so a puck that
// once went small can come back — left as it was, this would be a ratchet.
resetAmount(el) {
const figure = el.dataset.figure === undefined ? el.textContent : el.dataset.figure;
if (el.textContent !== figure) el.textContent = figure;
this.AMOUNT_STEPS.forEach(step => el.classList.remove(step));
return figure;
},
// A seat puck is 64px of usable width, and 34 on the tightest ring in the sweep. A
// tournament stack doesn't fit in that and, because a number has no spaces to break
// on, it doesn't wrap or ellipsise either — it draws straight out over the seat next
// to it. Stepping the type down keeps every digit, which matters: a dealer misreading
// a stack is a worse outcome than a small one, so this shrinks rather than
// abbreviating.
//
// Measured, and it used to be counted. The old rule was eight characters for one
// step and ten for the other, which is a guess at how wide a figure is that happens
// to hold at one text size and one puck width. Turn a phone's text up — 20px of root
// text is what an Android slider gives you at 125%, and every size in this
// stylesheet is in `rem` on purpose — and it stops holding: measured at 360x640
// twelve-handed, "$1,200" is six characters, took no step at all, and wanted 38px of
// a 34px puck. The sweep caught it as a stack figure painted over the seat next door
// at every phone in the list. What the puck can hold is a question about the puck,
// so it is asked of the puck.
//
// Written as passes over every amount on the ring rather than a loop per seat: each
// pass writes to all of them and then reads back from all of them, so the browser
// lays the felt out four times whatever the table's size. Interleaved, twelve seats
// would have cost twenty-four layouts, and this runs on every render.
fitAmounts(cells) {
const live = cells.filter(el => el && (el.dataset.figure || el.textContent));
if (!live.length) return;
const figures = new Map(live.map(el => [el, this.resetAmount(el)]));
// The separators go before the type size does. "$1,234,567" is ten characters,
// which used to drop the puck's figures to 7px — and 7px is not a size anybody
// reads a stack off a felt at, so the shrink-rather-than-abbreviate rule above
// was buying a precision the dealer then couldn't see. Without its commas the
// same number is eight characters and fits two steps higher up this ladder.
// Grouping is a reading aid for a column of figures; this is one number on a
// 44px puck, and there the aid costs more than it gives.
const ungroup = live.filter(el => figures.get(el).includes(",") && this.overflowing(el));
ungroup.forEach(el => { el.textContent = figures.get(el).replace(/,/g, ""); });
let carried = null;
this.AMOUNT_STEPS.forEach(step => {
const over = live.filter(el => this.overflowing(el));
const previous = carried;
over.forEach(el => {
if (previous) el.classList.remove(previous);
el.classList.add(step);
});
carried = step;
});
},
// One puck's worth — for refreshPuck, which redraws a single seat.
// A seat puck is 64px of usable width. A tournament stack doesn't fit in that and,
// because the number has no spaces to break on, it doesn't wrap or ellipsise
// either — it just draws straight out over the neighbouring seat. Stepping the
// font down keeps every digit, which matters: a dealer misreading a stack is a
// worse outcome than a small one, so this shrinks rather than abbreviating.
fitAmount(el) {
this.fitAmounts([el]);
},
const formatted = el.textContent || "";
// The separators go before the type size does. "$1,234,567" is ten characters,
// which used to drop the puck to 7px — and 7px is not a size anybody reads a
// stack off a felt at, so the shrink-rather-than-abbreviate rule above was
// buying precision the dealer then couldn't see. Without its commas the same
// number is eight characters, and eight fits at 10px with every digit intact.
// Grouping is a reading aid for a column of figures; this is one number on a
// 64px puck, and the aid costs more than it gives.
const text = formatted.length >= 10 ? formatted.replace(/,/g, "") : formatted;
if (text !== formatted) el.textContent = text;
// Every amount on the ring, so fitAmounts can do the whole table in four layouts.
seatAmountCells(layer) {
const cells = [];
for (let index = 0; index < layer.childElementCount; index++) {
const node = layer.children[index];
cells.push(node.querySelector(".seatPuckTotal"), node.querySelector(".seatPuckStack"));
}
return cells;
const length = text.length;
el.classList.toggle("puckAmountLong", length >= 8 && length < 10);
// Only a stack of $100,000,000 or more still lands here. It is an overflow
// guard rather than a size anybody is expected to read.
el.classList.toggle("puckAmountHuge", length >= 10);
},
// Cheaper than a full renderSeats() — used after edits so the seat editor's inputs
// (which live in a separate panel) never lose focus.
refreshPuck(index) {
const node = document.querySelector(`.seat[data-seat-index="${index}"]`);
if (!node) return;
this.updatePuckContent(node, this.state.seats[index], index);
this.fitAmounts([node.querySelector(".seatPuckTotal"), node.querySelector(".seatPuckStack")]);
if (node) this.updatePuckContent(node, this.state.seats[index], index);
},
// ---- Seat gestures ----
@@ -2423,9 +2156,7 @@ const Render = {
const index = this.selectedSeatIndex;
const player = this.state.seats[index];
// Named and numbered. The chair number came off the puck's face this round —
// see HoldemState.seatHeading — and this is one of the two places it went.
title.textContent = HoldemState.seatHeading(player, index);
title.textContent = this.puckLabel(player, index);
body.innerHTML = "";
const template = document.getElementById("seatEditorTemplate");
@@ -2522,65 +2253,6 @@ const Render = {
this.renderPanelGlances();
},
// The three things a seat can be, in the order a hand goes through them. `folded`
// is only meaningful while `inHand` is set, which is why these are one choice and
// not two: the pair (out, folded) has no meaning, and the old two-checkbox control
// could be asked for it.
SEAT_STATES: [
{ key: "out", label: "Sitting out", inHand: false, folded: false },
{ key: "in", label: "In the hand", inHand: true, folded: false },
{ key: "folded", label: "Folded", inHand: true, folded: true },
],
seatStateOf(player) {
if (!player.inHand) return "out";
return player.folded ? "folded" : "in";
},
// Draw the current answer onto the three buttons. Called on every paint and again
// whenever something other than a press changes the seat — typing a name into an
// empty chair seats somebody, and the control has to say so.
paintSeatState(form, player) {
const current = this.seatStateOf(player);
form.querySelectorAll(".seatStateOption").forEach(btn => {
const chosen = btn.dataset.seatState === current;
btn.setAttribute("aria-checked", chosen ? "true" : "false");
// One Tab stop for the three, the way the APG asks: Tab reaches the group,
// the arrows move inside it.
btn.tabIndex = chosen ? 0 : -1;
});
},
wireSeatState(form, player, index) {
const options = [...form.querySelectorAll(".seatStateOption")];
const choose = (key, { focus = false } = {}) => {
const state = this.SEAT_STATES.find(entry => entry.key === key);
if (!state) return;
player.inHand = state.inHand;
player.folded = state.folded;
this.paintSeatState(form, player);
if (focus) {
const chosen = options.find(btn => btn.dataset.seatState === key);
if (chosen) chosen.focus({ preventScroll: true });
}
this.commitSeatChange(form, player, index);
};
options.forEach(btn => {
btn.addEventListener("click", () => choose(btn.dataset.seatState));
btn.addEventListener("keydown", event => {
const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[event.key];
if (step === undefined) return;
event.preventDefault();
const at = this.SEAT_STATES.findIndex(entry => entry.key === this.seatStateOf(player));
const to = (at + step + this.SEAT_STATES.length) % this.SEAT_STATES.length;
choose(this.SEAT_STATES[to].key, { focus: true });
});
});
this.paintSeatState(form, player);
},
wireSeatFields(form, player, index) {
const nameInput = form.querySelector(".seatNameInput");
nameInput.value = player.name;
@@ -2599,13 +2271,32 @@ const Render = {
if (wasEmpty && player.name.trim()) {
player.inHand = true;
player.folded = false;
this.paintSeatState(form, player);
const inHandBox = form.querySelector(".inHandCheckbox");
if (inHandBox) inHandBox.checked = true;
const foldBox = form.querySelector(".foldCheckbox");
if (foldBox) { foldBox.checked = false; foldBox.disabled = false; }
}
document.getElementById("seatEditorTitle").textContent = this.puckLabel(player, index);
this.commitSeatChange(form, player, index);
});
this.wireSeatState(form, player, index);
const inHandCheckbox = form.querySelector(".inHandCheckbox");
inHandCheckbox.checked = player.inHand;
inHandCheckbox.addEventListener("change", () => {
player.inHand = inHandCheckbox.checked;
if (!player.inHand) player.folded = false;
foldCheckboxRef.checked = player.folded;
foldCheckboxRef.disabled = !player.inHand;
this.commitSeatChange(form, player, index);
});
const foldCheckboxRef = form.querySelector(".foldCheckbox");
foldCheckboxRef.checked = player.folded;
foldCheckboxRef.disabled = !player.inHand;
foldCheckboxRef.addEventListener("change", () => {
player.folded = foldCheckboxRef.checked;
this.commitSeatChange(form, player, index);
});
const slots = [form.querySelector(".card1Slot"), form.querySelector(".card2Slot")];
slots.forEach((slot, cardIdx) => {
@@ -2630,11 +2321,7 @@ const Render = {
const stackInput = form.querySelector(".stackInput");
stackInput.value = player.stack ?? "";
stackInput.addEventListener("input", () => {
// Null for empty *and* for anything that isn't a number — the box is
// type="text" now, so a stray letter arrives here rather than being eaten
// by the control, and a seat holding NaN chips is worse than one holding
// none. See Money.readAmount.
player.stack = Money.readAmount(stackInput.value);
player.stack = stackInput.value === "" ? null : Number(stackInput.value);
this.commitSeatChange(form, player, index);
});
@@ -2689,21 +2376,8 @@ const Render = {
const valueInput = row.querySelector(".customFieldValue");
labelInput.value = field.label;
valueInput.value = field.value;
// Same as the chip legend's remove button, and for the same reason: "×" is
// the whole of this control, so unnamed it announces as a button and nothing
// more, in a column of others exactly like it. The label the dealer typed is
// what tells them apart, and it changes as it is typed.
const removeBtn = row.querySelector(".removeFieldBtn");
const nameRemove = () => {
const typed = labelInput.value.trim();
removeBtn.setAttribute("aria-label", typed ? `Remove "${typed}"` : "Remove this note");
};
nameRemove();
labelInput.addEventListener("input", () => {
field.label = labelInput.value;
nameRemove();
this.hooks.save(this.state);
this.renderSeatDisclosures(form, player);
});
@@ -2712,7 +2386,7 @@ const Render = {
this.hooks.save(this.state);
this.renderSeatDisclosures(form, player);
});
removeBtn.addEventListener("click", () => {
row.querySelector(".removeFieldBtn").addEventListener("click", () => {
player.customFields.splice(fieldIdx, 1);
this.renderCustomFields(form, player, index);
this.hooks.save(this.state);
@@ -2724,34 +2398,7 @@ const Render = {
// ---- Chip legend ----
// The legend itself: what each colour is worth, read left to right.
//
// The panel has always been named for this and has never shown it — what it showed
// was the form for setting it, five rows deep, with the values in number boxes. A
// dealer wanting to know what the green one is worth had to read it out of an input.
// This is the same five facts in two lines, and it sits above the form now.
renderChipLegendStrip() {
const strip = document.getElementById("chipLegendStrip");
if (!strip) return;
strip.innerHTML = "";
(this.state.chipLegend || []).forEach(chip => {
const item = document.createElement("li");
item.className = "chipLegendChip";
const dot = document.createElement("span");
dot.className = "chipSwatch";
dot.style.background = chip.color;
const worth = document.createElement("span");
worth.textContent = Money.format(Number(chip.value) || 0);
item.appendChild(dot);
item.appendChild(worth);
strip.appendChild(item);
});
},
renderChipLegend() {
this.renderChipLegendStrip();
const list = document.getElementById("chipLegendList");
list.innerHTML = "";
const template = document.getElementById("chipLegendRowTemplate");
@@ -2780,18 +2427,10 @@ const Render = {
colorInput.addEventListener("input", () => {
chip.color = colorInput.value;
this.hooks.save(this.state);
// The strip above is the same fact this field is editing, so it keeps up
// with it. Only the strip, still — but for cost now rather than for
// safety: setField refuses to write into a focused control, so the
// mid-keystroke clobber this was dodging can no longer happen from any
// direction. Naming the two renders that matter is simply cheaper than
// laying the felt out again on every keystroke.
this.renderChipLegendStrip();
});
valueInput.addEventListener("input", () => {
chip.value = Number(valueInput.value) || 0;
nameRemove();
this.renderChipLegendStrip();
this.hooks.save(this.state);
this.renderSeats();
this.renderSeatEditor();
@@ -2800,8 +2439,8 @@ const Render = {
// legend's own summary is on it: shut, the panel reads "5 colours ·
// $1–$500", and re-pricing the top chip is exactly the edit that
// changes it. Called here for the same reason as the rest of the list:
// it is what this edit actually moves, and renderAll would lay the whole
// felt out again on every keystroke to find that out.
// renderAll would write this field's own value back into it mid-
// keystroke.
//
// Only that one. The bank check is not downstream of this, which is
// worth saying because it looks as though it should be — a seat's chip
@@ -3401,13 +3040,7 @@ const Render = {
const emptyHint = document.getElementById("handHistoryEmptyHint");
const history = this.state.handHistory || [];
emptyHint.hidden = history.length > 0;
// This one was greyed out with no explanation anywhere — not even a tooltip —
// so on every device it was a button that did nothing and said nothing about it.
Unavailable.set(
document.getElementById("exportHistoryBtn"),
history.length === 0,
"No hands to export yet — the file fills in as the night is played."
);
document.getElementById("exportHistoryBtn").disabled = history.length === 0;
const query = this.historyFilter || "";
const shown = query ? history.filter(entry => this.handMatchesFilter(entry, query)) : history;
@@ -1,26 +1,6 @@
// The long-press menu on a seat. Everything a dealer does to one player mid-hand,
// one press and one tap away, without opening the full editor and without hunting
// for whose turn it is in the action bar.
//
// It grew to eleven of those, all drawn the same: eleven full-width bars filling 85%
// of every screen it was measured on, scrolling on all of them, with Cancel below the
// fold on all of them too — 252px of it hidden on a 360px phone, 448px in landscape.
// Apple's action-sheet guidance puts the comfortable number at three to five and says
// not to let one scroll at all, and a dealer folding a player mid-hand is reading
// eleven choices to find the one they came for.
//
// The eleven have not been cut — every one of them is the only route to something, and
// "Blinds follow the button again" is the only route in the whole tool to clearing an
// override. They are sorted by *when* instead. A seat action is either something you
// do while the cards are in the air, or housekeeping between hands, and those two want
// different targets: the first wants a bar you can hit without looking, the second
// wants to be findable and out of the way. So the hand keeps the full-width bars and
// everything else goes to a two-column grid underneath it.
//
// The captions are not decoration. A half-width tile reading "Small blind" is a label,
// and the puck already wears one of those; under "Give this seat" it is a verb again.
// That is what lets three of the longest labels here lose ten characters each and
// still fit a half tile on one line, which is the height the caption pays back.
const SeatMenu = {
state: null,
hooks: null,
@@ -28,14 +8,6 @@ const SeatMenu = {
mode: null,
// The two groups that sit under the hand, in the order they are drawn. `hand` has
// no caption because it needs none — it is the top of the sheet, and its actions
// are the two the sheet is coloured for.
GROUPS: [
{ name: "player", caption: "This player" },
{ name: "position", caption: "Give this seat" },
],
open(state, hooks, seatIndex) {
this.state = state;
this.hooks = hooks;
@@ -54,68 +26,50 @@ const SeatMenu = {
// Only the actions that make sense for this seat right now — a folded player has
// nothing to go all-in with, and an empty chair has nothing to fold.
//
// `group` is when you do it, not what it touches: "hand" is what a dealer reaches
// for with cards on the table, and it is the only group drawn at full width.
actionsFor(player, index) {
const actions = [];
const dealtIn = player.inHand;
const live = dealtIn && !player.folded;
if (live) {
actions.push({ group: "hand", label: "Fold", className: "menuDanger", run: () => this.hooks.fold(index) });
actions.push({ group: "hand", label: "All-in", className: "menuAccent", run: () => this.hooks.allIn(index) });
actions.push({ label: "Fold", className: "menuDanger", run: () => this.hooks.fold(index) });
actions.push({ label: "All-in", className: "menuAccent", run: () => this.hooks.allIn(index) });
} else if (dealtIn && player.folded) {
actions.push({ group: "hand", label: "Un-fold", run: () => this.hooks.unfold(index) });
actions.push({ label: "Un-fold", run: () => this.hooks.unfold(index) });
}
// The most common interruption in a cash game, and until now it meant opening
// the editor and retyping a stack from memory.
actions.push({ label: "Add chips…", run: () => this.hooks.addChips(index) });
actions.push({
label: dealtIn ? "Sit out" : "Deal in",
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.
//
// In the hand group rather than with the housekeeping: the queue moves when a
// seat opens, which is between two hands with people watching, not later.
const waiting = (this.state.waitingList || [])[0];
const emptyChair = !HoldemState.seatInPlay(player);
if (waiting && emptyChair) {
actions.push({
group: "hand",
label: `Seat ${waiting.name}`,
className: "menuAccent",
run: () => this.hooks.seatWaiting(waiting.id, index),
});
}
// The most common interruption in a cash game, and until now it meant opening
// the editor and retyping a stack from memory.
actions.push({ group: "player", label: "Add chips…", run: () => this.hooks.addChips(index) });
actions.push({
group: "player",
label: dealtIn ? "Sit out" : "Deal in",
run: () => this.hooks.toggleDealtIn(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({ group: "player", label: "Cash out…", run: () => this.hooks.cashOut(index) });
actions.push({ label: "Cash out…", run: () => this.hooks.cashOut(index) });
}
// 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({ group: "player", label: "Move to…", run: () => this.openMovePicker() });
}
actions.push({ group: "player", label: "Edit seat…", run: () => this.hooks.editSeat(index) });
if (!this.state.dedicatedDealer && index !== this.state.dealerSeatIndex) {
actions.push({ group: "position", label: "The button", run: () => this.hooks.makeDealer(index) });
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 —
@@ -126,25 +80,25 @@ const SeatMenu = {
if (dealtIn && Betting.onlyBlindsIn(this.state)) {
const blinds = HoldemState.getBlindSeats(this.state);
if (blinds.sbIndex !== index) {
actions.push({ group: "position", label: "Small blind", run: () => this.hooks.assignBlind(index, "sb") });
actions.push({ label: "Make small blind", run: () => this.hooks.assignBlind(index, "sb") });
}
if (blinds.bbIndex !== index) {
actions.push({ group: "position", label: "Big blind", run: () => this.hooks.assignBlind(index, "bb") });
actions.push({ label: "Make big blind", run: () => this.hooks.assignBlind(index, "bb") });
}
// Not about this seat at all — it puts the whole table back on automatic —
// so it spans the grid instead of sitting in it as a fourth peer. It is
// still here because this menu is the only place in the tool that offers
// it, and it belongs beside the two overrides that caused it.
if (this.state.blindOverride) {
actions.push({
group: "position",
label: "Blinds follow the button again",
wide: true,
run: () => this.hooks.clearBlindOverride(),
});
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;
},
@@ -162,117 +116,48 @@ const SeatMenu = {
this.render();
},
closeMovePicker() {
this.mode = null;
this.render();
},
button(label, className) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = className;
btn.textContent = label;
return btn;
},
// A row of tiles. Two columns on a phone: a seat action's label is a verb and a
// short one, and stacking nine of them full width was most of the height the sheet
// was spending.
grid(extra) {
const el = document.createElement("div");
el.className = `seatMenuGrid ${extra || ""}`.trim();
return el;
},
// The two tiers, as boxes rather than as a run of siblings.
//
// Upright they are what they always were: one column, in the order they are
// written. The wrappers exist for the phone on its side, where the sheet has 306px
// of height and 616 of width and the tiers stop being two halves of a column and
// become two columns — see the `max-height: 560px` block in holdem.css. That is
// only expressible if each tier is one box the layout can address; as five loose
// children they can only ever stack.
tier(name) {
const el = document.createElement("div");
el.className = name;
return el;
},
caption(text) {
const el = document.createElement("p");
el.className = "seatMenuCaption";
el.textContent = text;
return el;
},
// Stepping into the move picker throws the whole list away, and the first thing it
// throws away is the "Move this seat" the dealer just pressed — which left focus on
// the body, inside a sheet that had just changed into a different sheet. The
// heading is where to be: in move mode it has stopped saying who this is and
// started saying what is being asked — "Move Alexandra to…".
render() {
Modal.preservingFocus(document.getElementById("seatMenuOverlay"), () => this.paint(), () => {
const title = document.getElementById("seatMenuTitle");
if (title) title.focus({ preventScroll: true });
});
},
paint() {
const player = this.state.seats[this.seatIndex];
const title = document.getElementById("seatMenuTitle");
const subtitle = document.getElementById("seatMenuSubtitle");
const list = document.getElementById("seatMenuActions");
const cancel = document.getElementById("seatMenuCancelBtn");
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.";
// Opening the picker used to be a one-way door: it replaced the sheet, and
// the only button left closed the whole menu. The sheet's own back is the
// button already at the thumb end, so it says Back while there is somewhere
// to go back to.
cancel.textContent = "Back";
const keeping = this.tier("seatMenuKeeping");
// Chairs, not verbs: "Seat 3 — Alexandra" sets 116px where "Sit out" sets
// about 50, so this grid asks for a wider column than the tiles do.
const grid = this.grid("seatMenuGridNames");
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();
const btn = this.button(
named ? `Seat ${seat + 1} — ${named}` : `Seat ${seat + 1} — empty`,
"seatMenuBtn menuTile");
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);
});
grid.appendChild(btn);
list.appendChild(btn);
});
keeping.appendChild(grid);
list.appendChild(keeping);
return;
}
cancel.textContent = "Cancel";
title.textContent = HoldemState.displayName(player, this.seatIndex);
const stack = Betting.stackOf(this.state, player);
const owed = Betting.toCall(this.state, player);
// Which chair this is leads the line. It came off the puck's face this round —
// see HoldemState.seatHeading — and this is the other place it went. It goes in
// the subtitle rather than the heading because the heading is a name and names
// are long: this header is stuck to the top of the sheet that scrolls, and a
// second line of h2 is height every action below it pays for.
const bits = [`Seat ${this.seatIndex + 1}`];
const bits = [];
if (stack > 0) bits.push(`${Money.format(stack)} behind`);
if (owed > 0) bits.push(`${Money.format(owed)} to call`);
subtitle.textContent = bits.join(" · ");
const actions = this.actionsFor(player, this.seatIndex);
const wire = (btn, action) => {
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.
@@ -283,37 +168,8 @@ const SeatMenu = {
this.close();
action.run();
});
};
const hand = this.tier("seatMenuHand");
actions.filter(a => a.group === "hand").forEach(action => {
const btn = this.button(action.label, `seatMenuBtn ${action.className || ""}`.trim());
wire(btn, action);
hand.appendChild(btn);
list.appendChild(btn);
});
// An empty tier is left out rather than drawn empty — a folded player in a game
// with nobody waiting has nothing in the air to do, and the sideways layout
// reads the tier's absence to know it has one column rather than two.
if (hand.childElementCount) list.appendChild(hand);
const keeping = this.tier("seatMenuKeeping");
this.GROUPS.forEach(({ name, caption }) => {
const inGroup = actions.filter(a => a.group === name);
// A caption for nothing is worse than no caption. A group empties often —
// past the blinds there is nothing to give a seat but the button.
if (inGroup.length === 0) return;
keeping.appendChild(this.caption(caption));
const grid = this.grid();
inGroup.forEach(action => {
const btn = this.button(action.label,
`seatMenuBtn menuTile ${action.wide ? "menuTileWide" : ""} ${action.className || ""}`
.replace(/\s+/g, " ").trim());
wire(btn, action);
grid.appendChild(btn);
});
keeping.appendChild(grid);
});
if (keeping.childElementCount) list.appendChild(keeping);
},
};
@@ -30,9 +30,7 @@ const Shortcuts = {
{ 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" },
// Both undos, because there are two and a dealer has no reason to know that:
// the bar's pill takes back a bet, and the toast's takes back a whole step.
{ keys: ["U"], action: "undo", what: "Take back the last thing you did" },
{ 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" },
],
@@ -476,23 +476,6 @@ const HoldemState = {
return player.name && player.name.trim() ? player.name.trim() : `Seat ${seatIndex + 1}`;
},
// The same seat, named and numbered, for the two sheets that are about one seat.
//
// The chair number used to be a grey digit in the corner of the puck, and the corner
// of the puck is where the blind badge lands and where a long name reaches: measured
// across 348 seats on 36 tables, 117 of them had it printed under something else —
// every seat posting a blind, and one in seven with it sitting on the first letter of
// the player's own name. The felt has no free corner to move it to, so it stopped
// being on the felt. These two headings are where a dealer goes when the question is
// about one seat, and they answer it now.
//
// An unnamed seat is already called "Seat 5" by displayName, so it doesn't say it
// twice.
seatHeading(player, seatIndex) {
const name = player && player.name ? player.name.trim() : "";
return name ? `${name} · Seat ${seatIndex + 1}` : `Seat ${seatIndex + 1}`;
},
// Contribution the table calculator will treat this player as having put in.
//
// Chips already pushed into the middle count whether or not the player is still
@@ -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);
});
}
-47
View File
@@ -1017,53 +1017,6 @@ lose. Chromium and Safari answer silently on their own engagement heuristics; Fi
asks. A no is not an error worth reporting at a table, so the only thing that changes is
how confident Save & reset is allowed to sound, and whether it offers the ask as a button.
## The sweep had never once been run at a size anybody sets
Every length in `holdem.css` that isn't a structural clearance is in `rem`. That is the
right way round: it means the whole page follows the browser's font size, which is what
a phone's text-size slider changes. It also means the page had a whole axis of behaviour
that 320 audits had never touched, because every one of them ran at the default 16px.
A third pass now runs the same surfaces on the eight phone-shaped viewports with the
root at **20px** — the 125% an Android accessibility slider gives you. It found four
things on its first run, and every check that caught them was already written:
- **The page slid sideways.** `#holdemHeaderActions` was `flex-wrap: nowrap` with three
`white-space: nowrap` labels inside it, so when they stopped fitting they had nowhere
to go: "Resolve Pot" ended 23px past the right edge of a 360px window and put a
horizontal scrollbar under the whole document. At 24px it was 60px past the edge of a
390px one. `page never scrolls sideways` had been in the audit for months and had never
had anything to say.
- **Figures painted over the seat next door.** Which of the two type steps an amount got
was decided by *counting characters* — eight for one, ten for the other — which is a
guess at how wide a figure is that happens to hold at one text size and one puck width.
At 20px, "$1,200" is six characters, took no step at all, and wanted 38px of a 34px
puck. `Render.fitAmounts` measures now, in the same batched passes `fitSeatNames` uses,
and takes the separators out before it takes a step of type.
- **Pucks grew and the ring could not answer.** The row heights are `px` tokens so that
an amount stepping its type down shortens its digits and not its row — but a token is
a floor, not a ceiling, and text bigger than its row makes the row taller. The puck
went from 55px to 65 and twelve of them came down on each other, because at the 44px
floor there is nowhere narrower for the search to go. The two sizes a puck is written
at are capped at exactly the sizes they have always been drawn at, so nothing changes
at an ordinary text size; the felt is a diagram, and the rule that already governed a
puck's width now governs its height. `.seatsRoomy` is exempt — those pucks have room.
- **A check that was crying wolf.** `no control is parked where scrolling can't reach it`
flagged the raise pad's Cancel and Raise-to in the landscape rail. The rail is
`overflow-y: auto` on purpose and they were one flick away, so the message was untrue;
it now asks whether a scrollable ancestor can bring the control back. What was true is
that the rail opened at its *top*, so the overflow came off the bottom — where the two
buttons that end a raise are. It opens at the end now, which is the same reason it is
anchored low in the first place.
One shape it is not held to, with the arithmetic in `runUiAudit.js`: twelve players
all-in on a 360x640 phone. The action bar is at its tallest there — 283px of the 640 at
20px — and with the header that leaves the felt 290x163 where it had 296x262. Twelve
pucks 44px wide (the floor a thumb needs) and 62px tall do not fit around an ellipse
163px deep. That is a geometry with no answer rather than a fault with a fix, so it is
reported as a note every run instead of a failure every run — a sweep that is always red
stops being read.
## Why the hand evaluator is tested the way it is
`handRank.js` is the only module here that can pay the wrong person without anybody
+12 -92
View File
@@ -51,13 +51,11 @@ const valuesIn = (properties, source = body) => {
describe("the spacing scale", () => {
// Hairlines and structural clearances, each one deliberate:
// 0px/1px/3px/4px/25px — borders, hairline offsets, and the 25px of room a seat
// puck's name gives the dealer button beside it: the chip's
// own 22px plus the 3px it is inset by, which theRail's
// "as wide as the chip it stands in for" holds it to.
// 0px/1px/3px/4px/22px — borders, hairline offsets, and the 22px of room a seat
// puck's name gives the dealer button beside it.
// 5rem/9rem — how far the page has to scroll past the fixed action
// bar. Not rhythm; clearance for a thing of a known size.
const ALLOWED = new Set(["0px", "1px", "2px", "3px", "4px", "25px", "5rem", "9rem"]);
const ALLOWED = new Set(["0px", "1px", "2px", "3px", "4px", "22px", "5rem", "9rem"]);
test("every gap, margin and padding comes off the scale", () => {
const offenders = valuesIn(
@@ -79,26 +77,12 @@ describe("the spacing scale", () => {
describe("the type scale", () => {
// 16px — the size below which iOS zooms the page when a field is focused, so
// every text input states it. Not a typographic choice.
// 1.05em — a relative bump inside a component.
// 9px — the floor under the seat puck's shrink-to-fit applied to a name. It is a
// limit rather than a size: past it the word stops being legible from
// across a table and an honest ellipsis is the better answer.
// 10px, 12px — the ceilings on a seat puck's two sizes, which are the one place in
// this file where type does not follow the browser's font size. Each is
// exactly the size that row has always been drawn at, so a cap only ever
// engages when a phone's text is turned up; see the #seatsLayer token block
// for why a ring whose pucks are already at the 44px floor cannot answer a
// taller row any other way. Limits rather than sizes.
const ALLOWED = new Set(["16px", "1.05em", "9px", "10px", "12px"]);
// The seat puck's two sizes are custom properties that feed `font-size` through
// `var()`, so the sweep above walks straight past them — which is exactly where a
// raw number would end up hiding. They are held to the same scale.
const TYPE_TOKENS = ["--seatNameSize", "--seatAmountSize"];
// 1.05em, 0.44rem — relative bumps inside a component, and the shrink-to-fit for
// a six-figure stack on a 58px seat puck.
const ALLOWED = new Set(["16px", "1.05em", "0.44rem"]);
test("every font-size comes off the scale", () => {
const offenders = valuesIn(["font-size", ...TYPE_TOKENS])
.filter(v => !ALLOWED.has(v.value));
const offenders = valuesIn(["font-size"]).filter(v => !ALLOWED.has(v.value));
assert.deepEqual(offenders, [],
"use var(--fs-2xs … --fs-2xl), or add the value to ALLOWED with a reason");
});
@@ -163,82 +147,18 @@ describe("numbers that change while the dealer watches", () => {
});
});
describe("what an open chair is allowed to weigh", () => {
// An empty seat used to be drawn as a whole puck of near-black on the green: the same
// box a seated player gets, at the same weight, saying nothing. Five players
// nine-handed is four of them, and they read as holes punched in the felt rather than
// as chairs nobody is in — the eye had to sort the loud empty boxes from the loud full
// ones before it could find who was in the hand.
//
// The wash the box carried was there for one reason: the chair number's contrast
// cannot depend on whether its chair happens to sit over the brown rim, the dark green
// or the light, which is about Lc 5 across one ring. So the wash moved off the chair
// and under the number, which is the only thing an open chair has to say.
test("the chair is quieter than the player who could sit in it", () => {
const empty = css.match(/\.seat\.seatEmpty \{([^}]*)\}/);
const seat = css.match(/\n\.seat \{([^}]*)\}/);
assert.ok(empty && seat);
assert.match(seat[1], /background:\s*linear-gradient/,
"a taken seat is a puck with a ground of its own");
const alpha = empty[1].match(/background:\s*rgba\([^)]*?([0-9.]+)\s*\)/);
assert.ok(alpha, "an open chair should still be washed, only faintly");
assert.ok(Number(alpha[1]) <= 0.35,
`an open chair at ${alpha[1]} reads as a hole in the felt, not a chair`);
});
test("and it still says it is open", () => {
const empty = css.match(/\.seat\.seatEmpty \{([^}]*)\}/)[1];
assert.match(empty, /border-style:\s*dashed/,
"the dashes are what carry the meaning; they always were");
});
test("the number keeps a ground of its own, because it is the part that is read", () => {
const rule = css.match(/\.seatEmpty \.seatChairNumber \{([^}]*)\}/);
assert.ok(rule, "no ground under the one thing an open chair says");
assert.match(rule[1], /background:\s*rgba/);
assert.match(rule[1], /border-radius:\s*var\(--r-pill\)/);
});
});
describe("controls that must not resize under a thumb", () => {
test("the undo pill has a fixed width, not a content width", () => {
// It names the last action, so its label changes on every tap. Measured across
// one hand before this: 148, 110, 109, 108, 109, 110, 120.
//
// A length at every breakpoint, never a percentage: `min(10rem, 34%)` was tried
// and the percentage resolves against the row it sits in, which is sized to its
// own contents — the two chase each other, and the sweep caught the pill drawn
// 38px wide at ten viewports. A phone gets a narrower one through a media query
// instead, which is still one width at a time.
const rules = [...css.matchAll(/\.actionUndo\s*\{([^}]*)\}/g)].map(m => m[1]);
assert.ok(rules.length, ".actionUndo rule not found");
rules.filter(body => /(?<![\w-])width:/.test(body)).forEach(body =>
assert.match(body, /(?<![\w-])width:\s*([0-9.]+rem|100%)/,
"a content width would let the pill resize under the dealer's thumb"));
assert.ok(rules.some(body => /(?<![\w-])width:\s*[0-9.]+rem/.test(body)),
"the pill should still state a fixed width somewhere");
const rule = css.match(/\.actionUndo\s*\{[^}]*\}/);
assert.ok(rule, ".actionUndo rule not found");
assert.match(rule[0], /width:\s*[0-9.]+rem/);
});
test("and its label is the part that gives, never the keycap", () => {
test("and its label is the part that ellipsizes", () => {
const rule = css.match(/\.actionUndoLabel\s*\{[^}]*\}/);
assert.ok(rule, ".actionUndoLabel rule not found");
assert.match(rule[0], /overflow:\s*hidden/);
assert.match(css.match(/\.actionUndoKey\s*\{[^}]*\}/)[0], /flex:\s*none/);
});
test("and within the label, the act outlives the actor", () => {
// Three items, not one string: the arrow, who it was, and what they did. Cut as
// one string, "↶ Mike from work's fold" reads "↶ Mike from wor…" on a phone —
// everything except the thing you would be taking back. The actor is dropped
// whole instead, so the act is what survives, and only the act may ellipsize.
const lead = css.match(/\.actionUndoGlyph,\s*\n\.actionUndoLead\s*\{([^}]*)\}/);
assert.ok(lead, "the arrow and the actor should be fixed items");
assert.match(lead[1], /flex:\s*none/);
assert.match(css, /\.actionUndoLead\[hidden\]\s*\{[^}]*display:\s*none/);
const act = css.match(/\.actionUndoAct\s*\{([^}]*)\}/);
assert.ok(act, ".actionUndoAct rule not found");
assert.match(act[1], /text-overflow:\s*ellipsis/);
assert.match(act[1], /min-width:\s*0/);
assert.match(rule[0], /text-overflow:\s*ellipsis/);
});
});
+1 -12
View File
@@ -140,18 +140,7 @@ describe("what stops the oval", () => {
// The oval is a preference and the room is a fact. Whatever shape is asked for
// above, the felt gives way to the measurement rather than the other way round,
// because the thing on the other side of the cap is a seat behind the action bar.
assert.match(phoneRule, /^var\(--feltRoomPx,/,
"something other than the measured room decides how tall the felt may be");
});
test("and what it falls back on is a near guess rather than the whole window", () => {
// The fallback is only ever used for the frame before actionBar.js has measured
// anything — but that frame is one a dealer sees, because the scripts run at the
// end of the body. `100vh` was what stood here, and it says the whole window is
// free: measured at 360x640 nine-handed, the table came up 336x504 and dropped to
// 336x374 as soon as the bar was measured. See whatTheFeltIsToldFirst.
assert.doesNotMatch(phoneRule, /var\(--feltRoomPx,\s*100vh\)/);
assert.match(phoneRule, /var\(--actionBarHeight, 9rem\)/);
assert.equal(phoneRule, "var(--feltRoomPx, 100vh)");
});
test("and it is not floored, however tempting that is", () => {
-30
View File
@@ -43,31 +43,6 @@ const CardPicker = load("cardPicker");
// navigator.wakeLock — see wakeLock.test.js.
const WakeLock = load("wakeLock");
// feedback.js exports two namespaces and a third. Two of them answer here: Unavailable,
// which is nearly all logic (see theUnansweredPress), and Toast, whose timer and focus
// bookkeeping ask little enough of a document that a stand-in can answer (see
// theWayBack). All three are also set as globals, the way the browser hands them to
// render.js and cardPicker.js.
const { Toast, Dialog, Unavailable } = require(path.join(SRC, "feedback.js"));
globalThis.Toast = Toast;
globalThis.Dialog = Dialog;
globalThis.Unavailable = Unavailable;
// Modal is DOM behaviour through and through, but two pieces of it answer here: the
// list of what counts as a Tab stop, which is a string, and preservingFocus, which asks
// so little of a document that a stand-in can answer it. See theKeyboardInASheet.
const Modal = load("modal");
// Quick Split draws itself entirely from the DOM, but the arithmetic that decides what
// its two steppers are allowed to reach — and how many ways a pot may be chopped — is
// plain and answers here. See theWaysControl.
const QuickSplit = load("quickSplit");
// The seat menu's list — which actions a seat is offered and which group each one
// lands in — is decided before anything is drawn, so actionsFor() answers here. Only
// render() and open() touch the DOM.
const SeatMenu = load("seatMenu");
// ---- Table builders ----------------------------------------------------------
// A table with `seatCount` chairs where `seated` (a list of seat indexes) are dealt
@@ -124,11 +99,6 @@ module.exports = {
ActionBar,
CardPicker,
WakeLock,
SeatMenu,
Modal,
QuickSplit,
Toast,
Unavailable,
makeTable,
chipsOnTable,
contributions,
-132
View File
@@ -1,132 +0,0 @@
// The half of the interface the stylesheet does not draw.
//
// The browser draws the checkboxes, the menu a <select> opens, the scrollbars, the
// caret, the spellcheck underline and the autofill wash, and it draws all of them for a
// light page unless the page says otherwise. This one never did: measured,
// `color-scheme: normal` on a #141414 ground, with three checkboxes in the Table Setup
// panel rendering as bright white squares — the loudest objects on the screen, brighter
// than the gold — and two more in the seat sheet doing the same over the felt.
//
// The comment on the pot wizard's hand-painted checkbox called it "the last raw browser
// control in the tool". Six others were still the platform's, and three of those were
// still 13x13 against a 24px floor this stylesheet had already written down once and
// applied to exactly one box.
//
// Painting is the expensive way to fix that, and the pot wizard's box shows the bill:
// a tick drawn as a `background` on a pseudo-element is not a colour forced-colors mode
// knows how to replace, so it simply disappears — no visible checked state at all, on
// the dialog that pays the table. Declaring the scheme fixes six controls natively and
// costs nothing; the seventh stays painted on purpose and pays its own way.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const CSS = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
const ruleFor = selector => {
const at = CSS.indexOf(`\n${selector} {`);
assert.ok(at >= 0, `there is no \`${selector}\` rule`);
return CSS.slice(at, CSS.indexOf("}", at));
};
// Every block of a given at-rule, braces balanced, joined.
//
// This used to take the first one and stop, which held for exactly as long as there was
// one of them. A second forced-colors block was added nearer the top of the file for a
// control that dropped out of the OS palette, and the bill-paying test below started
// reading that block instead and reporting the pot wizard's answers missing — a false
// failure about a rule nobody had touched. There is no reason a stylesheet should
// answer a media query in one place, so this asks about all of them.
const atRule = query => {
const open = `@media ${query} {`;
const blocks = [];
for (let at = CSS.indexOf(open); at >= 0; at = CSS.indexOf(open, at + 1)) {
let depth = 0;
for (let i = CSS.indexOf("{", at); i < CSS.length; i++) {
if (CSS[i] === "{") depth++;
else if (CSS[i] === "}" && --depth === 0) { blocks.push(CSS.slice(at, i + 1)); break; }
}
assert.ok(blocks.length && depth === 0, `\`${open}\` never closes`);
}
assert.ok(blocks.length, `there is no \`@media ${query}\` block`);
return blocks.join("\n");
};
// ---------------------------------------------------------------------------
describe("the page says what colour it is", () => {
const root = ruleFor(":root");
test("dark, to the browser and not only to the eye", () => {
assert.match(root, /color-scheme:\s*dark/);
});
// The checked colour is the green this tool already uses for yes, so the one
// painted checkbox and the six native ones agree rather than nearly agree.
test("and the checked colour is the tool's own green, not the platform's blue", () => {
assert.match(root, /--accent-on:\s*#1e8449/);
assert.match(root, /accent-color:\s*var\(--accent-on\)/);
assert.match(ruleFor(".potWizardChoice input:checked"), /background:\s*#1e8449/);
});
});
describe("a checkbox is one size", () => {
test("every one of them, not the one that was noticed", () => {
assert.match(ruleFor('input[type="checkbox"]'), /width:\s*24px/);
assert.match(ruleFor('input[type="checkbox"]'), /height:\s*24px/);
});
test("and no box carries a size of its own any more", () => {
assert.ok(!/#dedicatedDealerToggle\s*\{/.test(CSS),
"one checkbox is still sized by id, which is how five got left behind");
assert.ok(!/width:\s*22px/.test(ruleFor(".potWizardChoice input")),
"the painted box restates its size instead of taking the one above");
});
// A stated width is only a suggestion to a flex item. The coarse-pointer block had
// already been bitten by this and said so; the mouse case had not.
test("a swatch in a crowded row keeps the width it was given", () => {
assert.match(ruleFor(".chipColorInput"), /flex:\s*none/);
});
});
describe("what painting a control costs, and who pays it", () => {
// If a future round paints another one, this is the bill it has to settle too.
test("nothing gives up the platform's drawing without answering forced colours", () => {
const forced = atRule("(forced-colors: active)");
const painted = [...CSS.matchAll(/\n([^\n{}]+)\s*\{[^}]*appearance:\s*none/g)]
.map(m => m[1].trim());
assert.ok(painted.length > 0, "nothing is painted, so this test is measuring nothing");
painted.forEach(selector => {
assert.ok(forced.includes(selector),
`\`${selector}\` drops the platform's control but has no forced-colors answer`);
});
});
// A tick drawn as a background is gone in forced-colors mode; a system keyword is
// the only colour guaranteed to survive, because it *is* the palette.
test("the tick that is drawn rather than typed survives the OS repainting it", () => {
const forced = atRule("(forced-colors: active)");
assert.match(forced, /\.potWizardChoice input::before\s*\{[^}]*background:\s*CanvasText/);
});
test("and so does the row's own chosen state, which is a gradient", () => {
const forced = atRule("(forced-colors: active)");
assert.match(forced, /\.potWizardChoice\.chosen\s*\{[^}]*outline:\s*2px solid Highlight/);
});
});
describe("the other page of the same tool", () => {
const QUICKSPLIT = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "quicksplit.css"), "utf8");
// It is the darker of the two pages and is opened from the felt mid-hand. It has no
// checkboxes to go white, but scrollbars, caret and selection were still drawn for
// a light page.
test("Quick Split says what colour it is too", () => {
assert.match(QUICKSPLIT, /:root\s*\{[^}]*color-scheme:\s*dark/);
});
});
+2 -2
View File
@@ -175,8 +175,8 @@ describe("the rows that stay quiet until they have news", () => {
});
describe("what the row is allowed to be", () => {
const ROWS = ["glanceTableSetup", "glanceWhileYouDeal", "glanceStandings",
"glanceWaiting", "glanceHands", "glanceChips", "glanceNotes"];
const ROWS = ["glanceTableSetup", "glanceStandings", "glanceWaiting",
"glanceHands", "glanceChips", "glanceNotes"];
test("every glance the sidebar renders is one this knows about", () => {
// The list drives the DOM writes, so a glance computed and never listed is a
-154
View File
@@ -1,154 +0,0 @@
// The number the pot wizard prints beside a name under the words "up to".
//
// It was the player's own remaining contribution. That is not what winning pays them,
// and on the screen that moves the money the gap was large and in the wrong direction:
// three players in for $250 each showed "up to $250" on all three rows while the prompt
// directly above said "$765 is in play" — and $765 is what the winner was actually
// handed. A dealer who trusted the row would have short-paid by two thirds. With a real
// side pot it was worse in a more confusing way: the breakdown box at the top of the
// same dialog announced "Main pot $765 · all 3", and Bob's row three lines under it
// said "up to $250".
//
// The ceiling is now what resolveRound will actually pay, and the test that matters is
// that it stays that: every case below checks the label against the function that moves
// the chips rather than against a number written out by hand.
//
// It is also printed only where it binds. A ceiling equal to everything on the table is
// the prompt's own figure repeated once per row — redundant data-ink, in Tufte's sense
// — and the rows it does appear on are exactly the hands where picking that winner
// leaves a side pot still to award, which is the one time a dealer needs telling.
const { test, describe } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("fs");
const path = require("path");
const { PotCalculator, Money } = require("./load");
const p = (id, contribution, folded = false) => ({ id, contribution, folded });
const entriesOf = (...players) => PotCalculator.buildEntries(players);
// What the wizard would hand this player if their row were the one ticked.
const paysOut = (entries, id) => PotCalculator.resolveRound(entries, [id]).payoutPool;
// ---------------------------------------------------------------------------
describe("what a row promises is what the round pays", () => {
test("a pot everybody covered pays the whole thing, dead blinds included", () => {
// Three in for $250, two blinds folded before the raise.
const entries = entriesOf(p("chris", 250), p("bob", 250), p("dee", 250),
p("sb", 5, true), p("bb", 10, true));
for (const id of ["chris", "bob", "dee"]) {
assert.equal(PotCalculator.ceilingFor(entries, id), 765);
assert.equal(PotCalculator.ceilingFor(entries, id), paysOut(entries, id));
}
});
test("the short all-in's ceiling is the main pot, not their own stake", () => {
// Bob is all in for 250; Christopher and Dee go on to 600.
const entries = entriesOf(p("chris", 600), p("bob", 250), p("dee", 600),
p("sb", 5, true), p("bb", 10, true));
assert.equal(PotCalculator.ceilingFor(entries, "bob"), 765);
assert.equal(PotCalculator.ceilingFor(entries, "chris"), 1465);
assert.equal(PotCalculator.ceilingFor(entries, "dee"), 1465);
});
// The property, over every shape the wizard can reach: a ceiling that disagreed
// with the payout would be the same defect again under a different number.
test("across every shape of pot, the ceiling is the payout", () => {
const shapes = [
[p("a", 10), p("b", 10)],
[p("a", 10), p("b", 25), p("c", 25)],
[p("a", 5), p("b", 50), p("c", 120), p("d", 120)],
[p("a", 33.33), p("b", 66.67), p("c", 100)],
[p("a", 20), p("b", 20), p("folder", 20, true)],
[p("a", 1), p("b", 2), p("c", 3), p("d", 4), p("e", 5)],
[p("short", 0.05), p("deep", 1000)],
];
shapes.forEach((shape, n) => {
const entries = entriesOf(...shape);
PotCalculator.getContenders(entries).forEach(c => {
assert.equal(PotCalculator.ceilingFor(entries, c.id), paysOut(entries, c.id),
`shape ${n}: ${c.id}`);
});
});
});
test("a second round asks about what is left, not what was there", () => {
const entries = entriesOf(p("chris", 600), p("bob", 250), p("dee", 600));
const after = PotCalculator.resolveRound(entries, ["bob"]).updatedEntries;
// Bob took $750 of the $1,450; $700 is left between the two deep stacks.
assert.equal(PotCalculator.totalPot(after), 700);
assert.equal(PotCalculator.ceilingFor(after, "chris"), 700);
assert.equal(PotCalculator.ceilingFor(after, "dee"), 700);
});
test("somebody already paid out has nothing left to be owed", () => {
const entries = entriesOf(p("a", 100), p("b", 100));
const after = PotCalculator.resolveRound(entries, ["a"]).updatedEntries;
assert.equal(PotCalculator.ceilingFor(after, "a"), 0);
});
test("a name nobody put chips in for is worth nothing rather than an error", () => {
assert.equal(PotCalculator.ceilingFor(entriesOf(p("a", 10)), "ghost"), 0);
});
});
describe("and it is only said where it binds", () => {
test("a pot everybody covered says its figure once, in the prompt", () => {
const entries = entriesOf(p("chris", 250), p("bob", 250), p("dee", 250),
p("sb", 5, true), p("bb", 10, true));
["chris", "bob", "dee"].forEach(id =>
assert.equal(PotCalculator.bindingCeilingFor(entries, id), null));
});
test("only the player who is in for less carries a number", () => {
const entries = entriesOf(p("chris", 600), p("bob", 250), p("dee", 600),
p("sb", 5, true), p("bb", 10, true));
assert.equal(PotCalculator.bindingCeilingFor(entries, "bob"), 765);
assert.equal(PotCalculator.bindingCeilingFor(entries, "chris"), null);
assert.equal(PotCalculator.bindingCeilingFor(entries, "dee"), null);
});
// Two short stacks at different heights: each is capped by the one above, so both
// rows have something to say and they do not say the same thing.
test("two all-ins at different heights each carry their own", () => {
const entries = entriesOf(p("small", 50), p("middle", 120), p("big", 400));
assert.equal(PotCalculator.bindingCeilingFor(entries, "small"), 150);
assert.equal(PotCalculator.bindingCeilingFor(entries, "middle"), 290);
assert.equal(PotCalculator.bindingCeilingFor(entries, "big"), null);
});
test("the deepest player is never the one capped", () => {
const entries = entriesOf(p("a", 10), p("b", 50), p("c", 900));
assert.equal(PotCalculator.bindingCeilingFor(entries, "c"), null);
assert.equal(PotCalculator.ceilingFor(entries, "c"), PotCalculator.totalPot(entries));
});
// Dead money is owed to whoever wins the layer it fell in, so it lifts every
// ceiling — including the short stack's, which is the whole reason Bob's row reads
// $765 rather than $750.
test("a folded player's chips lift the ceilings rather than sitting outside them", () => {
const live = entriesOf(p("a", 100), p("b", 100));
const withDead = entriesOf(p("a", 100), p("b", 100), p("folder", 40, true));
assert.equal(PotCalculator.ceilingFor(live, "a"), 200);
assert.equal(PotCalculator.ceilingFor(withDead, "a"), 240);
});
test("money in cents does not drift", () => {
const entries = entriesOf(p("a", 0.05), p("b", 0.05), p("c", 0.05));
assert.equal(PotCalculator.ceilingFor(entries, "a"), Money.round(0.15));
});
});
describe("the wizard asks the calculator rather than reaching for the stake", () => {
const SRC = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem", "potWizard.js"), "utf8");
test("the row's number comes from bindingCeilingFor", () => {
assert.match(SRC, /PotCalculator\.bindingCeilingFor\(this\.entries, c\.id\)/);
});
test("and nothing prints a contender's own stake under those words any more", () => {
assert.ok(!/up to \$\{Money\.format\(c\.remaining\)\}/.test(SRC),
"the wizard still prints the player's own contribution as their ceiling");
});
});
+29 -142
View File
@@ -28,25 +28,7 @@ const VIEWPORTS = [
const OVERLAYS = [
["rulings", () => document.getElementById("openRulingsBtn").click()],
["seat menu", () => SeatMenu.open(HoldemState.load(), {}, 0)],
// Opened the way a dealer opens it, with cards already out. This used to un-hide the
// overlay and nothing else, so the grid inside it was whatever `paint()` had last
// drawn — which on a freshly loaded page is nothing at all. For the whole life of
// this sweep the card picker was audited as an empty sheet: no slots, no fifty-two
// targets, no dealt cards, none of the three things it is made of.
["card picker", () => {
const state = HoldemState.load();
const playing = state.seats.filter(seat => seat.inHand);
if (playing[0]) playing[0].cards = ["AS", "KH"];
if (playing[1]) playing[1].cards = ["QD", "QC"];
state.board = ["7C", "2H", "TS", "", ""];
const empty = playing[2] || playing[0];
CardPicker.open(state, { onPick: () => {}, onDone: () => {} }, state.seats.indexOf(empty), 0);
// And with its answer showing. The line that says where a dealt card went is the
// one piece of this sheet that is empty until something is pressed, so pressing
// one is the only way the sweep ever sees it holding text.
const out = document.querySelector('#cardPickerGrid .pickerCard[aria-disabled="true"]');
if (out) out.click();
}],
["card picker", () => { document.getElementById("cardPickerOverlay").hidden = false; }],
["add chips", () => AddChips.open(HoldemState.load(), 0, () => {})],
["pot wizard", () => document.getElementById("resolvePotBtn").click()],
];
@@ -128,88 +110,23 @@ async function main() {
const warnings = [];
let checks = 0;
// Three passes over the same surfaces.
//
// A mouse and a thumb, because 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 has never once measured a touch target, however many viewports it visited.
//
// And a thumb on a phone whose owner has turned the text up. Every length in this
// stylesheet that isn't a structural clearance is in `rem`, which is the right way
// round and means the whole page follows the browser's font size — including out of
// the side of the window, which is what it was doing: at 20px, which is the 125%
// an Android text-size slider gives you, "Resolve Pot" ran 23px off the right of a
// 360px phone and took the document's scroll width with it. Every check in the
// audit already knew how to catch that; not one of them had ever been run at a size
// anybody actually sets.
//
// One shape it is not held to, and the arithmetic for why. Twelve players all-in on
// a 360x640 phone is the tallest the action bar ever gets — the status block and the
// pot breakdown both grow with the text — and at 20px it stands 283px of the 640,
// which with the header leaves the felt 290x163 where it had 296x262. Twelve pucks
// 44px wide (the floor a thumb needs; there is nothing narrower to go to) and 62px
// tall do not fit around an ellipse 163px deep: two per side is all the height there
// is, and the sweep reports it as seats 9, 10 and 11 written across each other.
//
// That is a geometry with no answer rather than a fault with a fix, so it is
// reported as a note every run instead of a failure every run. A sweep that is
// always red stops being read — the same reasoning as the 30% furniture warning.
//
// The packed table is the same arithmetic and was only ever passing by luck.
// Measured on the same 360x640 phone at 20px, with nobody all-in: the felt is
// 330x294, the pucks are 44x63, and five of them sit down each side — 315px of puck
// into 294px of felt, with the pitch between neighbours at 35px and 51px against a
// puck 63px tall. They overlap by construction, worst case 12px, and they always
// have. What kept the sweep green was the hit-test probes landing in the gaps
// between the glyphs; a change anywhere that moves the ring by three pixels moves
// the probes onto the overlap and the run goes red for something nothing in the
// change touched. Twice this round, from a stylesheet reset and from a sidebar
// panel getting shorter.
//
// So both twelve-handed shapes are notes at this text size rather than failures,
// for the same stated reason. Not an exemption for the felt in general: at 16px the
// same table is held to the same standard as everything else, and the two passes
// above it are unchanged.
const CROWDED_BEYOND_HELP = ["all in", "packed"];
const LARGE_TEXT_PX = 20;
const PHONES = VIEWPORTS.filter(([w, h]) => Math.min(w, h) <= 430);
const PASSES = [
{ hand: "mouse", touch: false, viewports: VIEWPORTS },
{ hand: "touch", touch: true, viewports: VIEWPORTS },
{ hand: "large text", touch: true, viewports: PHONES, rootPx: LARGE_TEXT_PX, soft: CROWDED_BEYOND_HELP },
];
for (const { hand, touch, viewports, rootPx, soft } of PASSES) {
// Twice over: once with a mouse, once with a thumb. 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 here has never once measured a touch target,
// however many viewports it visited. The phone-sized ones are exactly the pass that
// needs it.
for (const touch of [false, true]) {
const context = await browser.newContext(touch
? { hasTouch: true, isMobile: true, deviceScaleFactor: 3 }
: {});
// Before any of the page's own scripts, so its listener is registered first and
// the size is in place before main.js measures anything. A stylesheet rather
// than an inline style because that is what a browser font-size setting is: the
// root's used size, from first paint, not something applied afterwards.
if (rootPx) {
await context.addInitScript((px) => {
document.addEventListener("DOMContentLoaded", () => {
const style = document.createElement("style");
style.textContent = `html { font-size: ${px}px }`;
document.head.appendChild(style);
}, { once: true });
}, rootPx);
}
const page = await context.newPage();
const hand = touch ? "touch" : "mouse";
for (const [width, height, rawLabel] of viewports) {
for (const [width, height, rawLabel] of VIEWPORTS) {
await page.setViewportSize({ width, height });
for (const [shape, seats, players, board, shoves] of TABLES) {
const label = `${rawLabel} · ${shape} · ${hand}`;
// Measured either way; where the shape has no answer at this text size,
// said in the notes rather than counted against the run.
const record = (where, offenders) => {
if (!offenders.length) return;
if (soft && soft.includes(shape)) warnings.push(`${where}: ${offenders.join("; ")}`);
else failures.push({ where, offenders });
};
await page.goto(`${BASE}/Casino/TexasHoldem`);
await page.evaluate(seedTable, [seats, players, board]);
await page.goto(`${BASE}/Casino/TexasHoldem`);
@@ -284,7 +201,7 @@ async function main() {
const table = await page.evaluate(() => LayoutAudit.runAll());
checks++;
record(`table · ${label} ${width}x${height}`, table.failed.flatMap(f => f.offenders));
if (!table.clean) failures.push({ where: `table · ${label} ${width}x${height}`, offenders: table.failed.flatMap(f => f.offenders) });
table.warnings.forEach(w => warnings.push(`${label}: ${w}`));
// The raise pad, which is part of the bar rather than an overlay and so
@@ -307,8 +224,12 @@ async function main() {
const pad = await page.evaluate(() => LayoutAudit.runAll({
occlusionSelector: "#actionBar button, #actionBar input",
}));
record(`raise pad · ${label} ${width}x${height}`,
pad.failed.flatMap(f => f.offenders));
if (!pad.clean) {
failures.push({
where: `raise pad · ${label} ${width}x${height}`,
offenders: pad.failed.flatMap(f => f.offenders),
});
}
await page.evaluate(() => {
const cancel = [...document.querySelectorAll("#actionBar button")]
.find(b => /^Cancel/.test(b.textContent.trim()));
@@ -322,67 +243,34 @@ async function main() {
// spent already.
if (shape !== TABLES[0][0]) continue;
// The whole audit, not the one check.
//
// This ran `occlusion()` and nothing else — one rule of twenty-five — so
// for the sweep's entire life the five sheets a dealer spends half the
// night in had never been measured for touch targets, contrast, control
// names, text spill or anything else. The three rules that made that a
// reasonable thing to do are modal-aware now: two of them ask about the
// fixed bar, which is not a question while a scrim is covering it, and
// the third called every card below the fold of a sheet that scrolls an
// unreachable button.
for (const [name, open] of OVERLAYS) {
await page.evaluate(open);
await page.waitForTimeout(180);
checks++;
const result = await page.evaluate(() => {
const overlay = [...document.querySelectorAll("[id$='Overlay']")].find(o => !o.hidden);
const sheet = LayoutAudit.runAll({
occlusionSelector: overlay
? `#${overlay.id} button, #${overlay.id} a, #${overlay.id} input`
: undefined,
});
return { offenders: sheet.failed.flatMap(f => f.offenders), warnings: sheet.warnings };
if (!overlay) return { offenders: [] };
return LayoutAudit.occlusion(`#${overlay.id} button, #${overlay.id} a, #${overlay.id} input`);
});
await page.evaluate(() => document.querySelectorAll("[id$='Overlay']").forEach(o => { o.hidden = true; }));
record(`${name} · ${label} ${width}x${height}`, result.offenders);
result.warnings.forEach(w => warnings.push(`${name} · ${label}: ${w}`));
if (result.offenders.length) failures.push({ where: `${name} · ${label} ${width}x${height}`, offenders: result.offenders });
}
}
}
for (const [width, height, rawLabel] of viewports.filter((_, i) => i % 4 === 0)) {
for (const [width, height, rawLabel] of VIEWPORTS.filter((_, i) => i % 4 === 0)) {
const label = `${rawLabel} · ${hand}`;
await page.setViewportSize({ width, height });
await page.goto(`${BASE}/Casino/TexasHoldem/QuickSplit`);
await page.evaluate(() => {
const button = [...document.querySelectorAll("button")].find(b => /all.?in/i.test(b.textContent));
if (button) button.click();
});
await page.addScriptTag({ path: AUDIT });
// Both halves of the screen. The sweep used to switch straight to the all-in
// mode and audit only that, so the mode the screen opens in — the common one,
// a pot and a number of ways — was never looked at once, which is how a bar
// with 252px of nothing in it lasted this long. The ways stepper only exists
// past five, so the even pass opens it before measuring.
const modes = [
["even", () => {
const beyond = [...document.querySelectorAll("#waysChoices .choiceChip")]
.find(b => /\+$/.test(b.textContent.trim()));
if (beyond) beyond.click();
}],
["all-in", () => {
const button = [...document.querySelectorAll("button")].find(b => /all.?in/i.test(b.textContent));
if (button) button.click();
}],
];
for (const [mode, arrange] of modes) {
await page.evaluate(arrange);
await page.waitForTimeout(120);
checks++;
const result = await page.evaluate(() => LayoutAudit.runAll({ occlusionSelector: "main button, main a, main input" }));
if (!result.clean) failures.push({ where: `quick split · ${mode} · ${label} ${width}x${height}`, offenders: result.failed.flatMap(f => f.offenders) });
result.warnings.forEach(w => warnings.push(`quick split · ${mode} · ${label}: ${w}`));
}
checks++;
const result = await page.evaluate(() => LayoutAudit.runAll({ occlusionSelector: "main button, main a, main input" }));
if (!result.clean) failures.push({ where: `quick split · ${label} ${width}x${height}`, offenders: result.failed.flatMap(f => f.offenders) });
result.warnings.forEach(w => warnings.push(`quick split · ${label}: ${w}`));
}
await context.close();
@@ -390,8 +278,7 @@ async function main() {
await browser.close();
console.log(`${checks} audits across ${VIEWPORTS.length} viewports — with a mouse, with a thumb, ` +
`and with a thumb at ${LARGE_TEXT_PX}px of root text on ${PHONES.length} phones`);
console.log(`${checks} audits across ${VIEWPORTS.length} viewports, with a mouse and with a thumb`);
warnings.forEach(w => console.log(` note: ${w}`));
if (failures.length === 0) {
console.log("clean");
-30
View File
@@ -204,36 +204,6 @@ describe("the blind badge counts", () => {
"the same distance at both ends, or one arc is charged for the other's badge");
});
test("and the dealer chip shares that edge on exactly the same terms", () => {
// It moved out of the puck's top-right corner and onto the rim beside the badges,
// which is only free while it hangs off by the same 5px they do. A pixel more and
// the ring is charging every chair for a distance it isn't measuring.
const top = CSS.match(/\n\.dealerChip \{[^}]*?(?<![\w-])top:\s*(-?[\d.]+)px/);
const bottom = CSS.match(/\.seat\.seatBelowCentre \.dealerChip \{[^}]*?(?<![\w-])bottom:\s*(-?[\d.]+)px/);
assert.ok(top && bottom, "the chip should hang off both arcs, like the badge");
assert.equal(Math.abs(Number(top[1])), Render.SEAT_BADGE_OVERHANG_PX);
assert.equal(Math.abs(Number(bottom[1])), Render.SEAT_BADGE_OVERHANG_PX);
});
test("and it stays inside the puck's own width, where the badges are", () => {
// Sideways is the direction that lands on the seat next door. This chip has been
// there: at -10px it took the centre of seat 1's taps and gave the button to seat
// 9. `right` is a positive inset for the same reason `left: 2px` is.
const inset = CSS.match(/\n\.dealerChip \{[^}]*?(?<![\w-])right:\s*(-?[\d.]+)px/);
assert.ok(inset, "the chip should be inset from the right edge");
assert.ok(Number(inset[1]) >= 0, "a negative inset hangs it over the neighbour");
});
test("and it is short enough to clear the name it sits above", () => {
// The tight ring is the close one: 1px of border, 3px of padding and 4px of
// margin put the name's top 8px below the puck's, and the chip reaches
// height - overhang below it.
const height = CSS.match(/\n\.dealerChip \{[^}]*?(?<![\w-])height:\s*([\d.]+)px/);
assert.ok(height, "the chip should have a height to check");
assert.ok(Number(height[1]) - Render.SEAT_BADGE_OVERHANG_PX <= 8,
"the chip dips into the name row on the tight ring");
});
test("and it is counted once, not once per seat", () => {
// seatsCollide is given one box and compares centres against it, so the
// separation it asks for is one puck plus one badge. That is exactly right for
-268
View File
@@ -1,268 +0,0 @@
// What the long-press menu offers, and in what shape.
//
// It had grown to eleven actions drawn as eleven identical full-width bars. Measured
// on the fullest menu the tool can build — a live seat that isn't the dealer, blinds
// still the only money in, an override already set — the sheet came to 796px and took
// 85% of every viewport in the sweep. It scrolled on all four, and Cancel was off the
// bottom of the screen on all four: 79px past it on a 390px phone, 252px on a 360px
// one, 448px in landscape. Apple puts the comfortable number for an action sheet at
// three to five and says not to let one scroll at all.
//
// Nothing was cut — every one of the eleven is the only route to something, and
// "Blinds follow the button again" is the only route in the whole tool to clearing an
// override. They were sorted by *when* instead: what you tap with cards in the air
// keeps the full-width bars, and the housekeeping goes to a two-column grid under a
// caption. Same fullest menu afterwards: 650px, 77%, no scroll, and the way out is in
// a header that can't scroll away.
//
// Two of these are answered by running the code — which actions a seat is offered,
// and which group each lands in. The rest are layout decisions, checked as text
// against the markup and the stylesheet.
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, SeatMenu, makeTable } = require("./load");
const PAGE = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
const CSS = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
const sheetMarkup = () => {
const at = PAGE.indexOf(`<div id="seatMenuOverlay"`);
assert.ok(at >= 0, "there is no seat menu overlay");
return PAGE.slice(at, PAGE.indexOf("</div>\n\n", at));
};
// The rule block for a selector, so a test asks about one rule rather than about
// whether a string happens to appear anywhere in 6000 lines.
const ruleFor = selector => {
const at = CSS.indexOf(`\n${selector} {`);
assert.ok(at >= 0, `there is no \`${selector}\` rule`);
return CSS.slice(at, CSS.indexOf("}", at));
};
// The fullest menu the tool can build, aimed at seat 3.
function fullestMenu() {
const state = makeTable({ seatCount: 9, seated: [0, 1, 2, 3, 4], stack: 500, dealerSeatIndex: 0 });
Betting.beginHand(state);
state.blindOverride = { sbIndex: 1, bbIndex: 2 };
state.waitingList = [{ id: "w1", name: "Nina" }];
SeatMenu.state = state;
SeatMenu.hooks = {};
return { state, actions: SeatMenu.actionsFor(state.seats[3], 3) };
}
const labels = actions => actions.map(a => a.label);
const inGroup = (actions, group) => labels(actions.filter(a => a.group === group));
// ---------------------------------------------------------------------------
describe("everything the menu used to offer, it still offers", () => {
test("all eleven are there on the fullest menu", () => {
const { actions } = fullestMenu();
assert.deepEqual(labels(actions).sort(), [
"Add chips…",
"All-in",
"Big blind",
"Blinds follow the button again",
"Cash out…",
"Edit seat…",
"Fold",
"Move to…",
"Sit out",
"Small blind",
"The button",
]);
});
// The three that lost words lost them to the caption above them, not to brevity
// for its own sake: "Give this seat" is what makes "Small blind" a verb, and a
// verb that short is what fits a half-width tile on one line.
test("the three that were shortened are the three the caption speaks for", () => {
const { actions } = fullestMenu();
assert.deepEqual(inGroup(actions, "position"), [
"The button", "Small blind", "Big blind", "Blinds follow the button again",
]);
});
// This menu is the only place in the tool that offers it. If it ever stops being
// offered here, a dealer who names a blind by hand can never take it back.
test("the only way out of a blind override is still here", () => {
const { actions } = fullestMenu();
assert.ok(labels(actions).includes("Blinds follow the button again"));
});
});
describe("a group is when you do it, not what it touches", () => {
test("the hand keeps only what a dealer reaches for with cards on the table", () => {
const { actions } = fullestMenu();
assert.deepEqual(inGroup(actions, "hand"), ["Fold", "All-in"]);
});
test("a folded player's one move is in the hand too", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 200 });
Betting.beginHand(state);
state.seats[2].folded = true;
SeatMenu.state = state;
SeatMenu.hooks = {};
const actions = SeatMenu.actionsFor(state.seats[2], 2);
assert.deepEqual(inGroup(actions, "hand"), ["Un-fold"]);
});
// Seating the front of the queue is not housekeeping. A chair opens between two
// hands with people watching it, and the dealer fills it then or not at all.
test("seating the next player off the rail is a hand action", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 200 });
// makeTable names every chair; an open one has nobody on it.
state.seats[4].name = "";
state.waitingList = [{ id: "w1", name: "Nina" }];
SeatMenu.state = state;
SeatMenu.hooks = {};
const actions = SeatMenu.actionsFor(state.seats[4], 4);
assert.deepEqual(inGroup(actions, "hand"), ["Seat Nina"]);
});
test("every action lands in a group the sheet knows how to draw", () => {
const { actions } = fullestMenu();
const known = ["hand", ...SeatMenu.GROUPS.map(g => g.name)];
actions.forEach(action => {
assert.ok(known.includes(action.group),
`"${action.label}" is in group "${action.group}", which nothing draws`);
});
});
// A caption for nothing is worse than no caption, and this group empties often:
// past the blinds there is nothing to give a seat but the button.
test("a group that has nothing to say is empty rather than thin", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200, dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.call(state, 3);
SeatMenu.state = state;
SeatMenu.hooks = {};
const actions = SeatMenu.actionsFor(state.seats[3], 3);
assert.deepEqual(inGroup(actions, "position"), ["The button"]);
});
});
describe("the one action here that is not about this seat", () => {
// It puts the whole table's blinds back on automatic. Spanning the grid is what
// stops it reading as a fourth position to give.
test("the override reset spans the grid and nothing else does", () => {
const { actions } = fullestMenu();
assert.deepEqual(labels(actions.filter(a => a.wide)),
["Blinds follow the button again"]);
});
test("and it is drawn lighter than the Cancel underneath it", () => {
const rule = ruleFor(".seatMenuBtn.menuTileWide");
assert.match(rule, /grid-column:\s*1 \/ -1/);
assert.match(rule, /border-style:\s*dashed/);
// Lighter, not smaller. A thumb does not care how rare an action is, so it
// keeps the 44px every other tile has rather than shrinking out of reach.
assert.ok(!/min-height/.test(rule),
"the wide row sets its own height, which takes it under the touch floor");
});
});
describe("the sheet a dealer reads it in", () => {
test("the header is stuck to the top, so whose menu this is cannot scroll away", () => {
const rule = ruleFor(".seatMenuHeader");
assert.match(rule, /position:\s*sticky/);
assert.match(rule, /top:\s*0/);
// A sticky header inside a padded scroller shows the list sliding through the
// gap either side of it unless it reaches the sheet's own edges.
assert.match(rule, /margin:\s*0 calc\(var\(--sp-4\) \* -1\)/);
assert.match(rule, /background:/);
});
test("and the sheet keeps no padding above it to slide through", () => {
assert.match(ruleFor("#seatMenuSheet"), /padding:\s*0 var\(--sp-4\)/);
});
// NN/g is explicit that a grab handle is not a close affordance. This sheet is the
// one that can outgrow the screen, so it is also the one that most needs a way out
// in the part that cannot leave.
test("there is a real close button, not just a grip", () => {
const markup = sheetMarkup();
assert.match(markup, /id="seatMenuCloseBtn"/);
assert.match(markup, /aria-label="Close this menu"/);
});
// Modal focuses a dialog's first control, and the first control used to be Fold.
// The APG says to keep initial focus off the destructive choice; a close button at
// the top of the markup is what puts it there without a special case.
test("the first control in the sheet is the one that does nothing", () => {
const markup = sheetMarkup();
assert.ok(markup.indexOf("seatMenuCloseBtn") < markup.indexOf("seatMenuActions"),
"the actions come before the close button, so focus opens on Fold again");
});
// Escape finds the first [data-modal-close] in the overlay. That has to be the one
// that shuts the sheet, not the one at the foot that steps back inside it.
test("Escape closes the sheet rather than stepping back inside it", () => {
const markup = sheetMarkup();
const closer = markup.indexOf("data-modal-close");
assert.ok(closer >= 0 && closer < markup.indexOf("seatMenuActions"),
"Escape does not reach a closer above the action list");
assert.ok(!/id="seatMenuCancelBtn"[^>]*data-modal-close/.test(markup),
"the button at the foot still answers Escape, so Escape only goes back");
});
});
describe("two columns, and what they are for", () => {
test("the housekeeping half is a grid", () => {
assert.match(ruleFor(".seatMenuGrid"), /grid-template-columns:\s*repeat\(2, minmax\(0, 1fr\)\)/);
});
// Half the width and a step quieter, but not below the floor a fingertip needs.
test("a tile is quieter than a bar without being smaller than a thumb", () => {
const rule = ruleFor(".seatMenuBtn.menuTile");
assert.match(rule, /min-height:\s*44px/);
assert.match(rule, /font-size:\s*var\(--fs-base\)/);
});
test("a bar is still a bar", () => {
assert.match(ruleFor(".seatMenuBtn"), /min-height:\s*52px/);
});
// The dashed row and the Cancel under it are both full width. Two rows of the same
// size touching, one of which changes the table and one of which changes nothing,
// is a mistake waiting at the end of a scroll.
test("the last action and the way out are not two rows touching", () => {
assert.match(ruleFor("#seatMenuCancelBtn"), /margin-top:\s*var\(--sp-3\)/);
});
});
describe("the move picker is no longer a one-way door", () => {
const MAIN = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem", "main.js"), "utf8");
// Opening it used to replace the whole sheet, and the only control left closed the
// menu outright — a dealer who opened the seat list by mistake lost their place.
test("the button at the foot goes back while there is an inside to go back to", () => {
const wiring = MAIN.slice(MAIN.indexOf('getElementById("seatMenuCancelBtn")'));
const body = wiring.slice(0, wiring.indexOf("});"));
assert.match(body, /SeatMenu\.mode === "move"/);
assert.match(body, /SeatMenu\.closeMovePicker\(\)/);
assert.match(body, /SeatMenu\.close\(\)/);
});
test("and the menu knows how to come back", () => {
assert.equal(typeof SeatMenu.closeMovePicker, "function");
SeatMenu.mode = "move";
SeatMenu.state = { seats: [] };
SeatMenu.seatIndex = 0;
// render() needs a document, so the mode change is checked where it is made.
assert.match(SeatMenu.closeMovePicker.toString(), /this\.mode = null/);
});
test("every other chair is a destination, including the empty ones", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
SeatMenu.state = state;
const seats = SeatMenu.destinationsFor(2).map(d => d.seat);
assert.deepEqual(seats, [0, 1, 3, 4, 5]);
});
});
+67 -299
View File
@@ -1,28 +1,22 @@
// What the ring is allowed to be sized from.
//
// resolveSeatLayout buys its answer with ten forced layouts — ten candidate widths, each
// applied to the felt and read back — and caches it against this signature. So the
// signature has to move whenever a puck's height can, and must not move otherwise: one
// that changed on every action would throw the cache away on every tap, which is the
// whole cost it exists to avoid.
// Two rows in every seat puck are usually empty — the hole-card line, and the pill that
// shows what a player has put in. Both used to hold their space anyway, and the pill,
// which has a background and a pill radius and side padding, painted an 8x11px green
// lozenge when it had nothing to say: measured on seven of nine seats at an ordinary
// table, a coloured mark on almost every seat standing for nothing.
//
// It used to name four things: every player's name, the dealer's seat, whether anybody
// had hole cards, and whether anybody had chips out. Three of those flip during an
// ordinary hand. A dealer calling a bet paid ten forced layouts for it, and so did the
// next street closing, and so did editing a name.
// 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 seats had to sit. On
// an iPhone 14 nine-handed the puck measured 78x88 with 25px of that reserved for
// nothing; collapsed, the width search settles on 78x63. Twelve-handed on a Galaxy S8
// the seats overlapped by 1556px² and now overlap by 87px².
//
// None of them can change a puck's height any more. The stylesheet holds three rows open
// — name, amount, stack — at a height that does not depend on what is written in them:
// the name gets one line whatever it says, the amount pill keeps its row when it is
// empty, and the dealer's chip changes a padding rather than a line count. What is left
// is the hole-card row, which still collapses, because whether a table tracks hole cards
// is something it decides once rather than something that changes under a thumb.
//
// The other half of that bargain is in renderSeats: it measures the finished pucks
// against the box the search settled on and asks again if they disagree. That is what
// makes a wrong assumption here a stale frame rather than a stale session — and it is
// what catches the first render of all, where the pucks are built empty and the search
// that used to run against them was measuring nine blank boxes.
// The collapse is only safe if the layout re-measures when one of those rows fills,
// because resolveSeatLayout caches its ten forced layouts against this signature. That
// is what is checked here: the cache key has to move when the puck's shape can, and
// stay still when it cannot — a signature that changed on every action would throw the
// cache away on every tap, which is the cost it exists to avoid.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
@@ -38,37 +32,35 @@ const signatureOf = state => {
const table = () => makeTable({ seatCount: 6, stack: 200 });
describe("the signature the ring layout is cached against", () => {
test("moves when a hole card is entered", () => {
// The one row still allowed to collapse, and the one thing still worth
// re-measuring for: a puck with cards on it is about 15px taller.
test("moves when the first chips go in", () => {
// Before the blinds every pill is empty and the pucks are short; after them two
// seats are ~15px taller. A ring still sized from the first measurement is sized
// for a puck that no longer exists.
const state = table();
const empty = signatureOf(state);
state.seats[1].committed = 10;
assert.notEqual(signatureOf(state), empty,
"the ring would still be sized for pucks with no amount on them");
});
test("and when a hole card is entered", () => {
const state = table();
const none = signatureOf(state);
state.seats[0].cards = ["AS", "KC"];
assert.notEqual(signatureOf(state), none);
});
test("but not again for the second player to be dealt one", () => {
// The point of a boolean over the table. The ring is sized from the tallest puck
// on it, so once anybody has cards the answer cannot change.
test("but not again for the second player to put chips in", () => {
// The whole point of a boolean over the table. The ring is sized from the
// tallest puck on it, so once anybody has an amount the answer cannot change —
// and re-measuring here would mean ten forced layouts on every single action.
const state = table();
state.seats[0].cards = ["AS", "KC"];
const first = signatureOf(state);
state.seats[1].cards = ["2H", "7D"];
assert.equal(signatureOf(state), first);
});
test("and it stands still while the chips move", () => {
// The expensive one. Somebody has money out for most of every hand and which
// seats it is changes on every action, so this used to be a ten-layout re-search
// twice a hand — and the pucks it was re-measuring for are the same height now
// whether there is an amount in them or not.
const state = table();
const quiet = signatureOf(state);
state.seats[1].committed = 10;
assert.equal(signatureOf(state), quiet,
"the amount pill holds its row whether or not it has an amount in it");
state.seats[2].committed = 250;
assert.equal(signatureOf(state), quiet);
const first = signatureOf(state);
state.seats[2].committed = 10;
assert.equal(signatureOf(state), first);
state.seats[3].committed = 250;
assert.equal(signatureOf(state), first);
});
test("nor when an amount merely changes size", () => {
@@ -80,278 +72,54 @@ describe("the signature the ring layout is cached against", () => {
"a wider number is the amount fitter's problem, not the ring's");
});
test("and it stands still for a rename", () => {
// Renaming used to be able to wrap a name onto a second line. A name gets one
// line now, and one that does not fit drops a word or steps its type down — see
// fitSeatNames. Neither moves the row it sits in.
test("and it still moves for the things it always moved for", () => {
// Renaming can wrap a name onto a second line, and the button's chip floats
// into the name's first line, so both change how tall a puck measures.
const state = table();
const base = signatureOf(state);
state.seats[0].name = "Bartholomew Fairweather";
assert.equal(signatureOf(state), base);
const renamed = signatureOf(state);
assert.notEqual(renamed, base);
state.dealerSeatIndex = (state.dealerSeatIndex + 1) % state.seats.length;
assert.notEqual(signatureOf(state), renamed);
});
test("and for the button moving round the table", () => {
// The chip in the puck's corner takes 22px off the name's line. That is a
// narrower name, not a taller puck, so the ring it sits on is the same ring.
const state = table();
const base = signatureOf(state);
state.dealerSeatIndex = (state.dealerSeatIndex + 1) % state.seats.length;
assert.equal(signatureOf(state), base);
test("and two tables that differ only in where a name is split stay different", () => {
// The names are joined on a control character no name can contain. Joined on
// nothing, "ann" + "abel" and "anna" + "bel" are both "annabel" and one of those
// tables would be laid out with the other's measurements — different names, so
// different wrapping, so different puck heights.
//
// The first version of this used "Ann"/"Abel" against "Anna"/"Bel", which can
// never collide: the capital letters land in different places, so it passed with
// the separator taken out and proved nothing at all. Lower case is what makes it
// a real test.
const a = table();
const b = table();
a.seats[0].name = "ann"; a.seats[1].name = "abel";
b.seats[0].name = "anna"; b.seats[1].name = "bel";
assert.notEqual(signatureOf(a), signatureOf(b));
});
});
describe("a table nobody has sat down at", () => {
test("carries no flag at all", () => {
test("has no amounts and no cards on it", () => {
// The empty-table case is the one the collapse is most visible on, and it is
// also the one where a stray default could quietly keep the row open forever.
// also the one where a stray default could quietly keep both rows open forever.
const state = HoldemState.createDefault();
HoldemState.resizeSeats(state, 9);
assert.equal(signatureOf(state), "",
"an untouched table should carry nothing");
assert.match(signatureOf(state), /\|$/,
"an untouched table should carry neither flag");
});
test("and neither does one with an ante on it", () => {
// computeContribution answers for a seat that has never been given a stack — the
// table's base pot stands in for it — so a table dealt with "everyone antes 5"
// has amounts on its pucks before anybody acts. That used to have to be in here.
// The pucks are the same height with it as without, so it does not any more.
test("but a table with an ante on it does have an amount", () => {
// computeContribution answers for a seat that has never been given a stack —
// the table's base pot stands in for it — so a table dealt with "everyone antes
// 5" has amounts on its pucks before anybody acts, and the ring has to know.
const state = HoldemState.createDefault();
HoldemState.resizeSeats(state, 6);
state.basePotAmount = 5;
state.seats.forEach(p => { p.inHand = true; });
assert.equal(signatureOf(state), "");
});
});
// ---------------------------------------------------------------------------
// The other half: the stylesheet has to actually hold those rows open, or the signature
// above is a promise about a puck that doesn't exist.
//
// Read as text, the way the seat sheet's rules and the reduced-motion rules are. What
// height a row is, is a decision that lives in the file — the browser is where it is
// proved, and runUiAudit.js is what proves it there.
const fs = require("fs");
const path = require("path");
const CSS = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
const ruleFor = selector => {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const found = CSS.match(new RegExp(`(^|[},])\\s*${escaped}\\s*\\{([^}]*)\\}`, "m"));
return found ? found[2] : null;
};
describe("every puck on the ring is the same puck", () => {
test("each of the three rows is a row high whether or not it says anything", () => {
// A blank label had no height at all, so the one puck with nobody in it was the
// one puck a different size from the rest — and a seat that had put chips in was
// a different size again from one that hadn't.
["name", "total", "stack"].forEach(row => {
const selector = `.seatPuck${row[0].toUpperCase()}${row.slice(1)}`;
const body = ruleFor(selector) || "";
assert.match(body, /min-height:\s*var\(--seatRow(Name|Money)\)/,
`${selector} does not hold its row open`);
});
});
test("and the row heights are tokens, not ems", () => {
// fitAmount steps a tournament stack's type down so every digit stays on the
// puck, and .puckNameTight does the same to a name with no word to drop. In ems
// each of those would have taken its row down with it, and the puck with it.
["--seatRowName", "--seatRowMoney"].forEach(token =>
assert.match(CSS, new RegExp(`\\${token}:\\s*\\d+px`), `${token} is not a fixed height`));
});
test("the amount pill hides its paint rather than giving up its place", () => {
// Collapsing it is what made a puck grow 17px when its player called and shrink
// again when the street closed. Painting an empty pill is what collapsing it was
// introduced to stop — an 8x11px green lozenge on seven of nine seats, standing
// for nothing. Neither, now: the row stays and the pill is not drawn.
assert.match(ruleFor(".seatPuckTotal:empty") || "", /visibility:\s*hidden/);
assert.doesNotMatch(ruleFor(".seatPuckTotal:empty") || "", /display:\s*none/);
});
test("but the hole-card row still collapses, because a table decides that once", () => {
// Holding 15px open on nine seats all night for a row most tables never fill is
// the crowding this is meant to avoid, and it is the one thing left that the
// signature above still watches for.
assert.match(ruleFor(".seatPuckCards:empty") || "", /display:\s*none/);
});
test("an empty pill sits on its top edge, not on a baseline it hasn't got", () => {
// An inline-block sits on its last line's baseline, and an empty one has no last
// line — so it sits on its bottom edge instead and the line box comes out 2px
// shorter than the same pill with a figure in it. That put the seats with money
// out 2px below everyone else: the one difference this row exists to remove,
// reappearing as its own mirror image.
assert.match(ruleFor(".seatPuckTotal") || "", /vertical-align:\s*top/);
});
});
// ---------------------------------------------------------------------------
describe("and the ring checks its own measurement afterwards", () => {
// The signature above is an assumption about what can change a puck. This is what
// happens when the assumption is wrong, and it is also what happens on the first
// render of all: buildSeatNodes puts nine empty pucks on the felt and the search runs
// before anything is written into them, so it was measuring nine blank boxes and
// caching the answer under a key naming a table it had never seen.
//
// That used to be permanent. The comment where it was cached said "let the resize
// that follows do this properly" — and nothing resizes a phone. A table opened
// upright kept a ring measured for empty seats until the dealer turned the phone
// sideways and back, which is the only thing that ever changed the key.
const withBox = (box, run) => {
const before = Render.measuredSeatBox;
Render.measuredSeatBox = () => box;
try { return run(); } finally { Render.measuredSeatBox = before; }
};
test("a layout that measured nothing is always asked again", () => {
// No box on the answer means the search never had a puck in front of it.
assert.equal(withBox({ width: 72, height: 56 }, () => Render.seatBoxMoved(null)), true);
assert.equal(withBox({ width: 72, height: 56 }, () => Render.seatBoxMoved(undefined)), true);
});
test("one that measured the puck that is now on the felt is not", () => {
assert.equal(
withBox({ width: 72, height: 56 }, () => Render.seatBoxMoved({ width: 72, height: 56 })),
false);
});
test("and a pixel of difference is a rounding artefact, not a table that moved", () => {
// The two measurements are taken at different moments. Without the slack this
// would ask the search to run again on every render for the rest of the session,
// which is ten forced layouts a tap — the exact cost the cache exists to avoid.
assert.equal(
withBox({ width: 72.4, height: 56.4 }, () => Render.seatBoxMoved({ width: 72, height: 56 })),
false);
assert.equal(
withBox({ width: 72, height: 58 }, () => Render.seatBoxMoved({ width: 72, height: 56 })),
true, "two pixels taller is a puck that has changed shape");
});
test("nothing on the felt to measure is not a reason to ask again", () => {
// Between a table being resized and the new pucks being built there is a moment
// with no seats at all. Asking again there would clear a good answer and replace
// it with one measured from nothing.
assert.equal(withBox(null, () => Render.seatBoxMoved({ width: 72, height: 56 })), false);
});
});
// ---------------------------------------------------------------------------
describe("how much room a figure has on the puck it is written on", () => {
// The two steps of type an amount comes down used to be chosen by counting
// characters: eight for the first, ten for the second. That is a guess at how wide a
// figure is, and it happens to hold at one text size and one puck width.
//
// Turn a phone's text up — 20px of root text is what an Android slider gives you at
// 125%, and every size in the stylesheet is in `rem` on purpose — and it stops
// holding. Measured at 360x640 twelve-handed: the puck is at its 44px floor, which
// is 34px of usable width, and "$1,200" is six characters, took no step at all, and
// wanted 38. The sweep caught it as a stack figure painted over the seat next door
// at every phone in the list, and as "ALL IN" doing the same on the all-in table.
//
// So it is measured. A cell is a stand-in for the real one: what it wants against
// what it has, and a class list to write the answer into.
const cell = (figure, wants, has) => ({
dataset: { figure },
textContent: figure,
classes: new Set(),
classList: {
add(name) { this.owner.classes.add(name); },
remove(name) { this.owner.classes.delete(name); },
},
// What the text wants is proportional to how many characters are in it and how
// far down the ladder it has come — which is what a browser would tell us.
get scrollWidth() {
const step = this.classes.has("puckAmountHuge") ? 0.7
: this.classes.has("puckAmountLong") ? 0.85 : 1;
return Math.round((wants * this.textContent.length / figure.length) * step);
},
clientWidth: has,
});
const make = (...args) => {
const c = cell(...args);
c.classList.owner = c;
return c;
};
const fit = cells => {
const before = Render.overflowing;
Render.overflowing = el => el.scrollWidth > el.clientWidth + 1;
try { Render.fitAmounts(cells); } finally { Render.overflowing = before; }
};
test("a figure that fits is left at the size it is meant to be read at", () => {
const c = make("$600", 30, 34);
fit([c]);
assert.equal(c.textContent, "$600");
assert.deepEqual([...c.classes], []);
});
test("one that doesn't gives up its separators first", () => {
// Grouping is a reading aid for a column of figures. This is one number on a
// 44px puck, and a step of type costs more than the commas do.
const c = make("$1,200", 38, 34);
fit([c]);
assert.equal(c.textContent, "$1200");
assert.deepEqual([...c.classes], [], "no type step needed once the commas are out");
});
test("then a step of type, and then another", () => {
const c = make("$1,234,567", 90, 34);
fit([c]);
assert.equal(c.textContent, "$1234567");
assert.deepEqual([...c.classes], ["puckAmountHuge"]);
});
test("and only ever one step at a time is worn", () => {
// The two rules have the same specificity, so a puck wearing both would be sized
// by whichever the stylesheet happens to mention last.
const c = make("$1,234,567", 45, 34);
fit([c]);
assert.equal(c.classes.has("puckAmountHuge") && c.classes.has("puckAmountLong"), false);
});
test("a figure with no separators to give goes straight to the type", () => {
// "ALL IN" is the one thing on this row that isn't a number, and it is the widest
// — six characters and a space, at 700 weight with its letters spread apart.
const c = make("ALL IN", 39, 34);
fit([c]);
assert.equal(c.textContent, "ALL IN");
assert.deepEqual([...c.classes], ["puckAmountLong"]);
});
test("a puck that went small can come back", () => {
// Everything is measured from the figure and the size it is meant to be read at,
// not from whatever the last render left behind. Otherwise this is a ratchet: one
// crowded frame and the seat is small for the rest of the night.
const c = make("$600", 30, 34);
c.classes.add("puckAmountHuge");
c.textContent = "$600";
fit([c]);
assert.deepEqual([...c.classes], []);
});
test("and the grouped figure is what it comes back to", () => {
const c = make("$1,200", 38, 34);
fit([c]);
assert.equal(c.textContent, "$1200");
c.clientWidth = 96;
fit([c]);
assert.equal(c.textContent, "$1,200", "a wider puck should get its separators back");
});
test("an empty row is not an amount", () => {
// The pill keeps its place on a seat with nothing in the pot — that is what makes
// every puck on the ring the same puck — and there is nothing there to fit.
const c = make("", 0, 34);
assert.doesNotThrow(() => fit([c]));
assert.deepEqual([...c.classes], []);
assert.match(signatureOf(state), /a$/);
});
});
+2 -2
View File
@@ -41,9 +41,9 @@ describe("what the sheet puts in front of a dealer at rest", () => {
// <details> blocks are stripped whole, so what is left is the flat part.
const alwaysVisible = () => seatForm().replace(/<details[\s\S]*?<\/details>/g, "");
test("the flat part is the name, what the seat is doing and the stack, and nothing else", () => {
test("the flat part is the name, the two status boxes and the stack, and nothing else", () => {
const flat = alwaysVisible();
["seatNameInput", "seatState", "seatStateOption", "stackInput", "seatBuyIns"]
["seatNameInput", "inHandCheckbox", "foldCheckbox", "stackInput", "seatBuyIns"]
.forEach(cls => assert.ok(flat.includes(cls),
`${cls} is what a dealer opens a seat for — it cannot be behind a disclosure`));
-209
View File
@@ -1,209 +0,0 @@
// The five cards in the middle of the felt, and the sheet of glass over them.
//
// Two faults, one on top of the other, and the sweep could see neither.
//
// #seatsLayer is a transparent `inset: 0` sibling of the table that holds the seat
// pucks. The pucks ring the felt; the middle of it is empty layer — and an element with
// no background still hit-tests. So every tap aimed at the community cards landed on the
// layer instead. Measured at 1280x900 with the board on and five cards out: all five
// hit-tested to `div#seatsLayer`, on every screen wide enough to keep the board in the
// middle rather than on the strip. Each one is a button that opens the picker for that
// card, and not one of them could be pressed. #betsLayer, the other sheet over the felt,
// has been `pointer-events: none` since it was written.
//
// Underneath that, the cards were a little over half the size they were meant to be.
// `placeBoard` measures how much middle the felt has and writes it to the centre's
// `max-width` — but a max-width caps a box, it does not claim anything, and the centre
// is a flex column that sizes to its content. So with 463px of measured room it went on
// being 136px wide, the board row inside it is `width: 100%` of that, and five cards
// sharing 136px come to rest on their own `min-width`: 24px, against a 44px floor this
// sweep holds every other control on the felt to. On the strip, where the row simply
// gets the width of the page, the same cards have always drawn 44.
//
// Neither was visible to a scripted probe — `.click()` on a node skips hit-testing
// entirely — and the sweep's occlusion check had only ever been shown three selectors'
// worth of the page: the action bar, the header, and the seats.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8");
const AUDIT = fs.readFileSync(path.join(__dirname, "uiAudit.js"), "utf8");
const DRIVER = fs.readFileSync(path.join(__dirname, "runUiAudit.js"), "utf8");
const src = name => fs.readFileSync(path.join(ROOT, "wwwroot", "js", "casino", "holdem", `${name}.js`), "utf8");
const ruleFor = selector => {
const at = CSS.indexOf(`\n${selector} {`);
assert.ok(at >= 0, `there is no \`${selector}\` rule`);
return CSS.slice(at, CSS.indexOf("}", at));
};
// ---------------------------------------------------------------------------
describe("the sheet over the felt", () => {
test("it does not take taps meant for what is under it", () => {
assert.match(ruleFor("#seatsLayer"), /pointer-events:\s*none/);
});
// Which is only safe because the thing in it that *is* a control says so itself.
test("but a seat still does", () => {
assert.match(ruleFor(".seat"), /pointer-events:\s*auto/);
});
// The two chips that want to stay untouchable set their own value and are inside a
// seat, so they override what they inherit rather than depending on the layer.
test("and the two pucks that are markers rather than controls still are", () => {
assert.match(ruleFor(".dealerChip:not(.active)"), /pointer-events:\s*none/);
assert.match(ruleFor(".dealerChip.active"), /pointer-events:\s*none/);
});
test("the other sheet is unchanged, and was always right", () => {
assert.match(ruleFor("#betsLayer"), /pointer-events:\s*none/);
});
test("what it measured is written beside the rule", () => {
const at = CSS.indexOf("/* A transparent sheet over the whole felt");
assert.ok(at >= 0, "the rule carries no note saying why");
const note = CSS.slice(at, CSS.indexOf("*/", at));
assert.match(note, /1280x900/);
assert.match(note, /seatsLayer/);
});
});
describe("how wide the middle actually is", () => {
test("the room it measured is claimed, not only capped", () => {
const render = src("render");
const at = render.indexOf("placeBoard(row, strip) {");
assert.ok(at >= 0, "there is no placeBoard");
const body = render.slice(at, render.indexOf("\n },", at));
assert.match(body, /centre\.style\.width = `\$\{Math\.round\(width\)\}px`/,
"the centre is still given a ceiling and no floor, so it shrink-wraps");
assert.match(body, /centre\.style\.maxWidth = `\$\{Math\.round\(width\)\}px`/);
});
// Both come off together when the board leaves the felt, or the centre would keep a
// width measured for a board that is no longer in it.
test("and both are given back when the board moves to the strip", () => {
const render = src("render");
const at = render.indexOf("placeBoard(row, strip) {");
const body = render.slice(at, render.indexOf("} else {", at));
assert.match(body, /centre\.style\.width = ""/);
assert.match(body, /centre\.style\.maxWidth = ""/);
});
// The floor is what it always said it was; what changed is that the cards can now
// reach past it to the ceiling.
test("a card is still allowed to grow to a thumb", () => {
const rule = ruleFor(".boardCard");
assert.match(rule, /max-width:\s*44px/);
assert.match(rule, /min-height:\s*44px/);
assert.match(rule, /flex:\s*1 1 0/);
});
test("what it measured is written beside the code", () => {
const render = src("render");
const at = render.indexOf("// Both, and the width is the half that was missing.");
assert.ok(at >= 0, "the fix carries no note saying why");
const note = render.slice(at, at + 1400);
assert.match(note, /463px/);
assert.match(note, /24px/);
});
});
describe("why the sweep could not see either of them", () => {
// Three selectors: the bar, the header and the seats. Nothing else on the page had
// ever been asked whether something was sitting on top of it.
test("the occlusion check looks at every control now, not three groups", () => {
const at = AUDIT.indexOf("occlusion(selector = null) {");
assert.ok(at >= 0, "occlusion still hard-codes its own list");
assert.match(AUDIT.slice(at, at + 200), /selector = selector \|\| this\.CONTROL_SELECTOR/);
});
// Widening it put three kinds of non-fault in front of the rule for the first time.
test("a control that opted out of the pointer is not reported as covered", () => {
const at = AUDIT.indexOf("occlusion(selector = null) {");
const body = AUDIT.slice(at, AUDIT.indexOf("\n },", at));
assert.match(body, /getComputedStyle\(el\)\.pointerEvents === "none"/);
});
test("nor is one inside a disclosure nobody has opened", () => {
const at = AUDIT.indexOf("occlusion(selector = null) {");
const body = AUDIT.slice(at, AUDIT.indexOf("\n },", at));
assert.match(body, /el\.checkVisibility/,
"a shut <details> still hands out a real rect, and a size check lets it through");
});
test("and flow content under a fixed layer is said rather than failed", () => {
const at = AUDIT.indexOf("occlusion(selector = null) {");
const body = AUDIT.slice(at, AUDIT.indexOf("\n },", at));
assert.match(body, /scrolls clear of it/);
});
});
describe("the rest of what the sweep had never run", () => {
// It opened five sheets and asked one question of them.
test("an overlay gets the whole audit, not the occlusion check alone", () => {
const at = DRIVER.indexOf("for (const [name, open] of OVERLAYS) {");
assert.ok(at >= 0);
const body = DRIVER.slice(at, DRIVER.indexOf("record(`${name}", at));
assert.match(body, /LayoutAudit\.runAll\(/);
assert.ok(!/return LayoutAudit\.occlusion\(/.test(body),
"the overlay pass still runs one rule");
});
// Two of the rules that made running only one of them reasonable ask about the
// fixed bar, which is not a question while a scrim is covering the page.
test("the two questions about the fixed bar stand down while a sheet is open", () => {
["focusHasRoom() {", "furnitureBudget() {"].forEach(signature => {
const at = AUDIT.indexOf(signature);
assert.ok(at >= 0, `there is no \`${signature}\``);
const body = AUDIT.slice(at, AUDIT.indexOf("\n },", at));
assert.match(body, /if \(this\.modalScope\(\)\) \{/,
`${signature} still reads a full-screen scrim as furniture`);
});
});
// And the third called the bottom four rows of a sheet that scrolls unreachable.
test("a dialog's buttons below the fold are only a fault if nothing scrolls to them", () => {
const at = AUDIT.indexOf("dialogActionsReachable() {");
const body = AUDIT.slice(at, AUDIT.indexOf("\n },", at));
assert.match(body, /if \(this\.reachableByScrolling\(button\)\) return;/);
});
// The helper all three of those lean on had a blind spot of its own: it asked
// whether the element had left the box entirely, which is true of something well
// below the fold and false of something hanging eight pixels over the edge.
test("and reachability counts a control that is only part-way out", () => {
const at = AUDIT.indexOf("reachableByScrolling(el) {");
const body = AUDIT.slice(at, AUDIT.indexOf("\n },", at));
assert.match(body, /const below = rect\.bottom - box\.bottom/);
assert.match(body, /const above = box\.top - rect\.top/);
// The comment above the fix quotes the old test, so the code is read on its own.
const code = body.split("\n").filter(line => !/^\s*\/\//.test(line)).join("\n");
assert.ok(!/rect\.top >= box\.bottom/.test(code),
"it still asks whether the element has left the box entirely");
});
});
describe("two controls that were a glyph and nothing else", () => {
test("the pot wizard's close is named for what leaving does", () => {
const markup = fs.readFileSync(path.join(ROOT, "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
const at = markup.indexOf('id="closePotWizardBtn"');
assert.ok(at >= 0);
assert.match(markup.slice(at, markup.indexOf(">", at)), /aria-label="Close without paying the pot"/);
});
// Named from the label the dealer typed, and kept up with it — the same shape the
// chip legend's remove button already used, in a column of identical crosses.
test("and a custom field's remove says which field", () => {
const render = src("render");
const at = render.indexOf("renderCustomFields(form, player, index) {");
const body = render.slice(at, render.indexOf("\n },", at));
assert.match(body, /removeBtn\.setAttribute\("aria-label"/);
assert.match(body, /labelInput\.value\.trim\(\)/);
assert.match(body, /nameRemove\(\);/);
});
});
@@ -1,184 +0,0 @@
// The one control in the header that is named for a hand and never starts one.
//
// "Next hand" names three different actions in this tool. The action bar's green button
// deals one. The pot wizard finishes on "Start Next Hand" and hands back a table ready
// to deal. And the header shortcut — which, between hands, deals nothing: it moves the
// button on a second time, so the seat that was about to pay the big blind is skipped
// and the next one pays it instead.
//
// Measured before this changed: settle a pot through the wizard, and the table is on
// hand 6 with the button on seat 2 and the bar offering "Post blinds & deal". Press the
// header's "Next Hand ⟳" — no confirmation, because there is nothing in the middle to
// warn about — and the table is still on hand 6, with the button on seat 3. The toast
// then opened with the same two words the wizard's button had just used, which reads as
// a confirmation of the thing that was already done rather than a report of a second
// rotation.
//
// The mechanism is right and stays: a dealer does pass the button along on a hand nobody
// played, and state.startNextHand says so in as many words. What was wrong was the name.
// A command is named for the state the system moves into — NN/g's Button States, "Show
// Tab Bar" against "Hide Tab Bar" — and between hands the state this moves into is a
// moved button, not a new hand.
//
// So the label is read off the hand: "Next Hand ⟳" while one is out, "Move Button ⟳"
// when none is. Both claims are checked here, along with the trap underneath them — the
// title is cached once from the markup for the dimmed-reason machinery to borrow and
// hand back, and a title that depends on the hand has to be written past that cache on
// every render or it freezes on whichever state happened to be true at load.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Render, HoldemState, Betting, makeTable } = require("./load");
// Stand-ins for the three header buttons. Enough of an element for Unavailable and for
// renderHeaderActions: attributes, a dataset, text, and a hidden flag.
const element = title => {
const attributes = title ? { title } : {};
return {
hidden: false,
textContent: "",
dataset: {},
getAttribute: name => (name in attributes ? attributes[name] : null),
setAttribute: (name, value) => { attributes[name] = String(value); },
removeAttribute: name => { delete attributes[name]; },
};
};
// The header as the page ships it, with the markup's own title on the shortcut.
const header = () => {
const buttons = {
misdealBtn: element("Take this hand back"),
nextHandBtn: element("Pass the button on again without dealing — for a hand nobody played"),
resolvePotBtn: element(""),
};
global.document = { getElementById: id => buttons[id] || null };
return buttons;
};
const renderOn = state => {
const before = Render.state;
Render.state = state;
try { Render.renderHeaderActions(); } finally { Render.state = before; }
};
const table = () => makeTable({ seatCount: 6, seated: [0, 1, 2, 3], smallBlind: 5, bigBlind: 10 });
test.afterEach(() => { delete global.document; });
describe("what the shortcut calls itself", () => {
test("between hands it offers to move the button, because that is all it does", () => {
const buttons = header();
renderOn(table());
assert.equal(buttons.nextHandBtn.textContent, "Move Button ⟳");
assert.match(buttons.nextHandBtn.getAttribute("title"), /without dealing/,
"the title has to say the thing the label can't fit");
});
test("and with a hand out it offers the next one, because that is what it moves to", () => {
const buttons = header();
const state = table();
Betting.beginHand(state);
state.bettingOpen = true;
renderOn(state);
assert.equal(buttons.nextHandBtn.textContent, "Next Hand ⟳");
assert.match(buttons.nextHandBtn.getAttribute("title"), /End this hand/);
});
test("the name follows the table rather than sticking where it started", () => {
// The failure this guards is a one-way trip: the label is right on the state the
// page loaded in and wrong for the rest of the night.
const buttons = header();
const state = table();
renderOn(state);
const betweenHands = buttons.nextHandBtn.textContent;
Betting.beginHand(state);
state.bettingOpen = true;
renderOn(state);
const midHand = buttons.nextHandBtn.textContent;
HoldemState.startNextHand(state);
renderOn(state);
assert.notEqual(betweenHands, midHand, "the label never moved off the loaded state");
assert.equal(buttons.nextHandBtn.textContent, betweenHands, "and it never came back");
});
test("including the title, which the dimmed-reason cache would otherwise freeze", () => {
// renderHeaderActions fills dataset.titleWhenUsable once, from the markup, so the
// sentence hung off a dimmed control can be handed back when it is usable again.
// A state-dependent title has to be written into that slot on every render — this
// is the assertion that the write happens before the borrow, not after it.
const buttons = header();
const state = table();
renderOn(state);
const betweenHands = buttons.nextHandBtn.getAttribute("title");
Betting.beginHand(state);
state.bettingOpen = true;
renderOn(state);
assert.notEqual(buttons.nextHandBtn.getAttribute("title"), betweenHands);
assert.match(buttons.nextHandBtn.getAttribute("title"), /End this hand/);
});
test("and an empty table still gets the reason it can't be pressed", () => {
// Naming the two states must not cost the third one its sentence: with nobody
// seated there is no button to move, and that is what the title says.
const buttons = header();
// A table as the tool first opens it: chairs, and nobody named in any of them.
renderOn(HoldemState.createDefault());
assert.match(buttons.nextHandBtn.getAttribute("title"), /seat a player first/);
// And seating the table hands the state's own sentence back.
renderOn(table());
assert.match(buttons.nextHandBtn.getAttribute("title"), /without dealing/);
});
test("Misdeal still comes and goes with the hand", () => {
const buttons = header();
const state = table();
renderOn(state);
assert.equal(buttons.misdealBtn.hidden, true, "there is nothing to take back yet");
Betting.beginHand(state);
state.bettingOpen = true;
renderOn(state);
assert.equal(buttons.misdealBtn.hidden, false);
});
});
describe("what it does when there is no hand to end", () => {
test("the button moves on and the hand number doesn't", () => {
// This is the behaviour the label now names, pinned so the name stays true.
const state = table();
state.handNumber = 6;
state.dealerSeatIndex = 1;
HoldemState.startNextHand(state);
assert.equal(state.dealerSeatIndex, 2, "the button passed to the next occupied seat");
assert.equal(state.handNumber, 6, "no hand was dealt, so none was counted");
assert.equal(state.bettingOpen, false);
assert.equal(state.postedBlinds, null, "nothing is posted until the dealer says so");
});
test("so pressing it twice skips a seat's big blind", () => {
// Not a bug — a dealer passing the button along twice means it — but it is the
// reason the control cannot be called "Next Hand" while it is doing it.
const state = table();
const first = HoldemState.getBlindSeats({ ...state });
HoldemState.startNextHand(state);
const second = HoldemState.getBlindSeats(state);
HoldemState.startNextHand(state);
const third = HoldemState.getBlindSeats(state);
assert.notEqual(second.bbIndex, first.bbIndex);
assert.notEqual(third.bbIndex, second.bbIndex,
"the seat that was about to pay the big blind never did");
});
});
-129
View File
@@ -1,129 +0,0 @@
// The one mark on a puck that isn't about this hand.
//
// A seat's chair number was a grey digit in the top-left corner, and the top-left corner
// is where the blind badge drops off the rim and where a long centred name reaches.
// Measured across 348 seats on 36 tables: 117 of them had the number printed under
// something else. All 66 seats posting a blind had it covered outright — the badge is
// 22px wide over a 6px digit, at z-index 4 — and 51 more had it sitting on the first
// letter of the player's own name, the whole glyph in the worst case, on a 54px puck.
//
// There is nowhere on the felt to move it to. The button's chip owns the right-hand end
// of the top rim. Bets land on the rim between a seat and the pot, which is the bottom
// edge for the seats above centre and the top edge for the ones below. The middle of the
// puck is three rows — name, bet, stack — and all three are about this hand. So the mark
// that isn't gave way, and the two sheets that are about one seat say which seat.
//
// The sweep had never mentioned any of it, because the check that asks whether a puck
// covers itself up only knew about two of the three things a puck wears, and only ever
// tested them against writing.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const { HoldemState, makeTable } = require("./load");
const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8");
const AUDIT = fs.readFileSync(path.join(__dirname, "uiAudit.js"), "utf8");
const src = name => fs.readFileSync(
path.join(ROOT, "wwwroot", "js", "casino", "holdem", `${name}.js`), "utf8");
const ruleFor = selector => {
const at = CSS.indexOf(`\n${selector} {`);
assert.ok(at >= 0, `there is no \`${selector}\` rule`);
return CSS.slice(at, CSS.indexOf("}", at));
};
// ---------------------------------------------------------------------------
describe("a chair with somebody in it", () => {
test("does not draw its number on the felt at all", () => {
assert.match(ruleFor(".seat:not(.seatEmpty) .seatChairNumber"), /display:\s*none/);
});
// Where the number is the thing being looked for rather than a footnote on somebody
// else's name — which chair is open — it is still large and centred and washed.
test("and an open one still wears its number, large and in the middle", () => {
const rule = ruleFor(".seatEmpty .seatChairNumber");
assert.match(rule, /top:\s*50%/);
assert.match(rule, /left:\s*50%/);
assert.match(rule, /background:/);
});
// Removing it from the felt is only defensible because the felt is not the only
// place it appears. A seat nobody has named says "Seat 5" as its name already.
test("a seat with no name typed in still says which seat it is", () => {
const state = makeTable({ seatCount: 6 });
state.seats[4].name = "";
assert.equal(HoldemState.displayName(state.seats[4], 4), "Seat 5");
});
});
describe("the two sheets that are about one seat", () => {
test("name the chair as well as the player", () => {
assert.equal(HoldemState.seatHeading({ name: "Alexandra" }, 2), "Alexandra · Seat 3");
});
test("and don't say it twice when the name is already the number", () => {
assert.equal(HoldemState.seatHeading({ name: "" }, 4), "Seat 5");
assert.equal(HoldemState.seatHeading({ name: " " }, 4), "Seat 5");
assert.equal(HoldemState.seatHeading({}, 0), "Seat 1");
assert.equal(HoldemState.seatHeading(null, 0), "Seat 1");
});
test("a typed name keeps its own spacing off the ends", () => {
assert.equal(HoldemState.seatHeading({ name: " Mike from work " }, 1), "Mike from work · Seat 2");
});
test("the seat editor's heading is the one that says it", () => {
const render = src("render");
const at = render.indexOf("renderSeatEditor() {");
assert.ok(at >= 0);
const body = render.slice(at, render.indexOf("\n },", at));
assert.match(body, /title\.textContent = HoldemState\.seatHeading\(player, index\)/);
});
// Not the heading here: this header is stuck to the top of the sheet that scrolls,
// and a name long enough to wrap would cost every action below it a line.
test("the seat menu says it on its first line instead, and says it first", () => {
const menu = src("seatMenu");
const at = menu.indexOf("const stack = Betting.stackOf(this.state, player);");
assert.ok(at >= 0);
const body = menu.slice(at, menu.indexOf("subtitle.textContent", at));
assert.match(body, /const bits = \[`Seat \$\{this\.seatIndex \+ 1\}`\]/);
});
});
describe("the sweep can see it now", () => {
const check = () => {
const at = AUDIT.indexOf(" seatLabelsClear() {");
assert.ok(at >= 0, "the check that asks whether a puck covers itself up is gone");
return AUDIT.slice(at, AUDIT.indexOf("\n },", at));
};
// The mark that was sitting on names was neither of the two this check knew about.
test("the chair number counts as something a puck wears", () => {
assert.match(check(), /const MARKS = "\.dealerChip, \.blindBadge, \.seatChairNumber"/);
});
// And a badge painted over the number is a puck covering itself up, which is the
// whole subject of this check, and was invisible to it.
test("and the marks are held against each other, not only against the writing", () => {
const body = check();
assert.match(body, /marks\.forEach\(\(a, i\) => marks\.slice\(i \+ 1\)/);
assert.match(body, /px across/);
});
test("a mark that isn't drawn isn't an offender", () => {
const body = check();
assert.match(body, /style\.display === "none"/);
assert.match(body, /parseFloat\(style\.opacity\) < 0\.05/);
// An empty badge slot has a box and no text; three of them sit on every puck.
assert.match(body, /if \(!\(mark\.textContent \|\| ""\)\.trim\(\)\) return null;/);
});
test("and the check says what it now covers", () => {
assert.match(AUDIT, /name: "nothing a seat wears covers its name or another mark"/);
});
});
-167
View File
@@ -1,167 +0,0 @@
// The panel named after a legend that never showed one.
//
// Measured on an iPhone: 793px, of which 420 were the colour form — five rows of
// swatch, value box and a filled red delete block, then an add button and a paragraph
// of instructions. Under all of it sat "Stacks in chips", which is the thing a dealer
// opens this panel during a game to read: the fewest chips that make each stack, for
// colouring up or counting one out. Setting the colours is something you do once, on
// the first night, and it was sitting on top of the thing you do at every colour-up.
//
// So the order is the other way round. The legend itself — five colours and five
// amounts, which is what the panel is named for and what it had never actually
// displayed, the values being locked inside number inputs — leads, in two lines. The
// colour-up list follows. The form goes behind a disclosure, which is the shape Table
// Setup already uses twice. Panel: 793px to 441px.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
const PAGE = fs.readFileSync(path.join(ROOT, "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8");
const RENDER = fs.readFileSync(path.join(ROOT, "wwwroot", "js", "casino", "holdem", "render.js"), "utf8");
const DRIVER = fs.readFileSync(path.join(__dirname, "runUiAudit.js"), "utf8");
const panel = () => {
const at = PAGE.indexOf('<summary><h2>Chip Legend</h2>');
assert.ok(at >= 0, "there is no Chip Legend panel");
return PAGE.slice(at, PAGE.indexOf('<details class="sidebarPanel" id="keyboardPanel">', at));
};
const ruleFor = selector => {
const at = CSS.indexOf(`\n${selector} {`);
assert.ok(at >= 0, `there is no \`${selector}\` rule`);
return CSS.slice(at, CSS.indexOf("}", at));
};
// ---------------------------------------------------------------------------
describe("what the panel leads with", () => {
test("the legend, then the stacks, then the form", () => {
const markup = panel();
const strip = markup.indexOf('id="chipLegendStrip"');
const stacks = markup.indexOf('id="colourUp"');
const form = markup.indexOf('id="chipLegendList"');
assert.ok(strip >= 0, "the panel still doesn't show the legend it is named for");
assert.ok(strip < stacks, "the legend is below the colour-up list");
assert.ok(stacks < form, "the form a dealer fills in once is above the list they read all night");
});
test("and the form is behind a disclosure, shut", () => {
const markup = panel();
const at = markup.indexOf('<details class="subPanel">');
assert.ok(at >= 0, "the colour form is not behind a disclosure");
assert.ok(!/<details class="subPanel"[^>]*\bopen\b/.test(markup));
assert.ok(at < markup.indexOf('id="chipLegendList"'), "the disclosure doesn't contain the form");
assert.match(markup.slice(at, at + 200), /<summary>Colours and values<\/summary>/);
});
// The add button and the instructions belong to the form, not to the panel.
test("the instructions went with it", () => {
const markup = panel();
const sub = markup.indexOf('<details class="subPanel">');
assert.ok(markup.indexOf('id="addChipColorBtn"') > sub);
assert.ok(markup.indexOf("Give each chip color a value") > sub);
});
});
describe("the legend line", () => {
test("is built from the same list the form edits", () => {
const at = RENDER.indexOf("renderChipLegendStrip() {");
assert.ok(at >= 0, "nothing draws the legend");
const body = RENDER.slice(at, RENDER.indexOf("\n },", at));
assert.match(body, /this\.state\.chipLegend/);
assert.match(body, /Money\.format/);
assert.match(body, /chipSwatch/);
});
test("and is drawn whenever the list is", () => {
const at = RENDER.indexOf("renderChipLegend() {");
assert.ok(at >= 0);
assert.match(RENDER.slice(at, at + 160), /this\.renderChipLegendStrip\(\);/);
});
// Typing in the form is the one moment the line above it goes stale. Only the
// strip is redrawn — redrawing the list would write the field's own value back
// into it mid-keystroke, which is why everything else here is called one at a time.
test("it keeps up with the field being typed into, and nothing else does", () => {
// Anchored inside renderChipLegend: the seat editor's custom fields have
// listeners spelled the same way, earlier in the file.
const from = RENDER.indexOf("renderChipLegend() {");
assert.ok(from >= 0);
["colorInput.addEventListener(\"input\"", "valueInput.addEventListener(\"input\""].forEach(hook => {
const at = RENDER.indexOf(hook, from);
assert.ok(at >= 0, `there is no ${hook}`);
const body = RENDER.slice(at, RENDER.indexOf("});", at));
assert.match(body, /this\.renderChipLegendStrip\(\);/, `${hook} leaves the legend line stale`);
assert.ok(!/this\.renderChipLegend\(\)/.test(body),
"redrawing the whole list would rewrite the field being typed in");
});
});
test("it wraps rather than running off the side of a 320px sidebar", () => {
const rule = ruleFor(".chipLegendStrip");
assert.match(rule, /display:\s*flex/);
assert.match(rule, /flex-wrap:\s*wrap/);
assert.match(rule, /list-style:\s*none/);
});
});
describe("five ways to delete something", () => {
// The target stays the size the touch rules give it. Only the paint changes: height
// is not what makes a destructive control safe, and these were the loudest thing in
// the panel — one filled red block per row, brighter than the values beside them.
test("the delete buttons are quiet without being small", () => {
const rule = ruleFor("#chipLegendList .removeChipColorBtn");
assert.match(rule, /background:\s*none/);
assert.ok(!/min-height/.test(rule), "de-emphasis is a colour change, never a height change");
assert.ok(!/width/.test(rule));
});
// The dialog that asks "reset the table?" is also holdemBtnDanger, and it is meant
// to be loud.
test("and the confirm dialog's own danger button is untouched", () => {
const at = CSS.indexOf("\n.holdemBtnDanger {");
assert.ok(at >= 0);
assert.match(CSS.slice(at, CSS.indexOf("}", at)), /background:\s*linear-gradient/);
});
test("the red comes back on the way to pressing one", () => {
assert.match(CSS, /#chipLegendList \.removeChipColorBtn:hover \{[^}]*color:\s*#ffd6d6/);
});
});
describe("the list that runs into its own footnote", () => {
test("the colour-up note is spaced off the last stack", () => {
assert.match(ruleFor("#colourUp .colourUpList + .fieldHint"), /margin-top:\s*var\(--sp-\d\)/);
});
});
describe("what the sweep is allowed to shrug at", () => {
// Both twelve-handed shapes, for one stated reason, and only at 20px text.
test("both crowded shapes are notes, not failures, at large text", () => {
const at = DRIVER.indexOf("const CROWDED_BEYOND_HELP");
assert.ok(at >= 0);
assert.match(DRIVER.slice(at, DRIVER.indexOf(";", at)), /\["all in", "packed"\]/);
assert.match(DRIVER, /soft && soft\.includes\(shape\)/);
});
// The arithmetic has to be written down, or the next round reads this as the sweep
// being softened to make something pass.
test("and the arithmetic for why is written down beside it", () => {
const at = DRIVER.indexOf("// The packed table is the same arithmetic");
assert.ok(at >= 0, "the packed shape is exempt with no reason given");
const why = DRIVER.slice(at, DRIVER.indexOf("const CROWDED_BEYOND_HELP", at));
assert.match(why, /315px of puck[\s/]*\/\/?\s*into 294px of felt/);
assert.match(why, /44x63/);
});
// Only at 20px. The same table at the browser's normal size is held to the same
// standard as everything else on the felt.
test("the other two passes are not softened", () => {
assert.match(DRIVER, /\{ hand: "mouse", touch: false, viewports: VIEWPORTS \}/);
assert.match(DRIVER, /\{ hand: "touch", touch: true, viewports: VIEWPORTS \}/);
});
});
-272
View File
@@ -1,272 +0,0 @@
// Typing 0.25 into a box and getting 0.25.
//
// The six setup fields are wired `input` -> save -> Render.renderAll(), and renderAll
// comes back round through renderSetupFields and assigns `.value` to the very box being
// typed in. The decimal point is where that turns into a wrong number rather than a
// wasted render, because a `type="number"` reports a half-typed "0." as "0" — so the
// write-back erased the point, and assigning `.value` to a number input parks the caret
// at position 0, so every digit after it landed at the front.
//
// Measured in Chrome against the page as it stood, keystroke by keystroke:
//
// small blind "0.25" 0 -> 0 -> 20 -> 250 a table playing 250/500
// big blind "0.50" 0 -> 0 -> 50 -> 500
// start stack "12.50" 1 -> 12 -> 12 -> 512 -> 5012
// rake percent "2.5" 2 -> 2 -> 52 a 52% rake
// rake cap "7.50" 7 -> 7 -> 57 -> 507
// base pot "0.75" 0 -> 0 -> 70 -> 750
//
// Nothing warned: every one of those is a valid number, so no validation could have
// caught it, and the dealer's own table is the only place it shows.
//
// Two rules hold it shut, and they are separate on purpose. One stops a render writing
// over somebody's half-finished typing at all. The other takes the fields off
// `type="number"`, which is what made a half-finished number unreadable in the first
// place — and is what GOV.UK's design system concluded after user testing, for this and
// four other reasons.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const { Render, Money } = require("./load");
const MARKUP = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
// A stand-in for one form control, and a document that knows which one has focus.
const field = (value = "") => ({ value, checked: false });
const withFocus = focused => {
global.document = { activeElement: focused || null };
};
test.afterEach(() => { delete global.document; });
// ---------------------------------------------------------------------------
describe("a render does not type for you", () => {
test("the box somebody is standing in is left exactly as they left it", () => {
// The whole fix in one assertion. "0." is not a number and state has read it as
// 0; writing that 0 back is what ate the point.
const box = field("0.");
withFocus(box);
Render.setField(box, 0);
assert.equal(box.value, "0.",
"the render wrote over a half-typed number, which is how 0.25 became 250");
});
test("and the box nobody is in is brought up to date", () => {
const box = field("2");
withFocus(null);
Render.setField(box, 5);
assert.equal(box.value, "5");
});
test("a value that has not changed is not written at all", () => {
// Assigning an identical value still moves the caret in some engines, so this
// is part of the guard rather than a saving.
const box = field("5");
let writes = 0;
Object.defineProperty(box, "value", {
get: () => "5",
set: () => { writes += 1; },
});
withFocus(null);
Render.setField(box, 5);
assert.equal(writes, 0, "an unchanged value was assigned anyway");
});
test("null and undefined clear the box rather than spelling themselves out", () => {
withFocus(null);
const a = field("9");
const b = field("9");
Render.setField(a, null);
Render.setField(b, undefined);
assert.equal(a.value, "");
assert.equal(b.value, "", "an unset blind would have read the word 'undefined'");
});
test("and a box that isn't there is not an error", () => {
withFocus(null);
assert.doesNotThrow(() => Render.setField(null, 5));
assert.doesNotThrow(() => Render.setToggle(null, true));
});
});
describe("the same rule for a switch", () => {
test("a checkbox somebody is on is not flipped under them", () => {
const box = field();
box.checked = true;
withFocus(box);
Render.setToggle(box, false);
assert.equal(box.checked, true,
"a render flipped a switch the dealer had their finger on");
});
test("and one nobody is on keeps up with the state", () => {
const box = field();
box.checked = true;
withFocus(null);
Render.setToggle(box, false);
assert.equal(box.checked, false);
});
});
describe("what a typed-in box is worth", () => {
// The fields are type="text" now, so what arrives is what was typed — including
// things that are not numbers. Number("abc") is NaN, and NaN must not become a blind.
test("a number is worth that number", () => {
assert.equal(Money.readAmount("0.25"), 0.25);
assert.equal(Money.readAmount("12.50"), 12.5);
assert.equal(Money.readAmount("200"), 200);
});
test("nothing typed yet is not zero", () => {
// Null and 0 mean different things to the fields that use this: an unset
// starting stack falls back to the table default, a zero one does not.
assert.equal(Money.readAmount(""), null);
assert.equal(Money.readAmount(" "), null);
assert.equal(Money.readAmount(null), null);
assert.equal(Money.readAmount(undefined), null);
});
test("and neither is something that is not a number", () => {
["abc", "1.2.3", "$5", "--", "1,5"].forEach(text => {
assert.equal(Money.readAmount(text), null,
`"${text}" came back as something other than null`);
});
});
test("infinity is not a stack", () => {
assert.equal(Money.readAmount("Infinity"), null);
assert.equal(Money.readAmount("1e999"), null);
});
test("a half-typed number is read as what it is so far", () => {
// "0." arrives here now instead of being flattened to "0" by the control. It is
// worth 0 at this instant, and the next keystroke will say otherwise — what
// matters is that the box still reads "0." when it does.
assert.equal(Money.readAmount("0."), 0);
assert.equal(Money.readAmount(".5"), 0.5);
});
test("and it comes back rounded to the cent the tool works in", () => {
assert.equal(Money.readAmount("0.005"), 0.01);
assert.equal(Money.readAmount("1.004"), 1);
});
test("a negative is left alone rather than quietly clamped", () => {
// A typed "-5" is a different mistake from a mistyped one, and the callers that
// care clamp for themselves. Folding it in here would change what the dealer
// asked for without saying so.
assert.equal(Money.readAmount("-5"), -5);
});
});
describe("the boxes themselves", () => {
const inputs = MARKUP.match(/<input\b[^>]*>/g) || [];
const attr = (tag, name) => {
const m = tag.match(new RegExp(`${name}="([^"]*)"`));
return m ? m[1] : null;
};
const named = tag => attr(tag, "id") || attr(tag, "class") || tag.slice(0, 40);
test("none of them is a type=number any more", () => {
const spinners = inputs.filter(t => attr(t, "type") === "number");
assert.deepEqual(spinners.map(named), [],
"a type=number is back: it reports a half-typed '0.' as '0', which is the " +
"read that let a render write the decimal point away");
});
test("every box that takes digits says which keypad it wants", () => {
// The digits fields are the ones this round converted. Identified by name rather
// than by type, because their type is exactly what no longer distinguishes them.
const DIGITS = [
"seatCountInput", "smallBlindInput", "bigBlindInput", "defaultStackInput",
"rakePercentInput", "rakeCapInput", "basePotInput",
"stackInput", "amountOverrideInput", "chipValueInput", "chipCountInput",
"addChipsInput",
];
DIGITS.forEach(name => {
const tag = inputs.find(t => named(t) === name);
assert.ok(tag, `${name} is gone from the markup`);
const mode = attr(tag, "inputmode");
assert.ok(mode === "decimal" || mode === "numeric",
`${name} has inputmode="${mode}" — without one a phone offers a ` +
`full keyboard for a number, which is the slowest thing on the page`);
});
});
test("the money ones ask for the separator, the counting ones do not", () => {
// "decimal" carries the reader's decimal separator; "numeric" is bare digits.
// A blind needs the point. A seat count would only be a way to type a wrong one.
const modeOf = name => attr(inputs.find(t => named(t) === name), "inputmode");
["smallBlindInput", "bigBlindInput", "defaultStackInput", "rakeCapInput",
"basePotInput", "stackInput", "chipValueInput"].forEach(name => {
assert.equal(modeOf(name), "decimal", `${name} cannot be given cents`);
});
["seatCountInput", "chipCountInput"].forEach(name => {
assert.equal(modeOf(name), "numeric", `${name} counts things, not money`);
});
});
test("and none of them invites the browser to fill it in", () => {
// A saved address offering itself as a small blind is the browser being helpful
// about the one field on the page it cannot know anything about.
["smallBlindInput", "bigBlindInput", "defaultStackInput", "seatCountInput",
"rakePercentInput", "rakeCapInput", "basePotInput"].forEach(name => {
const tag = inputs.find(t => named(t) === name);
assert.equal(attr(tag, "autocomplete"), "off", `${name} may be autofilled`);
});
});
});
describe("what still counts as a number box", () => {
// Money.selectsOnFocus decides whether tapping a pre-filled amount box selects what
// is in it. It used to recognise those boxes by type="number"; there are none left.
test("a text box with a decimal keypad is one", () => {
assert.equal(Money.selectsOnFocus(
{ tagName: "INPUT", type: "text", inputMode: "decimal" }), true);
});
test("and so is one with a numeric keypad", () => {
// The seat count and the chip count. Both open pre-filled, both were caught by
// the old type check, and reading only "decimal" would have dropped them.
assert.equal(Money.selectsOnFocus(
{ tagName: "INPUT", type: "text", inputMode: "numeric" }), true);
});
test("a plain text box is not", () => {
assert.equal(Money.selectsOnFocus(
{ tagName: "INPUT", type: "text", inputMode: "" }), false,
"a player's name would be wiped by the first keystroke after a tap");
});
});
describe("a wheel has nothing left to spin", () => {
test("no field in the tool is a spinbutton any more", () => {
// wheelWouldEdit answers false for every field now, which is the point rather
// than a regression: a text box is not a spinbutton, so a wheel over it cannot
// change it. The guard stays because it is what makes that safe to rely on.
assert.equal(Money.wheelWouldEdit(
{ tagName: "INPUT", type: "text", inputMode: "decimal" },
{ tagName: "INPUT", type: "text", inputMode: "decimal" }), false);
});
test("but it would still catch one if somebody added it back", () => {
const spinner = { tagName: "INPUT", type: "number" };
assert.equal(Money.wheelWouldEdit(spinner, spinner), true);
});
});
-245
View File
@@ -1,245 +0,0 @@
// Fifty-two cards on a phone.
//
// The picker put the whole deck on the screen at once — four suits across, thirteen
// ranks down — and that grid is 646px tall. Measured with the board tracked and seven
// cards already out:
//
// 640x360 816px of content in a 317px sheet, 40 of the 52 below the fold
// 667x375 816 in 330, 40 below
// 844x390 774 in 343, 36 below
// 375x667 816 in 587, 20 below
// 1280x900 774 in 775, none
//
// So on every phone the tool is actually used on, entering the flop began with a
// scroll, and a dealer looking for the nine of diamonds read down a column to find it.
// There is no arrangement that fixes that: fifty-two targets at the 44px this sweep
// holds everything else to need 572px of width before gaps if they are thirteen across,
// which is wider than any phone in portrait, and thirteen rows deep if they are four.
//
// A card is not one choice out of fifty-two. It is two — a suit and a rank — and that
// is how a dealer says it out loud. So the sheet asks them in that order: four suits on
// a row, then the thirteen ranks of the one chosen. Seventeen targets instead of
// fifty-two, none of them smaller than they were, and measured afterwards at eight
// viewports from 360x640 to 1280x900: nothing below the fold, nothing scrolling, every
// button still at least 44x44 and still hit-testing as itself.
//
// The second tap is not a second cost in the usual case. The suit stays where it was
// put, so a second card of the same suit is one tap — and picking one card used to be
// one tap plus a scroll through thirteen rows.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const { CardPicker } = require("./load");
const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
const PAGE = fs.readFileSync(path.join(ROOT, "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8");
const SRC = fs.readFileSync(path.join(ROOT, "wwwroot", "js", "casino", "holdem", "cardPicker.js"), "utf8");
const ruleFor = selector => {
const at = CSS.indexOf(`\n${selector} {`);
assert.ok(at >= 0, `there is no \`${selector}\` rule`);
return CSS.slice(at, CSS.indexOf("}", at));
};
const methodBody = name => {
const at = SRC.indexOf(`\n ${name}`);
assert.ok(at >= 0, `cardPicker has no ${name}`);
return SRC.slice(at, SRC.indexOf("\n },", at));
};
// The picker reaches for the global document in two places. Neither is exercised here —
// these tests ask what it decides, not what it draws — but aimSuit reads the slot it is
// about to fill, so it needs the state and nothing else.
function aimedAt(board, cardIndex, { suit = "S", cursor = 0 } = {}) {
CardPicker.state = { board: board.slice(), seats: [] };
CardPicker.target = "board";
CardPicker.boardLimit = board.length;
CardPicker.cardIndex = cardIndex;
CardPicker.suit = suit;
CardPicker.cursor = cursor;
CardPicker.aimSuit();
return { suit: CardPicker.suit, cursor: CardPicker.cursor };
}
// ---------------------------------------------------------------------------
describe("the deck is still the deck", () => {
test("all fifty-two of it, ace down to two", () => {
const deck = CardPicker.deck();
assert.equal(deck.length, 52);
assert.equal(new Set(deck.map(entry => entry.card)).size, 52);
assert.equal(deck[0].card, "AS");
assert.equal(deck[51].card, "2C");
});
// Which is what the duplicate check reads, and why splitting the *grid* by suit
// cannot let the same card be dealt twice.
test("and a card already out is still a card already out, in any suit", () => {
CardPicker.state = {
seats: [{ name: "Mike", cards: ["AS", "KH"] }],
board: ["7D", "", "", "", ""],
};
CardPicker.target = "board";
CardPicker.boardLimit = 5;
CardPicker.cardIndex = 1;
const used = CardPicker.cardsInUse();
assert.equal(used.get("AS"), "Mike");
assert.equal(used.get("KH"), "Mike");
assert.equal(used.get("7D"), "the board");
assert.equal(used.size, 3);
});
});
describe("one suit at a time", () => {
test("the grid is thirteen ranks of the chosen suit, in deck order", () => {
CardPicker.suit = "D";
const cards = CardPicker.gridCards();
assert.equal(cards.length, 13);
assert.deepEqual(cards.map(entry => entry.card).slice(0, 3), ["AD", "KD", "QD"]);
assert.equal(cards[12].card, "2D");
assert.ok(cards.every(entry => entry.suit.code === "D"));
});
test("every suit gets the same thirteen", () => {
CardPicker.SUITS.forEach(suit => {
CardPicker.suit = suit.code;
assert.deepEqual(CardPicker.gridCards().map(entry => entry.rank), CardPicker.RANKS);
});
});
// The glyph is the whole of the control on the eye, and "♦, radio button" is not a
// suit out loud.
test("and a suit has a name to be called by", () => {
assert.deepEqual(CardPicker.SUITS.map(suit => suit.name),
["Spades", "Hearts", "Diamonds", "Clubs"]);
assert.match(methodBody("paint()"), /aria-label", suit\.name/);
});
test("an unknown suit code falls back rather than emptying the grid", () => {
assert.equal(CardPicker.suitOf("Z").code, "S");
assert.equal(CardPicker.suitOf("H").symbol, "♥");
});
});
describe("where the sheet opens", () => {
// The dealer came here to change that card. Hunting for where it lives is work the
// sheet can do.
test("on the suit of the card already in the slot, with it under the cursor", () => {
assert.deepEqual(aimedAt(["7H", "2D", "TS", "", ""], 0), { suit: "H", cursor: 7 });
assert.deepEqual(aimedAt(["7H", "2D", "TS", "", ""], 1), { suit: "D", cursor: 12 });
assert.deepEqual(aimedAt(["7H", "2D", "TS", "", ""], 2), { suit: "S", cursor: 4 });
});
// Which is what makes a flush board, or two suited hole cards, one tap each after
// the first.
test("and an empty slot leaves the suit where the dealer put it", () => {
assert.deepEqual(aimedAt(["7H", "", "", "", ""], 1, { suit: "H", cursor: 7 }),
{ suit: "H", cursor: 7 });
});
test("junk in a slot is left alone rather than read as a suit", () => {
assert.deepEqual(aimedAt(["ZZ", "", "", "", ""], 0, { suit: "C", cursor: 3 }),
{ suit: "C", cursor: 3 });
assert.deepEqual(aimedAt(["", "", "", "", ""], 0, { suit: "C", cursor: 3 }),
{ suit: "C", cursor: 3 });
});
test("both ways in ask the question before they draw", () => {
["open(state, hooks, seatIndex, cardIndex) {", "openBoard(state, hooks, cardIndex, limit = 5) {"]
.forEach(signature => {
const at = SRC.indexOf(signature);
assert.ok(at >= 0, `there is no \`${signature}\``);
assert.match(SRC.slice(at, SRC.indexOf("this.render();", at)), /this\.aimSuit\(\);/);
});
});
});
describe("changing suit is not moving", () => {
// The cursor is a rank. The same rank in another suit is the card beside the one you
// were on, so coming back to the ace would undo the only thing the dealer had said.
test("the rank under the cursor survives the change", () => {
const body = methodBody("setSuit(code, { focus = false } = {}) {");
assert.ok(!/cursor/.test(body), "setSuit still has an opinion about where the cursor is");
});
test("and asking for the suit already showing redraws nothing", () => {
const body = methodBody("setSuit(code, { focus = false } = {}) {");
assert.match(body, /if \(this\.suit === code\) return;/);
});
// The APG's radio group: one Tab stop for the set, arrows to move within it, and
// moving is choosing. Wrapping is right here and wrong in the grid below — four
// suits are a ring with no ends, thirteen ranks have two.
test("the arrows run round the four suits", () => {
const body = methodBody("onSuitKey(event)");
assert.match(body, /ArrowRight: 1.*ArrowDown: 1.*ArrowLeft: -1.*ArrowUp: -1/s);
assert.match(body, /% this\.SUITS\.length/);
assert.match(body, /event\.preventDefault\(\)/);
});
});
describe("what the markup and the stylesheet have to agree about", () => {
// Not a tablist: the grid below is not another panel of content, it is the same
// thirteen controls filtered, and single-select filtering is what a radio group is.
test("the suits are a radio group with a name", () => {
const at = PAGE.indexOf('id="cardPickerSuits"');
assert.ok(at >= 0, "there is no suit row");
const tag = PAGE.slice(PAGE.lastIndexOf("<", at), PAGE.indexOf(">", at));
assert.match(tag, /role="radiogroup"/);
assert.match(tag, /aria-label="Suit"/);
});
test("each suit says whether it is the chosen one, and only one is a Tab stop", () => {
const body = methodBody("paint()");
assert.match(body, /setAttribute\("role", "radio"\)/);
assert.match(body, /aria-checked", chosen \? "true" : "false"/);
assert.match(body, /tabIndex = chosen \? 0 : -1/);
});
test("and the grid says which suit's thirteen these are", () => {
const at = PAGE.indexOf('id="cardPickerGrid"');
assert.ok(at >= 0);
assert.match(PAGE.slice(at, PAGE.indexOf(">", at)), /aria-labelledby="cardPickerGridLabel"/);
assert.match(methodBody("paint()"), /gridLabel\.textContent = `\$\{this\.suitOf\(this\.suit\)\.name\}/);
});
// A row of four, in the same four places every time, so the one the dealer wants is
// somewhere their thumb already knows.
test("the suit row is four fixed columns, not as many as fit", () => {
assert.match(ruleFor("#cardPickerSuits"), /grid-template-columns:\s*repeat\(4, minmax\(0, 1fr\)\)/);
});
test("a suit is a target before it is a glyph", () => {
assert.match(ruleFor(".pickerSuitTab"), /min-height:\s*48px/);
});
// Drawing the three unchosen suits in one grey made a dealer reaching for the hearts
// read four shapes to find them, which is the search this sheet exists to remove.
test("red is red whether or not it is the one chosen", () => {
assert.match(ruleFor(".pickerSuitTab.cardRed"), /color:\s*#e07b7b/);
assert.match(ruleFor(".pickerSuitTab.chosen"), /background:\s*#fdfdfd/);
assert.match(ruleFor(".pickerSuitTab.cardRed.chosen"), /color:\s*#b32020/);
});
// Five across on a 360px phone, seven at 460, more on a phone held sideways.
test("the ranks take as many columns as the row holds", () => {
assert.match(ruleFor("#cardPickerGrid"),
/grid-template-columns:\s*repeat\(auto-fill, minmax\(3\.25rem, 1fr\)\)/);
});
// Which is why the arrows cannot assume a number any more.
test("so the arrows read the column count back off the layout", () => {
assert.match(methodBody("onGridKey(event)"), /const stride = this\.gridStride\(\)/);
assert.match(methodBody("gridStride()"), /getComputedStyle\(grid\)\.gridTemplateColumns/);
});
test("what was measured is written beside the code", () => {
const note = SRC.slice(0, SRC.indexOf("const CardPicker = {"));
assert.match(note, /646px/);
assert.match(note, /40 of the 52/);
assert.match(note, /572px/);
});
});
-111
View File
@@ -1,111 +0,0 @@
// Forty-four pixels, meaning forty-four pixels.
//
// Every input and select a dealer touches has a rule asking for `min-height: 44px`, and
// not one of them was 44px tall. Form controls are content-box by default, so 44 was
// being read as the height of the text inside and 8px of padding top and bottom plus a
// 1px border each side went on the outside of it. Measured on an iPhone: all fifteen
// settings fields in the sidebar stood at 62px, and the raise pad's amount box at 66px
// with the text turned up.
//
// Nothing looked wrong, which is why it lasted eleven rounds. 62px clears a 44px floor,
// so the sweep — which asks whether a target is big enough, never whether it is the size
// it was asked to be — had nothing to say about it. The cost is only visible in the
// aggregate: fifteen fields at 18px over is 180px of sidebar, and the raise field's 10px
// is 10px of felt, because the action bar's height is published into --feltRoomPx and
// the table is laid out from what is left.
//
// This is the third time this stylesheet has been caught by content-box — the sidebar's
// panel headers and the rulings sheet's summaries are the other two, and both carry a
// note saying so. The note is in the stylesheet; the list of what it covers is here.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const CSS = fs.readFileSync(path.join(
__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
// The rule, the selectors it is written against, and the note above it saying why.
const boxSizing = () => {
const note = CSS.indexOf("/* A field is as tall as it says it is.");
assert.ok(note >= 0, "nothing in the stylesheet asks a field to be the height it states");
const at = CSS.indexOf("box-sizing: border-box;", note);
assert.ok(at >= 0);
return CSS.slice(note, CSS.indexOf("}", at));
};
// Every selector anywhere in the stylesheet that gives a typing field a 44px floor,
// wherever the media query that holds it happens to live. Checkboxes and colour swatches
// are left out: they are sized by an explicit width and height rather than by a floor
// their padding can push past, which is the whole of the bug this guards.
const touchSized = () => {
const found = [];
let from = 0;
for (;;) {
const hit = CSS.indexOf("min-height: 44px", from);
if (hit === -1) break;
from = hit + 1;
const opens = CSS.lastIndexOf("{", hit);
const prior = Math.max(CSS.lastIndexOf("}", opens), CSS.lastIndexOf("{", opens - 1),
CSS.lastIndexOf("*/", opens));
CSS.slice(prior + 1, opens).split(",")
.map(line => line.trim())
.filter(Boolean)
.forEach(selector => found.push(selector));
}
return found.filter(selector =>
/\b(input|select|textarea)\b|\.raiseInput/.test(selector) &&
!/\[type="(checkbox|color)"\]/.test(selector));
};
// ---------------------------------------------------------------------------
describe("a field is the height it says it is", () => {
const rule = boxSizing();
// Every field, not a list of the surfaces fields sit on. The list version of this
// rule was written first and had already missed one — see the note.
test("said about the elements, so nothing can sit outside the list", () => {
assert.match(rule, /(^|\n)input,\s*\n\s*select,\s*\n\s*textarea \{/);
});
// The one field whose height is not only its own: it sets the action bar's height,
// the bar's height is published into --feltRoomPx, and the felt is laid out from
// what is left. Taking 10px off the bar gave them to the table.
test("the felt's stake in it is written down", () => {
assert.match(rule, /--feltRoomPx/);
assert.match(rule, /248px to[\s*]*238/);
});
test("and so is the measurement that found it", () => {
assert.match(rule, /62px/);
assert.match(rule, /fifteen settings fields/);
});
// Two other rules in this file already carry a content-box note; this is the third
// place it has bitten, and the note is what stops it being a fourth.
test("and what it is about, so the next one is recognised", () => {
assert.match(rule, /content-box/);
assert.match(rule, /padding/);
});
});
describe("nothing asks for a height it will not get", () => {
// A durable guard rather than a snapshot. If a later round gives a field a 44px
// floor, that floor is only the height the author meant while this rule reaches the
// field — and nothing else in the suite would notice if it stopped.
test("every field given a touch height is measured the way that height assumes", () => {
const fields = touchSized();
assert.ok(fields.length >= 4, `only ${fields.length} field rules found — the scrape has drifted`);
// The rule names the element types themselves, so the only way a field escapes
// is a later round narrowing it back to a list of places.
const rule = boxSizing();
fields.forEach(selector => {
const kind = (selector.match(/\b(input|select|textarea)\b/) || [])[1]
|| (/\.raiseInput/.test(selector) ? "input" : null);
assert.ok(kind && new RegExp(`(^|\\n|,\\s*)${kind}\\b`).test(rule),
`\`${selector}\` is given a touch height that box-sizing no longer reaches`);
});
});
});
-210
View File
@@ -1,210 +0,0 @@
// What Table Setup holds while it is open.
//
// It is the one panel the tool opens by itself, on a table nobody has set up yet — so
// it is most of what a dealer sees the first time they arrive. Measured on a fresh
// table, before this: the panel stood 886px on a 667px phone, and the whole page 2076.
//
// The order was the problem more than the length. The two figures the panel's own
// summary glances at — "10 seats · $1/$2" — were the seat count at the top and the
// blinds 218px further down, under three switches and three paragraphs explaining
// them: a dedicated dealer, a card sheet at every street, and whether the phone is
// allowed to sleep. A dealer setting up a $1/$2 game read all of that on the way to
// the two boxes they came for.
//
// The three have gone into "While you deal", with the paragraphs that explain them.
// That is not a new idea in this panel — House take and Without live betting were
// already nested disclosures — it is the same idea applied to the things that were not
// about money. Afterwards: the panel is 529px, the page 1719 on the same phone and
// 1275 on a 1280x900 desktop, and the first two rows are the seats and the blinds.
//
// A switch nobody can see is a switch nobody remembers turning on, so the disclosure
// carries a glance of its own.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const { Render, makeTable } = require("./load");
const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
const PAGE = fs.readFileSync(path.join(ROOT, "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8");
const MAIN = fs.readFileSync(path.join(ROOT, "wwwroot", "js", "casino", "holdem", "main.js"), "utf8");
// The Table Setup panel, from its opening tag to the panel after it.
const setupPanel = () => {
const at = PAGE.indexOf('<details class="sidebarPanel" id="tableSetupPanel">');
assert.ok(at >= 0, "there is no Table Setup panel");
const end = PAGE.indexOf('<details class="sidebarPanel"', at + 1);
assert.ok(end > at, "the panel after Table Setup has gone");
return PAGE.slice(at, end);
};
// What the panel shows without anything being opened: everything before the first
// nested disclosure.
const alwaysShowing = () => {
const panel = setupPanel();
const firstSub = panel.indexOf('<details class="subPanel">');
assert.ok(firstSub >= 0, "the panel has no nested disclosures at all");
return panel.slice(0, firstSub);
};
const ruleFor = selector => {
const at = CSS.indexOf(`\n${selector} {`);
assert.ok(at >= 0, `there is no \`${selector}\` rule`);
return CSS.slice(at, CSS.indexOf("}", at));
};
const glance = state => (Render.panelGlances(state).glanceWhileYouDeal || {}).text;
const table = () => makeTable({ seatCount: 9, seated: [], stack: 0 });
// ---------------------------------------------------------------------------
describe("what the open panel leads with", () => {
// The two the summary already glances at. They were first and seventh.
test("the seats and the blinds, in that order, above everything else", () => {
const body = alwaysShowing();
const seats = body.indexOf('id="seatCountInput"');
const small = body.indexOf('id="smallBlindInput"');
const big = body.indexOf('id="bigBlindInput"');
const stack = body.indexOf('id="defaultStackInput"');
assert.ok(seats >= 0 && small >= 0 && big >= 0 && stack >= 0,
"one of the four numbers is no longer in the part of the panel that shows");
assert.ok(seats < small, "the seat count is below the blinds");
assert.ok(small < big && big < stack, "the blinds and the stack have swapped");
});
test("and nothing in it is a switch about how the tool behaves", () => {
const body = alwaysShowing();
["dedicatedDealerToggle", "trackBoardToggle", "keepScreenAwakeToggle"].forEach(id => {
assert.ok(!body.includes(id), `${id} is still on the panel's first screen`);
});
});
// One line, and it is about the boxes directly above it.
test("one paragraph survives, the short one", () => {
const hints = alwaysShowing().match(/<p class="fieldHint">/g) || [];
assert.equal(hints.length, 1, "the panel's first screen is back to being prose");
assert.match(alwaysShowing(), /Blinds post themselves each hand/);
});
});
describe("where the three switches went", () => {
const dealing = () => {
const panel = setupPanel();
const at = panel.indexOf("<summary>While you deal");
assert.ok(at >= 0, 'there is no "While you deal" disclosure');
return panel.slice(panel.lastIndexOf("<details", at), panel.indexOf("</details>", at));
};
test("all three of them, with the paragraphs that explain them", () => {
const body = dealing();
["dedicatedDealerToggle", "trackBoardToggle", "keepScreenAwakeToggle"].forEach(id => {
assert.ok(body.includes(id), `${id} did not come with the rest`);
});
assert.match(body, /no seat shows the dealer button/);
assert.match(body, /a sheet asks which cards came off the deck/);
assert.match(body, /Stops the phone dimming and locking/);
});
// The same shape House take and Without live betting already had. A disclosure that
// ships open is a disclosure that has saved nobody anything.
test("it is shut, like the two money ones beside it", () => {
const panel = setupPanel();
assert.ok(!/<details class="subPanel"[^>]*\bopen\b/.test(panel),
"a nested disclosure in this panel ships open");
});
test("and it sits above them, because it is not about money", () => {
const panel = setupPanel();
assert.ok(panel.indexOf("While you deal") < panel.indexOf("<summary>House take"));
assert.ok(panel.indexOf("<summary>House take") < panel.indexOf("<summary>Without live betting"));
});
});
describe("the glance on a shut disclosure", () => {
test("silence when all three are where they ship", () => {
const state = table();
assert.equal(glance(state), "");
});
test("and a name when one of them is not", () => {
const state = table();
state.trackBoard = true;
assert.equal(glance(state), "Asking for cards");
const other = table();
other.dedicatedDealer = true;
assert.equal(glance(other), "House dealer");
});
// The only one that ships on, so it is its absence that is worth saying.
test("the screen setting speaks by going quiet, not by being on", () => {
const on = table();
on.keepScreenAwake = true;
assert.equal(glance(on), "");
const off = table();
off.keepScreenAwake = false;
assert.equal(glance(off), "Screen may sleep");
});
// Joined, the three run to 49 characters, and this row is 44px of a 320px column
// with a label already on it. "House dealer · Asking for c…" has told nobody
// anything; the one most likely to be the surprise has.
test("one of them, never a list of three", () => {
const state = table();
state.trackBoard = true;
state.dedicatedDealer = true;
state.keepScreenAwake = false;
assert.equal(glance(state), "Asking for cards");
assert.ok(glance(state).length <= 24);
});
test("it is a row this sidebar knows how to write", () => {
assert.ok(Render.PANEL_GLANCES.includes("glanceWhileYouDeal"),
"the glance is computed and never written, so the row keeps its old text all night");
const panel = setupPanel();
const at = panel.indexOf('id="glanceWhileYouDeal"');
assert.ok(at >= 0, "there is nowhere to write it");
assert.match(panel.slice(panel.lastIndexOf("<span", at), at + 40), /class="panelGlance subPanelGlance"/);
assert.match(panel.slice(at, panel.indexOf(">", at)), /hidden/);
});
});
describe("what had to be true for it to hang there", () => {
// Without this the span anchors to whichever ancestor is positioned, which is the
// sidebar — the glance would appear at the top right of the whole column.
test("the summary it hangs off is a positioned box", () => {
assert.match(ruleFor(".subPanel > summary"), /position:\s*relative/);
});
// A footnote on a line that is itself a footnote. At the weight of "Off by $40" the
// eye stops on a row saying nothing has gone wrong.
test("and it is drawn a step quieter than the panel headings' own", () => {
const sub = ruleFor(".subPanelGlance");
assert.match(sub, /font-size:\s*var\(--fs-xs\)/);
assert.match(sub, /color:\s*var\(--text-dim\)/);
assert.match(ruleFor(".panelGlance"), /font-size:\s*var\(--fs-sm\)/);
});
});
describe("the panel still opens itself only when it should", () => {
test("on a table nobody has touched, and not on the fourth hand", () => {
const at = MAIN.indexOf('document.getElementById("tableSetupPanel")');
assert.ok(at >= 0, "nothing decides whether the panel starts open");
const block = MAIN.slice(at, at + 400);
assert.match(block, /Number\(state\.handNumber\) \|\| 1\) === 1/);
assert.match(block, /!state\.seats\.some\(seat => HoldemState\.seatInPlay\(seat\)\)/);
assert.match(block, /panel\.open = fresh/);
});
test("what was measured is written beside the decision", () => {
const at = MAIN.indexOf("Table Setup used to ship open");
assert.ok(at >= 0);
const note = MAIN.slice(at, at + 1200);
assert.match(note, /886px/);
assert.match(note, /529px/);
});
});
+3 -3
View File
@@ -97,9 +97,9 @@ describe("the header is one row on a phone", () => {
});
test("and the buttons grow into that width without ever shrinking below their labels", () => {
// `flex: 1 1 0` would divide the row into equal shares: at 360px the row is 269
// wide, so mid-hand that is 87px each and "Next Hand ⟳" needs 89 — 73 of text and
// 16 of padding. These are `white-space: nowrap`, so what gives is the text.
// `flex: 1 1 0` would divide the row into equal thirds and squeeze "Next Hand ⟳"
// — 105px of text — into 99. These are `white-space: nowrap`, so what gives is
// the text.
const rule = ruleIn(PHONE(), "#holdemHeaderActions .holdemBtn") || "";
assert.match(rule, /flex:\s*1\s+1\s+auto/, "an auto basis makes the label the floor");
assert.match(rule, /white-space:\s*nowrap/);
-413
View File
@@ -1,413 +0,0 @@
// Where a sheet puts the dealer, and whether they can get anywhere from there.
//
// Modal exists because six overlays were each missing the same three things, and it
// fixed all six at once by answering one question the same way everywhere: focus the
// first control, and hold Tab between the first and the last. Measured this round, that
// single answer had gone wrong in three separate directions.
//
// It could not see a <summary>. The rulings sheet is twelve of them and one Done
// button, so the trap decided Done was both ends of the ring and bounced every press
// back to it: eighty Tab presses, one element ever reached, twelve rulings that could
// not be opened without a mouse.
//
// "The first control" was the card picker's Clear. The sheet a dealer opens to write a
// card down opened on the button that rubs one out — the one thing the APG says to keep
// a dialog off. And "the first control" is not an answer six dialogs share: Roselli's
// rule is that a dialog long enough to scroll should land on its heading so the reader
// can orient, and a short question with one irreversible answer should land on the
// answer that leaves them no worse off.
//
// And a sheet that redraws itself was deleting the control that held focus. Choosing a
// buy-in, picking a card, switching slots, confirming a side pot: all four left focus on
// the document body, which inside a modal is nowhere at all.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const { Modal, CardPicker } = require("./load");
const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
const PAGE = fs.readFileSync(path.join(ROOT, "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8");
const src = name => fs.readFileSync(
path.join(ROOT, "wwwroot", "js", "casino", "holdem", `${name}.js`), "utf8");
// The markup of one overlay, from its opening div to the next one's.
const OVERLAY_ORDER = ["addChipsOverlay", "rulingsOverlay", "seatMenuOverlay",
"cardPickerOverlay", "confirmOverlay", "potWizardOverlay"];
const overlay = id => {
const at = PAGE.indexOf(`<div id="${id}"`);
assert.ok(at >= 0, `there is no ${id}`);
const ends = OVERLAY_ORDER
.map(other => PAGE.indexOf(`<div id="${other}"`, at + 1))
.filter(i => i > at);
return PAGE.slice(at, ends.length ? Math.min(...ends) : PAGE.length);
};
// A document small enough to answer the handful of questions these functions ask of
// one, and honest about the two that matter: a detached node knows it is detached, and
// a disabled button refuses focus.
function fakeSheet(specs) {
const doc = { activeElement: null };
const nodes = specs.map(spec => ({
dataset: spec.key === undefined ? {} : { focusKey: spec.key },
disabled: !!spec.disabled,
isConnected: true,
tabIndex: 0,
label: spec.key || spec.label,
focus() {
if (this.disabled) return;
doc.activeElement = this;
},
}));
const root = {
nodes,
contains: el => nodes.includes(el),
querySelectorAll: () => nodes.filter(n => n.dataset.focusKey !== undefined),
};
return { doc, root, nodes };
}
// preservingFocus and the picker's cursor both reach for the global document. They are
// given one for the length of a test and it is taken away again afterwards.
function withDocument(doc, body) {
const had = Object.prototype.hasOwnProperty.call(globalThis, "document");
const before = globalThis.document;
globalThis.document = doc;
try {
return body();
} finally {
if (had) globalThis.document = before;
else delete globalThis.document;
}
}
// ---------------------------------------------------------------------------
describe("what counts as somewhere you can Tab to", () => {
const selectors = Modal.FOCUSABLE.split(",");
// The whole of the rulings sheet hung on this one omission.
test("a summary is a control, because the browser already treats it as one", () => {
assert.ok(selectors.some(s => /details\s*>\s*summary/.test(s)),
"a <details> summary is focusable without a tabindex, and this list can't see it");
});
// Left off, a card taken out of the Tab order still matches `button`, and the grid
// goes back to being fifty-two stops with a roving tabindex painted on top.
test("every line excludes what has been taken out of the Tab order, not just the last", () => {
selectors.forEach(selector => {
assert.ok(/:not\(\[tabindex="-1"\]\)/.test(selector) || /^\s*\[tabindex\]/.test(selector),
`\`${selector.trim()}\` still matches controls that are not Tab stops`);
});
});
test("and the sheet itself is still off the list", () => {
assert.ok(/\[tabindex\]:not\(\[tabindex="-1"\]\)/.test(Modal.FOCUSABLE));
});
});
describe("where each sheet lands the dealer", () => {
const LANDS_ON_ITS_HEADING = [
["cardPickerOverlay", "cardPickerTitle"],
["addChipsOverlay", "addChipsTitle"],
["rulingsOverlay", "rulingsTitle"],
["potWizardOverlay", "potWizardTitle"],
["seatMenuOverlay", "seatMenuTitle"],
];
LANDS_ON_ITS_HEADING.forEach(([id, heading]) => {
test(`${id} opens on the line that says which`, () => {
const markup = overlay(id);
const tag = markup.match(new RegExp(`<h2 id="${heading}"[^>]*>`));
assert.ok(tag, `${heading} is not an h2 in ${id}`);
assert.match(tag[0], /data-modal-focus/);
assert.match(tag[0], /tabindex="-1"/,
"a heading that is a Tab stop is a Tab stop the dealer has to press past");
});
});
// The APG's own example of when to land on a control rather than a heading, and the
// reason: one of the two answers cannot be taken back.
test("the confirm dialog is the exception, and it lands on Cancel", () => {
const markup = overlay("confirmOverlay");
const cancel = markup.match(/<button id="confirmCancelBtn"[^>]*>/);
assert.ok(cancel);
assert.match(cancel[0], /data-modal-focus/);
assert.ok(!/<button id="confirmAcceptBtn"[^>]*data-modal-focus/.test(markup),
"the dialog lands on the answer that cannot be taken back");
});
test("no sheet names two landing spots, which would be a coin toss", () => {
OVERLAY_ORDER.forEach(id => {
const found = (overlay(id).match(/data-modal-focus/g) || []).length;
assert.equal(found, 1, `${id} names ${found} landing spots`);
});
});
test("the card picker no longer opens on Clear", () => {
const markup = overlay("cardPickerOverlay");
assert.ok(markup.indexOf('id="cardPickerTitle"') < markup.indexOf('id="cardPickerClearBtn"'),
"the heading has moved below the button it was there to keep focus off");
assert.ok(!/id="cardPickerClearBtn"[^>]*data-modal-focus/.test(markup));
});
// The landing spot is deliberately outside the ring, and a wrap that only knows
// "is this the first one" walks straight out of the dialog on the first Shift+Tab.
test("the trap wraps from outside the ring as well as from its ends", () => {
const modal = src("modal");
const at = modal.indexOf("const at = focusable.indexOf(");
assert.ok(at >= 0, "the Tab trap no longer asks where the dealer is in the ring");
const body = modal.slice(at, modal.indexOf("\n },", at));
assert.match(body, /shiftKey && at <= 0/);
assert.match(body, /at === -1/);
});
});
describe("a redraw that does not drop the dealer", () => {
test("focus comes back to the control with the same name", () => {
const { doc, root, nodes } = fakeSheet([
{ key: "quick-500" }, { key: "quick-1000" }, { key: "quick-2000" }]);
doc.activeElement = nodes[1];
withDocument(doc, () => Modal.preservingFocus(root, () => {
// What a rebuild does: the old buttons go, identical new ones arrive.
nodes.forEach(n => { n.isConnected = false; });
root.nodes = root.querySelectorAll = null;
const rebuilt = fakeSheet([{ key: "quick-500" }, { key: "quick-1000" }, { key: "quick-2000" }]);
rebuilt.nodes.forEach(n => { n.focus = () => { doc.activeElement = n; }; });
root.querySelectorAll = () => rebuilt.nodes;
root.contains = el => rebuilt.nodes.includes(el);
}));
assert.equal(doc.activeElement.dataset.focusKey, "quick-1000");
});
// The card just picked is a dealt card now, and a dealt card is a disabled button.
// There is no coming back to it, so the caller is asked where to go instead.
test("a name that comes back disabled hands the decision to the caller", () => {
const { doc, root, nodes } = fakeSheet([{ key: "AS" }, { key: "KH" }]);
doc.activeElement = nodes[0];
let asked = false;
withDocument(doc, () => Modal.preservingFocus(root, () => {
nodes[0].isConnected = false;
nodes[0].disabled = true;
}, () => { asked = true; }));
assert.ok(asked, "focus was left on the body of a modal");
});
test("and a name that is simply gone asks too", () => {
const { doc, root, nodes } = fakeSheet([{ key: "confirm" }]);
doc.activeElement = nodes[0];
let asked = false;
withDocument(doc, () => Modal.preservingFocus(root, () => {
nodes[0].isConnected = false;
root.querySelectorAll = () => [];
}, () => { asked = true; }));
assert.ok(asked);
});
// Clear is in the header. Nothing rebuilds it, so nothing should move it — a redraw
// the dealer asked for is not a reason to take them somewhere else.
test("a control that survived the redraw is left exactly where it was", () => {
const { doc, root, nodes } = fakeSheet([{ label: "Clear" }, { key: "AS" }]);
doc.activeElement = nodes[0];
let asked = false;
withDocument(doc, () => Modal.preservingFocus(root, () => {
nodes[1].isConnected = false;
}, () => { asked = true; }));
assert.equal(doc.activeElement, nodes[0]);
assert.ok(!asked);
});
test("and a dealer who was never in this sheet is not dragged into it", () => {
const { doc, root } = fakeSheet([{ key: "AS" }]);
const elsewhere = { dataset: {}, isConnected: true };
doc.activeElement = elsewhere;
let asked = false;
withDocument(doc, () => Modal.preservingFocus(root, () => {}, () => { asked = true; }));
assert.equal(doc.activeElement, elsewhere);
assert.ok(!asked);
});
// Four sheets redraw themselves on a tap. All four go through this now, and each
// one hands over the piece of itself it is about to throw away.
[["addChips", "render()", "addChipsSheet"],
["cardPicker", "render()", "cardPickerSheet"],
["seatMenu", "render()", "seatMenuOverlay"],
["potWizard", "renderStepInto()", "potWizardModal"]].forEach(([module, method, root]) => {
test(`${module}'s ${method} redraws through it`, () => {
const text = src(module);
const at = text.indexOf(`\n ${method} {`);
assert.ok(at >= 0, `${module} has no ${method}`);
const body = text.slice(at, text.indexOf("\n },", at));
assert.match(body, /Modal\.preservingFocus\(/,
`${module} rebuilds its controls without saying what to do about focus`);
assert.ok(body.includes(`"${root}"`),
`${module} hands preservingFocus something other than ${root}`);
});
});
});
describe("thirteen cards, one Tab stop", () => {
test("the deck is written once, ace down to two", () => {
const deck = CardPicker.deck();
assert.equal(deck.length, 52);
assert.equal(new Set(deck.map(c => c.card)).size, 52);
assert.deepEqual(deck.slice(0, 4).map(c => c.card), ["AS", "AH", "AD", "AC"]);
assert.equal(deck[4].card, "KS");
assert.equal(deck[51].card, "2C");
});
// A dealt card is `aria-disabled` rather than `disabled`, so it holds focus like any
// other cell — which is what lets the arrows tell the truth about the grid, and what
// lets pressing one be answered instead of ignored. See theUnansweredPress.
const grid = keys => keys.map(key => ({
dataset: { focusKey: key.replace("!", "") },
getAttribute: name => (name === "aria-disabled" && key.includes("!") ? "true" : null),
tabIndex: 0,
focus() {},
}));
const withGrid = (cards, body) => {
const nodes = grid(cards);
return withDocument({ querySelectorAll: () => nodes }, () => body(nodes));
};
test("a step is one card, whether or not that card is already out", () => {
// The seven is dealt. Stepping along from the eight lands on it and says so,
// rather than quietly arriving at the six.
withGrid(["8C", "7C!", "6C"], () => {
assert.equal(CardPicker.nextCursor(0, 1), 1);
});
});
// The whole reason the skipping had to go. Hunting for the next card that could take
// focus did the exact thing the no-wrap rule below was written to prevent: it moved
// the dealer sideways without being asked. Right from the ace over a dealt king
// landed on the queen, so the column silently became the one to its right, and the
// next press of Down walked a rank nobody had asked for.
test("and it never slides the dealer into a column they didn't ask for", () => {
withGrid(["AS", "KS!", "QS", "JS", "TS", "9S", "8S", "7S"], () => {
const afterRight = CardPicker.nextCursor(0, 1);
assert.equal(afterRight, 1, "a dealt king is still the cell to the right of the ace");
assert.equal(CardPicker.nextCursor(afterRight, 4), 5,
"and down a row of four from it is the nine, four ranks along");
});
});
// Wrapping would answer "move me down a row" by moving the dealer to the other end
// of the grid, which is not what they asked and not where they would look.
test("the edge of the grid is the edge, not a way round to the other side", () => {
withGrid(["AS", "KS", "QS", "JS"], () => {
assert.equal(CardPicker.nextCursor(3, 1), 3);
assert.equal(CardPicker.nextCursor(0, -1), 0);
});
});
test("only one card is in the Tab order at a time", () => {
withGrid(["AS", "KS", "QS", "JS"], nodes => {
CardPicker.aimCursor(2);
assert.deepEqual(nodes.map(n => n.tabIndex), [-1, -1, 0, -1]);
});
});
// Which happens after every pick: the cursor is sitting on a card that has just
// become a dealt card. It stays there, because that cell is still a cell and its
// neighbours are still where they were a moment ago.
test("a cursor left on a dealt card stays on it", () => {
withGrid(["AS!", "KS", "QS", "JS"], nodes => {
CardPicker.aimCursor(0);
assert.equal(CardPicker.cursor, 0);
assert.deepEqual(nodes.map(n => n.tabIndex), [0, -1, -1, -1]);
});
});
test("and a grid with nothing left in it still has somewhere to put the cursor", () => {
withGrid(["5S!", "4S!", "3S!", "2S!"], nodes => {
CardPicker.aimCursor(3);
assert.equal(CardPicker.cursor, 3);
assert.deepEqual(nodes.map(n => n.tabIndex), [-1, -1, -1, 0]);
});
});
// An index from outside the grid is clamped rather than wrapped, which is what
// Home and End lean on: they ask for one card past either end and take what they
// are given.
test("Home and End ask past the ends and land on them", () => {
withGrid(["AS", "KS", "QS", "JS"], () => {
assert.equal(CardPicker.nextCursor(-1, 1), 0);
assert.equal(CardPicker.nextCursor(4, -1), 3);
});
});
// It used to step by `SUITS.length`, because the grid was four suits wide at every
// size. The grid is thirteen ranks of one suit now and takes as many columns as the
// width holds — five on a 360px phone, seven at 460 — so a constant would be wrong
// on most screens, which is the thing that slid the cursor into the wrong column
// the last time these two disagreed. It asks the layout instead.
test("the arrows step by the row the grid actually has, not by a guess", () => {
const picker = src("cardPicker");
const at = picker.indexOf("onGridKey(event)");
assert.ok(at >= 0, "the grid has no arrow keys");
const body = picker.slice(at, picker.indexOf("\n },", at));
assert.match(body, /const stride = this\.gridStride\(\)/);
assert.match(body, /ArrowRight: 1.*ArrowLeft: -1.*ArrowDown: stride.*ArrowUp: -stride/s);
assert.match(body, /"Home"/);
assert.match(body, /"End"/);
});
test("and the row it actually has is read off the grid", () => {
const picker = src("cardPicker");
const at = picker.indexOf("gridStride() {");
assert.ok(at >= 0, "there is no gridStride");
const body = picker.slice(at, picker.indexOf("\n },", at));
assert.match(body, /getComputedStyle\(grid\)\.gridTemplateColumns/);
// Outside a browser there is nothing to measure, and thirteen ranks in one row
// is the shape that makes Up and Down do nothing rather than something wrong.
assert.match(body, /return this\.RANKS\.length/);
});
test("a fresh sheet starts at the top of the deck", () => {
const picker = src("cardPicker");
["open(state, hooks, seatIndex, cardIndex) {", "openBoard(state, hooks, cardIndex, limit = 5) {"]
.forEach(signature => {
const at = picker.indexOf(signature);
assert.ok(at >= 0, `there is no \`${signature}\``);
assert.match(picker.slice(at, picker.indexOf("this.render();", at)), /this\.cursor = 0;/);
});
});
});
describe("seeing where you are", () => {
// Until a summary could be Tabbed to at all, there was no way to notice that
// twenty of them had no ring.
test("a summary gets the tool's own focus ring, not the browser's", () => {
const at = CSS.indexOf("---------- Keyboard focus ----------");
assert.ok(at >= 0);
const block = CSS.slice(at, CSS.indexOf("outline: 2px solid #7fc4ff", at));
assert.match(block, /(^|\n)summary:focus-visible,/);
});
// Only script ever focuses these, so `:focus` would ring the title of every sheet
// on a phone that has no keyboard to ring it for.
test("the landing heading rings for a keyboard and stays quiet for a thumb", () => {
const at = CSS.indexOf('h2[data-modal-focus]:focus-visible {');
assert.ok(at >= 0, "the heading a sheet lands on shows nothing when it takes focus");
const rule = CSS.slice(at, CSS.indexOf("}", at));
assert.match(rule, /outline: 2px solid #7fc4ff/);
// These headings run to the edge of their sheet; an outward ring is clipped.
assert.match(rule, /outline-offset: -2px/);
assert.ok(!/\[data-modal-focus\]:focus\b(?!-)/.test(CSS),
"a plain :focus rule would ring the title on every tap");
});
});
+41 -533
View File
@@ -15,8 +15,6 @@ const { ActionBar, Render } = require("./load");
const CSS = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
const CSS_JS = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem", "actionBar.js"), "utf8");
// ---------------------------------------------------------------------------
@@ -117,506 +115,53 @@ describe("the bar lines up with the table", () => {
// ---------------------------------------------------------------------------
describe("the button seat's name", () => {
// The button seat's name is the same name in the same box as every other seat's.
//
// Three attempts said otherwise, in this order: a float, a float with a first-line
// indent, and `padding-right: 25px` — the dealer chip's width plus its inset, taken
// off the name because the chip sat inside the puck's top-right corner. All three
// were answers to a chip that had no business being in the writing, and the price
// was a quarter of the name's line on whichever seat currently held the button.
// Measured with the sweep's twelve names: 7 of 12 fitted on the button seat at
// 9-handed on an iPhone against 11 anywhere else, and 1 of 12 against 7 at
// 12-handed on a Galaxy S8.
const name = CSS.match(/\n\.seatPuckName \{([^}]*)\}/);
// The dealer button's chip sits in the puck's top-right corner. Keeping the name out
// from under it with `padding-right` 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". The button moves every hand, so that is a player
// whose name reads properly for four hands and comes apart on the fifth.
const roomy = CSS.match(/#seatsLayer:not\(\.seatsTight\) \.seat\.seatHasButton \.seatPuckName \{([^}]*)\}/);
const float = CSS.match(/#seatsLayer:not\(\.seatsTight\) \.seat\.seatHasButton \.seatPuckName::before \{([^}]*)\}/);
const base = CSS.match(/\n\.seat\.seatHasButton \.seatPuckName \{([^}]*)\}/);
test("nothing reserves a gutter inside the name any more", () => {
assert.equal(/\.seat\.seatHasButton \.seatPuckName/.test(CSS), false,
"the button seat's name should be the same box as every other seat's");
test("a ring with room for two lines indents only the line beside the chip", () => {
assert.ok(roomy && float, "the roomy regime should use a float");
assert.match(float[1], /float:\s*right/);
assert.match(roomy[1], /padding-right:\s*0/);
});
test("the float that used to do this is gone, not merely unused", () => {
// Left in place with a one-line name it would clip the name to nothing: the word
// cannot fit beside the float, so it moves to a second line, and there is no
// second line. Measured — "Christopher" drew as an empty label.
assert.equal(/\.seatPuckName::before/.test(CSS), false,
"a float and one line of name cannot both be true");
test("the float is exactly one line tall", () => {
// Tall enough to cover the chip's reach into the name, and no taller: a float
// that spills into the second line indents that one too, which puts the whole
// problem back.
assert.match(float[1], /height:\s*1\.2em/);
});
test("and no rule anywhere gives a puck's name a second line", () => {
// This is what every puck being the same height rests on. A name is a label, and
// the ring is sized against the tallest puck on it, so one name wrapping is one
// seat setting how far apart all of them have to sit.
assert.ok(name, "the name should have a base rule");
assert.match(name[1], /white-space:\s*nowrap/);
test("break-word stands down where the float is doing the work", () => {
// It is what stops the float working — it breaks a word to fit the short line
// rather than letting it move past the float to the long one.
assert.match(roomy[1], /overflow-wrap:\s*normal/);
});
test("and it is one line by not wrapping, not by hiding what wrapped", () => {
// A `-webkit-box` clamped to one line hides the lines past the first; it does not
// hide the part of the second one that the row's own min-height leaves uncovered.
// The row is 15px and .puckNameTight's line box is 12.24, so 2.76px of the next
// line showed — "Christ…" with the ascenders of "opher" underneath it.
assert.equal(/(?<![\w-])line-clamp:/.test(CSS), false,
"a clamp and a fixed row height only agree while the type never moves");
assert.match(name[1], /text-overflow:\s*ellipsis/);
test("a crowded ring keeps the padding, because it has only one line", () => {
// `.seatsTight .seatPuckName` is `line-clamp: 1` on purpose, and one line has
// nowhere to move to — the only way to keep it off the chip is to shorten it.
// Trying the float on both cost a whole sweep to find out: 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.
assert.ok(base, "the base rule should still reserve the chip's width");
assert.match(base[1], /padding-right:\s*22px/);
assert.match(CSS, /\.seatsTight \.seatPuckName \{[^}]*line-clamp:\s*1/);
});
test("the box a name is measured in is the box it is drawn in", () => {
// What the clamp cost besides the stray line: a name that wrapped reported
// scrollWidth == clientWidth, so fitSeatNames() could only ever see the overflow
// as height. `nowrap` puts the whole name on one line whether it fits or not,
// which is what makes the width worth reading.
assert.match(name[1], /overflow:\s*hidden/);
assert.equal(/overflow-wrap:\s*break-word/.test(name[1]), false,
"nothing to break a word onto");
});
});
// ---------------------------------------------------------------------------
describe("the table is sized against the bar, not against a pad over it", () => {
// The raise pad is 57px taller than the row it replaces, and the felt was sized
// against it. Measured with five players nine-handed, pressing Raise:
//
// 1280x900 felt 738x532 -> 669x481, pucks 120 -> 109, worst seat moved 73px
// 360x640 felt 283x329 -> 283x279, pucks 44 -> 48, worst seat moved 50px
//
// The whole table jumped, re-ran the ten-candidate width search, and undid all of it
// when the dealer pressed Raise to. Every raise, all night, at the one moment they
// are reading the felt to decide what to type.
//
// A phone held sideways already scored 0px, because the rail is a column down the
// side and its height never was the table's business. This is the upright layout
// being held to what the sideways one already does.
test.afterEach(() => { ActionBar.raiseOpen = false; delete ActionBar.barHeightAtRest; });
test("a closed bar is measured, and remembered", () => {
ActionBar.raiseOpen = false;
assert.equal(ActionBar.restingBarHeight(151), 151);
assert.equal(ActionBar.barHeightAtRest, 151);
});
test("and an open pad gets the height the bar had before it opened", () => {
ActionBar.raiseOpen = false;
ActionBar.restingBarHeight(151);
ActionBar.raiseOpen = true;
assert.equal(ActionBar.restingBarHeight(208), 151,
"the felt would resize under the dealer's thumb mid-raise");
});
test("and gets it back the moment the pad closes", () => {
ActionBar.raiseOpen = false;
ActionBar.restingBarHeight(151);
ActionBar.raiseOpen = true;
ActionBar.restingBarHeight(208);
ActionBar.raiseOpen = false;
assert.equal(ActionBar.restingBarHeight(172), 172,
"a bar that really did grow — a side pot to show — still takes its room");
});
test("a pad open before anything was ever measured takes what it can see", () => {
// Nothing has been remembered yet, so the measurement is the only answer there
// is. Better a felt sized for the pad than one sized for undefined.
ActionBar.raiseOpen = true;
delete ActionBar.barHeightAtRest;
assert.equal(ActionBar.restingBarHeight(208), 208);
});
test("the bar that is actually covering the window is still reported honestly", () => {
// Only the felt's question is answered from the resting height. The toast has to
// clear whatever is really there, and so does the page's scroll padding, so
// --actionBarHeight stays a measurement.
const publish = CSS_JS.slice(CSS_JS.indexOf("const publish = () => {"));
const body = publish.slice(0, publish.indexOf("};"));
assert.match(body, /--actionBarHeight[^\n]*Math\.round\(height\)/,
"--actionBarHeight should still be the measured height");
assert.match(body, /publishFeltRoom\(.*restingBarHeight\(height\)/,
"the felt should be the one asking for the resting height");
});
});
// ---------------------------------------------------------------------------
describe("the status block is the same number of lines whoever is acting", () => {
// The bar is fixed to the bottom, so a line added to this block comes straight off
// the felt. The block wraps, and flex settles its line breaks before it decides what
// to shrink — so a name too long for the space left beside the clock and the undo
// pill did not give way, it pushed them onto a third line.
//
// Measured at 360x640 mid-street: "Dee · $600 behind" left the table 283x329 and
// "Christopher · $1,200 behind" made it 283x310, with every seat moving 24px between
// one player and the next. Whose turn it was decided how big the table was.
const acting = CSS.match(/\.actionActing \{([^}]*)\}/);
const who = CSS.match(/\n\.actionWho \{([^}]*)\}/);
const name = CSS.match(/\.actionWhoName \{([^}]*)\}/);
const stack = CSS.match(/\.actionWhoStack \{([^}]*)\}/);
test("the acting row takes a line of its own, once there is somebody on it", () => {
assert.ok(acting, ".actionActing rule not found");
assert.match(acting[1], /flex:\s*0 1 auto/,
"the pill on its own should not claim a line");
const withActor = CSS.match(/\.actionActing:has\(\.actionWho\) \{([^}]*)\}/);
assert.ok(withActor, "nothing gives the acting row its own line");
assert.match(withActor[1], /flex:\s*1 1 100%/,
"without a full basis it shares whatever line the wrap left it");
});
test("and only then — a run-out has nobody to act and cannot spare the line", () => {
// While a board is running out there is nobody to act, so this row is the undo
// pill alone. Claiming a line for it is 22px off the felt on the twelve-handed
// 360x640 table where the pucks are already at their 44px floor, and the sweep
// caught that as a seat's "ALL IN" going back under the blind badge next door.
assert.doesNotMatch(acting[1], /flex:\s*1 1 100%/);
});
test("and it shrinks rather than wrapping", () => {
// Left wrapping, this row reproduced the fault one level down: the undo pill is
// a fixed width, so a long name pushed it onto a line of its own and the block
// was 84px for some players and 66 for others.
assert.match(acting[1], /flex-wrap:\s*nowrap/);
});
test("the name is what gives way", () => {
// `min-width` on a flex item defaults to `auto` — "never smaller than your own
// content" — which is why the ellipsis this was written with never once appeared.
assert.ok(who && name, "the actor should be a name and an amount, not one string");
assert.match(who[1], /min-width:\s*0/);
assert.match(name[1], /min-width:\s*0/);
assert.match(name[1], /text-overflow:\s*ellipsis/);
});
test("and the stack is what does not", () => {
// The same rule the seat pucks keep: a number read wrong is a worse failure than
// a name read short, so the stack keeps every digit.
assert.ok(stack, ".actionWhoStack rule not found");
assert.match(stack[1], /flex:\s*none/);
});
test("except in the rail, which is a column and has no line to shrink into", () => {
// 190px wide: every one of these readings is on its own line there regardless,
// and squeezing a name into a fifth of the column would be shrinking to fit a
// row that isn't there.
const rules = [...CSS.matchAll(/\.actionActing \{([^}]*)\}/g)].map(m => m[1]);
assert.equal(rules.length, 2,
"one rule for the bar and one for the rail — no more, no fewer");
assert.ok(rules.some(body => /flex-wrap:\s*wrap\s*;/.test(body)),
"the rail should say what it wants instead");
});
});
// ---------------------------------------------------------------------------
describe("and the name is the half that is still there at the end of it", () => {
// Shrinking the name had no floor under it. Flex takes the only item that can give
// way and takes it all the way, so with the clock button on the line there was
// nothing left to give: measured with five players nine-handed, mid-street, forcing
// the clock button on —
//
// 360x640 "Christopher" wanted 62px and was drawn 0. "Alexandra" 54 -> 0.
// 390x844 "Christopher" wanted 62px and was drawn 0. "Alexandra" 54 -> 0.
// 430x932 "Christopher" 62 -> 31.
//
// What the bar read at the first two was "· $1,200 behind" — the separator, the
// figure, and no player. The ellipsis the rule was written around never appeared,
// because there were no characters left to put one after.
//
// The rule itself is kept: a number read wrong is worse than a name read short, so
// the stack is never truncated. It is taken off the line whole instead. Measured
// again afterwards, with the same clock button on the same line:
//
// 360x640 "Christopher" 62 -> 44 ("Christo…"), "Alexandra" 54 -> 44
// 390x844 both whole
// 430x932 both whole
//
// and 0px of seat movement across the thirty-second mark at every one of them.
const withLine = (name, stack, run) => {
const el = (extra) => Object.assign({
hidden: false, scrollWidth: 0, clientWidth: 0,
}, extra);
const nameEl = el(name);
const stackEl = el(stack);
global.document = {
querySelector: sel => (sel.endsWith(".actionWhoName") ? nameEl
: sel.endsWith(".actionWhoStack") ? stackEl : null),
};
try { run(nameEl, stackEl); } finally { delete global.document; }
};
test("a name that fits keeps the stack beside it", () => {
withLine({ scrollWidth: 54, clientWidth: 54 }, {}, (nameEl, stackEl) => {
ActionBar.fitActingLine();
assert.equal(stackEl.hidden, false);
});
});
test("a name being cut takes the stack off the line first", () => {
withLine({ scrollWidth: 62, clientWidth: 0 }, {}, (nameEl, stackEl) => {
ActionBar.fitActingLine();
assert.equal(stackEl.hidden, true,
"the stack is on that player's own seat puck; the name is only here");
});
});
test("and a stack already off it is put back before anything is measured", () => {
// Otherwise the answer is a property of whatever the last render left behind
// rather than of the line: a name that fits only *because* the stack is missing
// would keep it missing for the rest of the night.
withLine({ scrollWidth: 54, clientWidth: 54 }, { hidden: true }, (nameEl, stackEl) => {
ActionBar.fitActingLine();
assert.equal(stackEl.hidden, false);
});
});
test("a hair of rounding is not a cut", () => {
withLine({ scrollWidth: 55, clientWidth: 54 }, {}, (nameEl, stackEl) => {
ActionBar.fitActingLine();
assert.equal(stackEl.hidden, false);
});
});
test("and a bar with nobody acting on it is left alone", () => {
global.document = { querySelector: () => null };
try { assert.doesNotThrow(() => ActionBar.fitActingLine()); }
finally { delete global.document; }
});
test("the stylesheet can actually take it off the line", () => {
// `hidden` is a UA-stylesheet default, and .actionWhoStack sits inside a flex
// container. Said out loud here so a later `display:` on the class can't quietly
// put the figure back.
assert.match(CSS, /\.actionWhoStack\[hidden\]\s*\{[^}]*display:\s*none/);
assert.match(CSS, /\.actionTank\[hidden\]\s*\{[^}]*display:\s*none/);
});
test("the space before the separator is in the stylesheet, not in the string", () => {
// These are two flex items, and a flex item's own leading whitespace is collapsed
// at its edge — so ` · $1,200 behind` drew as `Alexandra· $1,500 behind` from the
// day the sentence was split in two.
const who = CSS.match(/\n\.actionWho \{([^}]*)\}/);
assert.match(who[1], /gap:\s*var\(--sp-1\)/);
assert.match(CSS_JS, /whoStack\.textContent = `· \$\{/,
"a leading space here is invisible and would double the gap if it weren't");
});
test("and the fit is taken after the timer has decided whether it is on the line", () => {
const render = CSS_JS.slice(CSS_JS.indexOf(" render() {"));
const body = render.slice(0, render.indexOf("\n },"));
const tank = body.indexOf("this.syncTankTimer()");
const fit = body.indexOf("this.fitActingLine()");
assert.ok(tank >= 0 && fit >= 0, "render() should do both");
assert.ok(fit > tank,
"measured before the stopwatch is hidden, the name is measured against 50px it has");
});
});
// ---------------------------------------------------------------------------
describe("what the undo pill says it would take back", () => {
// The pill is a fixed width — it names the last action, so its text changes on every
// tap, and a control that resizes under a thumb reads as the interface twitching —
// and on a phone that width leaves the label 73px. Measured across one hand, four of
// the five labels a hand produces wanted more:
//
// ↶ dealing the hand 104 ↶ Christopher's fold 109
// ↶ Mike from work's fold 128 ↶ Alexandra's fold 100
// ↶ Dee's fold 69 (the only one that fitted)
//
// So the safety net spent most of a night reading "↶ dealing …", which does not say
// what it is a net for. Cut as one string it says the wrong half: "↶ Mike from wor…"
// names everybody except the action.
//
// Two halves, and the actor is the one that goes — whole, not shortened. Measured
// again afterwards, nothing is cut at any viewport in the sweep: a phone reads
// "↶ the hand", "↶ Dee's fold", "↶ raise to $20", "↶ call"; a desktop and the
// landscape rail keep the actor wherever it fits.
test("a label handed over in halves knows which is which", () => {
const step = ActionBar.undoParts({ lead: "Christopher's", act: "fold" });
assert.equal(step.lead, "Christopher's");
assert.equal(step.act, "fold");
assert.equal(step.long, "Christopher's fold", "the sentence the toast and the tooltip use");
});
test("one with no actor is all act", () => {
// "the straddle" belongs to the table rather than to a player.
const step = ActionBar.undoParts({ lead: "", act: "the straddle" });
assert.equal(step.lead, "");
assert.equal(step.long, "the straddle", "no stray space in front of it");
});
test("and a plain string still works, with nothing to drop", () => {
// Every caller passes halves today. This is what keeps a caller that doesn't
// from drawing "[object Object]" on the one control a dealer reaches for when
// they have made a mistake.
const step = ActionBar.undoParts("something nobody split");
assert.equal(step.lead, "");
assert.equal(step.act, "something nobody split");
assert.equal(step.long, "something nobody split");
});
test("the sentence is what goes into the history, and the halves ride with it", () => {
const seen = [];
const before = ActionBar.hooks;
ActionBar.hooks = {
beginAction: (label, parts) => seen.push({ label, parts }),
save: () => {}, rerender: () => {},
};
try {
ActionBar.commit({ lead: "Dee's", act: "call" }, () => {});
} finally { ActionBar.hooks = before; }
assert.equal(seen.length, 1);
assert.equal(seen[0].label, "Dee's call", "the toast says the whole thing");
assert.deepEqual(
{ lead: seen[0].parts.lead, act: seen[0].parts.act },
{ lead: "Dee's", act: "call" });
});
test("and the halves are read back from the history, not remembered here", () => {
// Undo pops the history. A copy kept on this object would name the step that was
// just taken back rather than the one now on top of the pile.
const before = ActionBar.hooks;
ActionBar.hooks = {
lastActionLabel: () => "Dee's call",
lastActionParts: () => ({ lead: "Dee's", act: "call" }),
};
try {
assert.deepEqual(ActionBar.undoStep(), { lead: "Dee's", act: "call" });
} finally { ActionBar.hooks = before; }
});
test("a caller that offers no halves gets the label whole", () => {
const before = ActionBar.hooks;
ActionBar.hooks = { lastActionLabel: () => "dealing the hand" };
try {
assert.deepEqual(ActionBar.undoStep(), { lead: "", act: "dealing the hand" });
} finally { ActionBar.hooks = before; }
});
const withPill = (act, run) => {
const lead = { hidden: false, className: "actionUndoLead" };
const label = {
className: "actionUndoLabel",
querySelector: sel => (sel === ".actionUndoLead" ? lead
: sel === ".actionUndoAct" ? act : null),
};
global.document = {
querySelector: sel => (sel.endsWith(".actionUndoLabel") ? label : null),
};
try { run(lead); } finally { delete global.document; }
};
test("an act that fits keeps the actor beside it", () => {
withPill({ scrollWidth: 19, clientWidth: 40 }, lead => {
ActionBar.fitUndoLabel();
assert.equal(lead.hidden, false);
});
});
test("an act being cut drops the actor whole", () => {
withPill({ scrollWidth: 63, clientWidth: 40 }, lead => {
ActionBar.fitUndoLabel();
assert.equal(lead.hidden, true,
"\"Christo…'s fold\" is a worse sentence than \"fold\"");
});
});
test("and it is the act that is asked, never the label", () => {
// The label cannot overflow: the act inside it is the item with `overflow:
// hidden`, so it swallows the excess and the row measures as though everything
// fitted. Asking the label was the first way this was written, and it drew
// "↶ dealing the hand" with "the hand" quietly cut off inside it.
const fit = CSS_JS.slice(CSS_JS.indexOf(" fitUndoLabel() {"));
const body = fit.slice(0, fit.indexOf("\n },"));
assert.match(body, /act\.scrollWidth > act\.clientWidth/);
assert.doesNotMatch(body, /label\.scrollWidth/);
});
test("an actor already dropped is put back before anything is measured", () => {
withPill({ scrollWidth: 19, clientWidth: 40 }, lead => {
lead.hidden = true;
ActionBar.fitUndoLabel();
assert.equal(lead.hidden, false);
});
});
test("and a bar with nothing to take back is left alone", () => {
global.document = { querySelector: () => null };
try { assert.doesNotThrow(() => ActionBar.fitUndoLabel()); }
finally { delete global.document; }
});
test("the keycap goes where there is no keyboard to press it", () => {
// Every other control on the bar had dropped its cap on a phone since the rule
// was written; this one is a different class and had been left out of it, so the
// narrowest label on the bar was the only one still spending 22px on a shortcut
// nothing could press.
const rule = CSS.match(/@media \(hover: none\) \{\s*\.actionKey,\s*\.actionUndoKey \{([^}]*)\}/);
assert.ok(rule, ".actionUndoKey should be hidden alongside .actionKey on touch");
assert.match(rule[1], /display:\s*none/);
});
});
// ---------------------------------------------------------------------------
describe("a stopwatch that is only there when it is saying something", () => {
// 50px of a 349px line, from the first second of every decision, reading "0:07" in
// grey. It is a prompt rather than a readout — nothing happens when it grows — and a
// prompt that is always on screen is not a prompt. It arrives when the button that
// acts on it arrives, which is the same threshold, and the room goes to the name.
const withClock = ({ seat = 0, started = null, unknown = null, clockAt = null }, run) => {
const saved = {
state: ActionBar.state, tankSeatIndex: ActionBar.tankSeatIndex,
tankStartedAt: ActionBar.tankStartedAt, tankUnknownFor: ActionBar.tankUnknownFor,
clockSeatIndex: ActionBar.clockSeatIndex, clockStartedAt: ActionBar.clockStartedAt,
};
ActionBar.state = { actingSeatIndex: seat };
ActionBar.tankSeatIndex = seat;
ActionBar.tankStartedAt = started;
ActionBar.tankUnknownFor = unknown;
ActionBar.clockSeatIndex = clockAt === null ? null : seat;
ActionBar.clockStartedAt = clockAt;
try { run(); } finally { Object.assign(ActionBar, saved); }
};
test("a decision that has only just landed on somebody shows neither", () => {
withClock({ started: Date.now() - 3000 }, () =>
assert.equal(ActionBar.tankWorthShowing(), false));
});
test("and both arrive together, at the threshold the stopwatch already marked", () => {
withClock({ started: Date.now() - (ActionBar.NUDGE_AFTER_S * 1000) }, () =>
assert.equal(ActionBar.tankWorthShowing(), true));
});
test("a clock already running is always worth watching", () => {
// The readout is a countdown by then, and the button beside it says "Stop the
// clock" — there is no second at which either of those is not the point.
withClock({ started: Date.now(), clockAt: Date.now() }, () =>
assert.equal(ActionBar.tankWorthShowing(), true));
});
test("so is the seat whose wait cannot be known", () => {
// The action was already on them when the page loaded. The dash and the sentence
// behind it are the answer to "why is there no time on this one", so the answer
// has to be visible for the question to get asked.
withClock({ started: Date.now(), unknown: 0 }, () =>
assert.equal(ActionBar.tankWorthShowing(), true));
});
test("and with nothing to time from at all, nothing is shown", () => {
withClock({ started: null }, () =>
assert.equal(ActionBar.tankWorthShowing(), false));
});
test("it is built hidden, so a fresh decision never flashes one up", () => {
assert.match(CSS_JS, /tank\.hidden = true;/);
});
test("and the tick re-fits the line only when one of them actually moved", () => {
// The line is measured by forcing a layout, and this runs once a second all
// night. Nothing has changed on the seconds in between.
const paint = CSS_JS.slice(CSS_JS.indexOf(" paintTank() {"));
const body = paint.slice(0, paint.indexOf("\n },"));
assert.match(body, /if \(el\.hidden !== wasTank \|\| \(nudge && nudge\.hidden !== wasNudge\)\) this\.fitActingLine\(\)/);
test("the float is as wide as the chip it stands in for", () => {
const chip = CSS.match(/\n\.dealerChip \{[^}]*?(?<![\w-])width:\s*(\d+)px/);
const inset = CSS.match(/\n\.dealerChip \{[^}]*?(?<![\w-])right:\s*(\d+)px/);
const floatW = float[1].match(/width:\s*(\d+)px/);
assert.ok(chip && inset && floatW);
assert.equal(Number(floatW[1]), Number(chip[1]) + Number(inset[1]),
"the float should cover the chip and the gap it is inset by");
});
});
@@ -689,17 +234,9 @@ describe("a name that will not fit", () => {
// ladder — which words go, in what order, and when it stops — and the browser's part
// is one boolean per pass.
const label = (text, fits) => {
const classes = new Set();
const el = {
textContent: text,
title: "",
// The step of type a name takes when it has no word left to drop, held as
// the same set of strings the real element's classList is.
classList: {
add: name => classes.add(name),
remove: name => classes.delete(name),
contains: name => classes.has(name),
},
// The browser's answer, standing in for a real one: this name fits if
// `fits` says the version currently written to it does.
get scrollWidth() { return fits(this.textContent) ? 10 : 100; },
@@ -772,16 +309,13 @@ describe("a name that will not fit", () => {
assert.equal(Render.dropAWord(["Mike"], 1), 1, "one word has nothing to drop");
});
test("a single word steps its type down rather than being cut", () => {
// There is no honest way to shorten "Christopher" at a word, and "Christo…" is a
// name only until a Christine sits down. The amounts on the same puck have never
// taken that trade — fitAmount shrinks a tournament stack rather than rounding it
// — and a misread name is the same kind of failure as a misread stack.
test("a single word is left to the ellipsis it always had", () => {
// There is no honest way to shorten "Christopher" that a dealer glancing across
// a table would read faster than "Christo…".
const el = label("Christopher", () => false);
seatsNamed("Christopher");
Render.fitSeatNames(layer([el]));
assert.equal(el.textContent, "Christopher", "never cut mid-word here");
assert.ok(el.classList.contains("puckNameTight"), "and never left overflowing either");
});
test("a name that fits is not touched", () => {
@@ -789,32 +323,6 @@ describe("a name that will not fit", () => {
seatsNamed("Mike from work");
Render.fitSeatNames(layer([el]));
assert.equal(el.textContent, "Mike from work");
assert.ok(!el.classList.contains("puckNameTight"), "nothing to shrink for");
});
test("a name shortened until it fits is not also shrunk", () => {
// The step of type is the last resort, not a second helping. "Mike" fits, so it
// is read at the size everything else on the felt is.
const el = label("Mike from work", text => text === "Mike");
seatsNamed("Mike from work");
Render.fitSeatNames(layer([el]));
assert.equal(el.textContent, "Mike");
assert.ok(!el.classList.contains("puckNameTight"));
});
test("the step comes off again when the puck it was for is gone", () => {
// Otherwise it is a ratchet: a name that had to shrink on a nine-handed phone
// would still be shrunk on the same table six-handed, or on a desktop, because
// nothing ever measured it at the size it was supposed to be read at.
const el = label("Christopher", () => false);
seatsNamed("Christopher");
Render.fitSeatNames(layer([el]));
assert.ok(el.classList.contains("puckNameTight"));
const roomy = label("Christopher", () => true);
roomy.classList.add("puckNameTight");
Render.fitSeatNames(layer([roomy]));
assert.ok(!roomy.classList.contains("puckNameTight"));
});
test("the whole name is on the tooltip whether it was shortened or not", () => {
-274
View File
@@ -1,274 +0,0 @@
// The sheet a dealer opens mid-argument.
//
// It held twelve rulings with all their prose showing — 2,480px of it on a 390px
// phone, of which 1,754 was below the fold — and a row of twelve chips across the top
// to jump between them. The chips were an index of the twelve headings printed directly
// above the same twelve headings: the same list twice, 172px of navigation before the
// first word, and the dealer who used one landed in the middle of a scroll with eleven
// rulings they hadn't asked for still wrapped around them.
//
// The headings are the index now. NN/g's rule for this shape — a longer list of
// independent topics somebody opens one of — is an accordion, shut by default, and
// half of these headings answer the question without being opened at all: "A short
// all-in doesn't reopen the betting" is the ruling, not the topic. Measured after:
// 29px below the fold on an iPhone with all twelve rows on screen at once, 51px on a
// desktop. Opening the longest of them leaves 244px.
//
// These are layout and markup decisions, so they are checked as text — except the one
// about what the sheet does when it is opened again, which is behaviour and lives in
// main.js.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
const PAGE = fs.readFileSync(path.join(ROOT, "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8");
const MAIN = fs.readFileSync(path.join(ROOT, "wwwroot", "js", "casino", "holdem", "main.js"), "utf8");
const sheet = () => {
const at = PAGE.indexOf('<div id="rulingsOverlay"');
assert.ok(at >= 0, "there is no rulings overlay");
return PAGE.slice(at, PAGE.indexOf('<div id="seatMenuOverlay"', at));
};
const ruleFor = selector => {
const at = CSS.indexOf(`\n${selector} {`);
assert.ok(at >= 0, `there is no \`${selector}\` rule`);
return CSS.slice(at, CSS.indexOf("}", at));
};
// Every @media block written at exactly this condition, concatenated — the same helper
// theHeader.test.js uses, and for the same reason: this condition is opened more than
// once in the file, and a helper that stops at the first finds a block with none of
// these rules in it and reports them missing.
const mediaBlock = condition => {
const found = [];
const needle = "@media ";
for (let at = CSS.indexOf(needle); at >= 0; at = CSS.indexOf(needle, at + 1)) {
const open = CSS.indexOf("{", at);
if (CSS.slice(at + needle.length, open).trim() !== condition) continue;
let depth = 0;
for (let i = open; i < CSS.length; i++) {
if (CSS[i] === "{") depth++;
else if (CSS[i] === "}" && --depth === 0) { found.push(CSS.slice(open + 1, i)); break; }
}
}
assert.ok(found.length > 0, `no @media block written as "${condition}"`);
return found.join("\n");
};
const ruleIn = (block, selector) => {
const re = new RegExp(`(^|[},])\\s*${selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\{([^}]*)\\}`, "m");
const found = block.match(re);
assert.ok(found, `there is no \`${selector}\` rule in that block`);
return found[2];
};
const SIDEWAYS = () => mediaBlock("(orientation: landscape) and (max-height: 560px)");
// Each ruling as its own slice of markup.
const rulings = () => {
const parts = sheet().split('<details class="ruling"');
return parts.slice(1).map(p => p.slice(0, p.indexOf("</details>")));
};
// ---------------------------------------------------------------------------
describe("twelve rulings, shut", () => {
test("every one of them is a disclosure, and there are still twelve", () => {
assert.equal(rulings().length, 12);
});
// NN/g is explicit that opening the first one by default costs the reader the
// glance at the whole list, which on this sheet is the entire point.
test("none of them is open in the markup", () => {
assert.ok(!/<details class="ruling"[^>]*\bopen\b/.test(sheet()),
"a ruling starts open, so the sheet no longer opens on its own contents");
});
// Native exclusive accordions close the open one when another is opened, which
// shifts everything below it under the finger that is reaching. That is the
// disorientation the accordion is here to remove, not add.
test("opening one does not close another", () => {
assert.ok(!/<details class="ruling"[^>]*\bname=/.test(sheet()),
"the rulings are an exclusive accordion, so content moves when one opens");
});
test("the name of a ruling is the control that opens it", () => {
rulings().forEach((r, i) => {
assert.match(r, /<summary><h3>.+<\/h3><\/summary>/,
`ruling ${i + 1} does not carry its heading in its summary`);
});
});
});
describe("the index that was above them is gone, not merely hidden", () => {
test("nothing builds a chip row any more", () => {
assert.ok(!/rulingsIndex/.test(PAGE), "the index nav is still in the markup");
assert.ok(!/buildRulingsIndex|rulingsIndexBtn/.test(MAIN), "main.js still builds the index");
assert.ok(!/rulingsIndexBtn/.test(CSS), "the index chips still have styles");
});
// The short labels existed only to fit three chips on a line. With the full
// headings doing the work there is nothing left to read them.
test("and the short labels it read are gone with it", () => {
assert.ok(!/data-short=/.test(PAGE));
});
});
describe("what a summary has to be to work", () => {
// A <summary> is announced as a button and not as a heading, so without this the
// twelve names — the whole of this sheet's navigation — are invisible to anybody
// moving through it by heading. Same fix as the sidebar's panel names.
test("the heading survives being a control", () => {
assert.match(ruleFor(".ruling > summary > h3"), /display:\s*inline/);
assert.match(ruleFor(".ruling > summary > h3"), /font:\s*inherit/);
});
// A <summary> is content-box, so a min-height is *added* to its padding — the trap
// the sidebar's headers were in, one stylesheet section over.
test("a thumb gets its 44px, counted the way the box actually counts", () => {
const rule = ruleFor(".ruling > summary");
assert.match(rule, /box-sizing:\s*border-box/);
assert.match(rule, /min-height:\s*44px/);
});
test("the accent belongs to the ruling being read, not to all twelve", () => {
assert.match(ruleFor(".ruling > summary"), /color:\s*var\(--text\)/);
assert.match(ruleFor(".ruling[open] > summary"), /color:\s*var\(--gold\)/);
});
test("and so does the gold bar down the side", () => {
assert.match(ruleFor(".ruling[open]"), /border-left:\s*2px solid/);
assert.ok(!/border-left/.test(ruleFor(".ruling")),
"every ruling wears the open one's marker");
});
});
describe("reading one of them", () => {
// Five of the twelve are two paragraphs and they ran into each other: the sections
// were spaced from one another, but nothing separated the halves of one ruling.
test("two paragraphs of a ruling are two paragraphs", () => {
assert.match(ruleFor(".ruling p + p"), /margin-top:\s*var\(--sp-\d\)/);
});
// <details> keeps its state, so without this a dealer who read the odd-chip rule an
// hour ago finds it still hanging open under the one they came back for.
test("the sheet opens shut, however it was left", () => {
const at = MAIN.indexOf("const openRulings");
assert.ok(at >= 0, "nothing owns what the sheet looks like when it opens");
const body = MAIN.slice(at, MAIN.indexOf("};", at));
assert.match(body, /\.ruling\[open\]/);
assert.match(body, /open = false/);
});
test("and that is what the button calls", () => {
assert.match(MAIN, /getElementById\("openRulingsBtn"\)\.addEventListener\("click", openRulings\)/);
});
});
// The dealer who has read the answer has to be able to leave without scrolling back
// past it. Measured before this: on a 360x640 phone the twelve shut rulings are 791px of
// a 549px sheet, so at the bottom of that scroll Done is 221px above the sheet's top
// edge; on a 390x844 phone with one ruling opened — the only reason anybody is in here —
// it is 262px. The seat menu's sheet had already been given the fix and this one, the
// longer of the two, had not.
describe("the way out does not scroll away", () => {
test("the header is stuck to the top of the sheet's own scrollport", () => {
const rule = ruleFor(".rulingsHeader");
assert.match(rule, /position:\s*sticky/);
assert.match(rule, /top:\s*0/);
// Out to the sheet's edges and back, or the list shows through either side of it.
assert.match(rule, /margin:\s*0 calc\(var\(--sp-4\) \* -1\)/);
assert.match(rule, /background:\s*var\(--surface-2\)/);
});
test("and nothing is left above it for the list to scroll out through", () => {
assert.match(ruleFor("#rulingsSheet"), /padding:\s*0 var\(--sp-4\)/,
"the sheet keeps a top padding, which is a gap above a sticky header");
// The grip was the one thing up there. It belongs inside the header now.
const header = sheet().match(/<div class="rulingsHeader">[\s\S]*?<\/div>\s*<\/div>/);
assert.ok(header, "the header is not in the markup where it was");
assert.match(header[0], /seatSheetGrip/,
"the grip is still outside the header, so it scrolls away above it");
assert.ok(ruleFor(".rulingsHeader .seatSheetGrip"),
"the grip has no rule placing it inside a two-column header");
});
test("the scroll inside the sheet stays inside it", () => {
assert.match(ruleFor("#rulingsSheet"), /overscroll-behavior:\s*contain/);
});
});
// A phone held sideways is 844x390: no height and swimming in width. Measured before
// this, the bottom sheet was 460px wide in the middle of that window — 384px of it
// unused either side — holding 754px of rulings in a 334px scrollport, 111px of which
// went on a title and a caveat that stand still. Four of the twelve names on screen.
//
// Material's rule for a bottom sheet in landscape is to stop being one, and this tool
// already does exactly that under this query for the action bar. So the sheet takes the
// window and the width pays for the height: two columns of six, all twelve names on
// screen at 844x390 with nothing to scroll at all.
describe("a phone on its side gets the whole window", () => {
test("the twelve are six and six in the markup", () => {
const columns = sheet().split('<div class="rulingsColumn">').slice(1);
assert.equal(columns.length, 2, "there are not exactly two columns to stand side by side");
columns.forEach((c, i) => {
const count = (c.slice(0, c.indexOf("\n </div>")).match(/<details class="ruling"/g) || []).length;
assert.equal(count, 6, `column ${i + 1} holds ${count} rulings rather than six`);
});
});
test("but everywhere else they are still one column", () => {
// `display: contents` hands the rulings straight to the list, so every layout
// that isn't landscape sees exactly the markup it saw before the wrappers.
assert.match(ruleFor(".rulingsColumn"), /display:\s*contents/);
assert.match(ruleIn(SIDEWAYS(), ".rulingsColumn"), /display:\s*block/);
});
test("sideways the sheet stops being a sheet and becomes the screen", () => {
const block = SIDEWAYS();
assert.match(ruleIn(block, "#rulingsOverlay"), /align-items:\s*stretch/,
"the sheet is still anchored to the bottom edge, so it is still short");
const sheetRule = ruleIn(block, "#rulingsSheet");
assert.match(sheetRule, /height:\s*100%/);
assert.match(sheetRule, /max-height:\s*none/);
// Capped rather than unbounded: this query also matches a short desktop window,
// and two columns of a 1400px one would be 680px lines of body text.
assert.match(sheetRule, /max-width:\s*62rem/);
assert.match(sheetRule, /overflow:\s*hidden/,
"the sheet scrolls as well as the list, which is two scrollbars for one list");
});
test("the title, the caveat and the way out share one line", () => {
// That line is the whole of the 111px this screen was spending on them, and the
// sticky positioning goes with the box being dropped: nothing above the list
// scrolls here, so nothing has to stick.
const block = SIDEWAYS();
assert.match(ruleIn(block, ".rulingsHeader"), /display:\s*contents/);
assert.match(ruleIn(block, ".rulingsHeader .seatSheetGrip"), /display:\s*none/,
"a grab handle on a sheet that is not a sheet any more");
assert.match(ruleIn(block, "#rulingsSheet"), /grid-template-areas:\s*\n?\s*"title intro done"/);
});
test("the two columns are two flows, not one flow wrapped", () => {
// A grid with twelve children of its own pairs them into six shared rows, and
// opening the third ruling on the left pushes the ninth down the right-hand side
// to keep the row square. Two wrappers means a column only ever moves itself.
const rule = ruleIn(SIDEWAYS(), ".rulingsList");
assert.match(rule, /grid-template-columns:\s*1fr 1fr/);
assert.match(rule, /overflow-y:\s*auto/);
assert.match(rule, /min-height:\s*0/,
"a grid item's floor is its content, so the list would push the sheet open");
});
test("and the gutter is reserved before it is needed", () => {
// It arrives the moment a ruling is opened. Without the reservation it takes 8px
// off the tracks and slides the right-hand column sideways under the eye that is
// reading it.
assert.match(ruleIn(SIDEWAYS(), ".rulingsList"), /scrollbar-gutter:\s*stable/);
});
});
-263
View File
@@ -1,263 +0,0 @@
// What a seat is doing, asked once.
//
// The seat editor had two checkboxes, "In hand" and "Fold". A seat is really in one of
// three states — sitting out, in the hand, folded — and "folded" means nothing while
// the seat is out, so two boxes offered a fourth corner that has no meaning. The code
// that stopped a dealer reaching it `disabled` the Fold box whenever In hand was clear.
//
// Measured: that left the label at opacity 1 and colour rgb(170,170,170) — identical to
// a live one — with a `title` of "Folded" on it. So pressing Fold on a seat that was
// sitting out gave no movement and no reason, which is the exact failure round 16 went
// through the whole tool to remove. It hid from the sweep rule that finds those because
// the title sat on the <label> rather than on the control the rule inspects.
//
// Three named states cannot contradict each other and none of them is ever unavailable,
// so there is nothing left to dim.
//
// The second half of this round is where that form appears at all. The phone block's
// own comment has always said why the editor becomes a bottom sheet — "the seat editor
// lives far enough down the page that tapping a seat looks like it did nothing" — but
// the sheet was scoped to 700px while #holdemLayout goes to one column at 900. The two
// tiers in between had precisely the fault the sheet was written to fix. Measured,
// tapping a seat:
//
// 844x390 the editor drew in the flow at y=369 in a 390px window: 21px of 401 on
// screen, 5% of it, at scrollY 0 with nothing scrolling it into view.
// 768x1024 31% visible, the rest below the fold, and again no scroll.
//
// Afterwards every viewport from 360x640 to 900x500 shows 97-99% of the sheet, and
// above 900 the editor is a static block at the top of a sidebar that is beside the
// table rather than below it — 100% visible there too.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const { Render, makeTable } = require("./load");
const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
const PAGE = fs.readFileSync(path.join(ROOT, "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8");
const seatForm = () => {
const at = PAGE.indexOf('<template id="seatEditorTemplate">');
assert.ok(at >= 0, "there is no seat editor template");
return PAGE.slice(at, PAGE.indexOf("</template>", at));
};
const ruleFor = selector => {
const at = CSS.indexOf(`\n${selector} {`);
assert.ok(at >= 0, `there is no \`${selector}\` rule`);
return CSS.slice(at, CSS.indexOf("}", at));
};
// Everything inside every block with this query, braces balanced and joined. Every one
// of them: this stylesheet opens `@media (hover: none)` twice, and a helper that stops
// at the first has twice caught a test asserting about the wrong half of the file.
function blockAt(query) {
const found = [];
for (let at = CSS.indexOf(query); at >= 0; at = CSS.indexOf(query, at + 1)) {
let depth = 0;
for (let i = at + query.length - 1; i < CSS.length; i++) {
if (CSS[i] === "{") depth++;
else if (CSS[i] === "}" && --depth === 0) { found.push(CSS.slice(at, i + 1)); break; }
}
}
assert.ok(found.length, `there is no \`${query}\` block`);
return found.join("\n");
}
// The three buttons, and enough of a form to hand them back.
function fakeForm() {
const options = Render.SEAT_STATES.map(state => ({
dataset: { seatState: state.key },
attrs: {},
tabIndex: 0,
handlers: {},
setAttribute(name, value) { this.attrs[name] = value; },
getAttribute(name) { return this.attrs[name]; },
addEventListener(type, fn) { this.handlers[type] = fn; },
focus() { options.focused = this.dataset.seatState; },
}));
return {
options,
querySelectorAll: selector => {
assert.equal(selector, ".seatStateOption");
return options;
},
};
}
const checked = form => form.options.filter(o => o.getAttribute("aria-checked") === "true")
.map(o => o.dataset.seatState);
// ---------------------------------------------------------------------------
describe("three states, not two booleans", () => {
test("the markup asks one question with three answers", () => {
const form = seatForm();
const at = form.indexOf('class="seatState"');
assert.ok(at >= 0, "the seat form has no state control");
const group = form.slice(form.lastIndexOf("<div", at), form.indexOf("</div>", at));
assert.match(group, /role="radiogroup"/);
assert.match(group, /aria-label="This seat"/);
["out", "in", "folded"].forEach(key => {
assert.match(group, new RegExp(`data-seat-state="${key}"`), `there is no "${key}" answer`);
});
assert.equal((group.match(/role="radio"/g) || []).length, 3);
});
test("and the two checkboxes are gone from the page and the stylesheet", () => {
["inHandCheckbox", "foldCheckbox", "inHandToggle", "foldToggle", "seatCardToggles"]
.forEach(name => {
assert.ok(!PAGE.includes(name), `${name} is still in the markup`);
assert.ok(!CSS.includes(name), `${name} is still in the stylesheet`);
});
});
// The pair (out, folded) is the one the old control could be asked for.
test("every state names both halves, and none of them is the meaningless pair", () => {
assert.deepEqual(Render.SEAT_STATES.map(s => s.key), ["out", "in", "folded"]);
Render.SEAT_STATES.forEach(state => {
assert.equal(typeof state.inHand, "boolean");
assert.equal(typeof state.folded, "boolean");
assert.ok(!(state.folded && !state.inHand),
`"${state.key}" is folded and not in the hand, which means nothing`);
});
});
test("and a seat reads back as the state it was put in", () => {
Render.SEAT_STATES.forEach(state => {
assert.equal(Render.seatStateOf({ inHand: state.inHand, folded: state.folded }), state.key);
});
// Including a seat saved before any of this, where folded was left set on a
// chair that had sat out.
assert.equal(Render.seatStateOf({ inHand: false, folded: true }), "out");
});
});
describe("what the control draws", () => {
test("exactly one answer is marked, and it is the seat's", () => {
const form = fakeForm();
Render.paintSeatState(form, { inHand: true, folded: true });
assert.deepEqual(checked(form), ["folded"]);
Render.paintSeatState(form, { inHand: false, folded: false });
assert.deepEqual(checked(form), ["out"]);
Render.paintSeatState(form, { inHand: true, folded: false });
assert.deepEqual(checked(form), ["in"]);
});
// One stop for the group, arrows inside it — the APG's radio group, and the same
// shape the card picker's suits use.
test("and exactly one of the three is a Tab stop", () => {
const form = fakeForm();
Render.paintSeatState(form, { inHand: true, folded: false });
assert.deepEqual(form.options.map(o => o.tabIndex), [-1, 0, -1]);
});
});
describe("what pressing one does", () => {
const wired = player => {
const form = fakeForm();
const commits = [];
const realCommit = Render.commitSeatChange;
Render.commitSeatChange = () => commits.push(true);
try {
Render.wireSeatState(form, player, 0);
} finally {
Render.commitSeatChange = realCommit;
}
return { form, commits, press: key => {
const btn = form.options.find(o => o.dataset.seatState === key);
const again = Render.commitSeatChange;
Render.commitSeatChange = () => commits.push(true);
try { btn.handlers.click(); } finally { Render.commitSeatChange = again; }
} };
};
test("it writes both halves of the seat, not one", () => {
const player = { inHand: true, folded: false };
const { press } = wired(player);
press("folded");
assert.deepEqual({ inHand: player.inHand, folded: player.folded }, { inHand: true, folded: true });
press("out");
assert.deepEqual({ inHand: player.inHand, folded: player.folded }, { inHand: false, folded: false });
press("in");
assert.deepEqual({ inHand: player.inHand, folded: player.folded }, { inHand: true, folded: false });
});
test("and says so, so the table redraws", () => {
const player = { inHand: true, folded: false };
const { commits, press } = wired(player);
const before = commits.length;
press("out");
assert.ok(commits.length > before, "nothing was committed, so the felt still shows the old state");
});
// Typing a name into an empty chair seats somebody. The control has to say so —
// it is the only thing on the form that shows it.
test("naming an empty chair repaints the control rather than leaving it stale", () => {
const src = fs.readFileSync(
path.join(ROOT, "wwwroot", "js", "casino", "holdem", "render.js"), "utf8");
const at = src.indexOf("const wasEmpty = !player.name.trim()");
assert.ok(at >= 0);
assert.match(src.slice(at, at + 400), /this\.paintSeatState\(form, player\);/);
});
});
describe("how it is drawn", () => {
// The answer moves between the three as the hand goes on, and a segment that
// resizes under the thumb reads as the control flinching.
test("three equal columns, whatever the words in them", () => {
assert.match(ruleFor(".seatState"), /grid-template-columns:\s*repeat\(3, minmax\(0, 1fr\)\)/);
});
test("the chosen one is the one that looks pressed", () => {
assert.match(ruleFor('.seatStateOption[aria-checked="true"]'), /background:\s*var\(--surface-3\)/);
assert.match(ruleFor('.seatStateOption[aria-checked="true"]'), /color:\s*var\(--text-bright\)/);
});
// The same red the seat menu's own Fold wears, and only when it is the answer: a red
// segment sitting unpressed beside two grey ones reads as a warning about the seat.
test("folded is red, and only once it is chosen", () => {
const rule = ruleFor('.seatStateOption[data-seat-state="folded"][aria-checked="true"]');
assert.match(rule, /#3d1f1f/);
assert.ok(!/\n\.seatStateOption\[data-seat-state="folded"\] \{/.test(CSS),
"an unchosen Folded segment is painted red");
});
test("and a thumb gets its 44px wherever there is no pointer", () => {
assert.match(blockAt("@media (hover: none) {"), /\.seatStateOption \{\s*min-height: 44px;/);
});
});
describe("where the form appears at all", () => {
// The sheet exists because a stacked sidebar puts the editor far below the felt.
// That is true at every width where the layout is one column.
test("the sheet is scoped to the breakpoint that stacks the layout", () => {
const oneColumn = blockAt("@media (max-width: 900px) {");
assert.match(oneColumn, /#holdemLayout \{\s*grid-template-columns: 1fr;/,
"900px is no longer where the sidebar stops being a column beside the table");
const sheet = CSS.slice(CSS.indexOf("---- Seat editor as a bottom sheet ----"));
const query = sheet.slice(sheet.indexOf("@media"), sheet.indexOf("{", sheet.indexOf("@media")) + 1);
assert.equal(query.trim(), "@media (max-width: 900px) {");
});
test("and it is a sheet, fixed to the bottom, not a block in the flow", () => {
const sheet = CSS.slice(CSS.indexOf("---- Seat editor as a bottom sheet ----"));
const at = sheet.indexOf(" #seatEditorPanel {");
assert.ok(at >= 0, "the sheet block no longer positions the panel");
const rule = sheet.slice(at, sheet.indexOf("}", at));
assert.match(rule, /position:\s*fixed/);
assert.match(rule, /bottom:\s*0/);
assert.match(rule, /max-height:\s*74vh/);
});
test("what was measured is written beside the query", () => {
const note = CSS.slice(CSS.indexOf("---- Seat editor as a bottom sheet ----"),
CSS.indexOf("@media (max-width: 900px) {", CSS.indexOf("---- Seat editor as a bottom sheet ----")));
assert.match(note, /844x390/);
assert.match(note, /768x1024/);
assert.match(note, /5% of it/);
});
});
-277
View File
@@ -1,277 +0,0 @@
// The seat menu on a phone held sideways.
//
// The tool learned two rounds ago that a landscape phone is short of height and
// swimming in width: the action bar stops being a bar across the bottom and becomes a
// rail down the right. The sheets were never told. Measured on the fullest menu the
// tool can build — twelve seated, somebody on the waiting list, blinds still the only
// money in:
//
// 640x360 545px of content in a 306px sheet. Cash out…, Move to…, Edit seat…,
// Small blind, Big blind and Cancel all below the fold, Cancel by 224px.
// 667x375 545 in 319, Cancel 211px under.
// 844x390 529 in 332, Cancel 183px under.
//
// And on all three the sheet was 420px wide inside a window 640 to 844 wide. Two
// hundred and forty-seven pixels of width held empty on the one screen shape whose
// whole problem is height, while the dealer scrolled a menu to find the way out of it.
//
// The two tiers this menu has been sorted into since round 7 — what you press with
// cards in the air, and housekeeping between hands — stop being the top and bottom of
// a column and become the right and left of a row. Fold and All-in land under the same
// thumb the rail puts Fold, Call and Raise under, with Cancel beneath them. Measured
// after: 297px in a 298px sheet at 640x360 and at 667x375, 281 in 282 at 844x390.
// Nothing below the fold on any of them, and the move picker's twelve chairs and its
// Back went from four rows and a scroll to three rows and none.
//
// Upright nothing moved at all: 545px before, 545px after, the same two columns.
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, SeatMenu, makeTable } = require("./load");
const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8");
const ruleFor = selector => {
const at = CSS.indexOf(`\n${selector} {`);
assert.ok(at >= 0, `there is no \`${selector}\` rule`);
return CSS.slice(at, CSS.indexOf("}", at));
};
// Everything inside one @media block, braces balanced, so a test asks what that block
// says rather than whether a string appears anywhere in six thousand lines.
const SHORT = "@media (max-height: 560px) and (min-width: 600px) {";
function shortViewportBlock() {
const at = CSS.indexOf(SHORT);
assert.ok(at >= 0, "there is no short-viewport block for the sheets");
let depth = 0;
for (let i = at + SHORT.length - 1; i < CSS.length; i++) {
if (CSS[i] === "{") depth++;
else if (CSS[i] === "}" && --depth === 0) return CSS.slice(at, i + 1);
}
assert.fail("the short-viewport block is never closed");
}
// A document small enough to answer what paint() asks of one. It builds elements,
// appends them and reads their labels back; nothing here lays anything out, which is
// the stylesheet's half of the job and is checked as text below.
function fakeDom() {
const element = tag => ({
tag,
className: "",
type: "",
textContent: "",
children: [],
appendChild(child) { this.children.push(child); return child; },
addEventListener() {},
setAttribute() {},
focus() {},
get childElementCount() { return this.children.length; },
set innerHTML(value) { assert.equal(value, "", "only clearing is modelled"); this.children = []; },
get innerHTML() { return ""; },
});
const byId = {};
["seatMenuTitle", "seatMenuSubtitle", "seatMenuActions", "seatMenuCancelBtn",
"seatMenuOverlay"].forEach(id => { byId[id] = element("div"); });
return {
byId,
doc: {
createElement: element,
getElementById: id => byId[id] || null,
},
};
}
function withDocument(doc, body) {
const had = Object.prototype.hasOwnProperty.call(globalThis, "document");
const before = globalThis.document;
globalThis.document = doc;
try { return body(); } finally {
if (had) globalThis.document = before; else delete globalThis.document;
}
}
// The fullest menu the tool can build, aimed at a live seat that isn't the dealer with
// the blinds still the only money in and an override already set.
function painted(seatIndex = 3, { move = false, seated = [0, 1, 2, 3, 4], waiting = true } = {}) {
const state = makeTable({ seatCount: 9, seated, stack: 500, dealerSeatIndex: 0 });
Betting.beginHand(state);
state.blindOverride = { sbIndex: 1, bbIndex: 2 };
if (waiting) state.waitingList = [{ id: "w1", name: "Nina" }];
const { doc, byId } = fakeDom();
return withDocument(doc, () => {
SeatMenu.state = state;
SeatMenu.hooks = {};
SeatMenu.seatIndex = seatIndex;
SeatMenu.mode = move ? "move" : null;
SeatMenu.paint();
return { list: byId.seatMenuActions, cancel: byId.seatMenuCancelBtn, state };
});
}
const classes = el => el.className.split(/\s+/).filter(Boolean);
const labels = el => el.children.map(child => child.textContent);
// ---------------------------------------------------------------------------
describe("the two tiers are boxes, not a run of siblings", () => {
// Five loose children can only ever stack. Two boxes can be placed.
test("the sheet's actions are exactly the hand and the housekeeping", () => {
const { list } = painted();
assert.deepEqual(list.children.map(child => child.className),
["seatMenuHand", "seatMenuKeeping"]);
});
test("the hand tier holds the bars a dealer presses with cards in the air", () => {
const { list } = painted();
const hand = list.children[0];
assert.deepEqual(labels(hand), ["Fold", "All-in"]);
hand.children.forEach(btn => {
assert.ok(classes(btn).includes("seatMenuBtn"));
assert.ok(!classes(btn).includes("menuTile"),
"a bar is not a tile — the whole point of the tier is that it is a bar");
});
});
test("and the housekeeping tier holds its captions and grids in order", () => {
const { list } = painted();
const keeping = list.children[1];
assert.deepEqual(keeping.children.map(child => child.className),
["seatMenuCaption", "seatMenuGrid", "seatMenuCaption", "seatMenuGrid"]);
assert.deepEqual(keeping.children.map(child => child.tag), ["p", "div", "p", "div"]);
});
// The sideways layout reads the tier's absence to know it has one column rather
// than two, so an empty tier has to be absent rather than empty.
test("a tier with nothing in it is left out rather than drawn empty", () => {
// A chair nobody is sitting in, with nobody waiting to take it: there is
// nothing at all to do to it while the cards are in the air.
const { list } = painted(7, { seated: [0, 1, 2], waiting: false });
assert.deepEqual(list.children.map(child => child.className), ["seatMenuKeeping"],
"an empty chair still drew a hand tier with nothing in it");
});
});
describe("the move picker", () => {
test("its chairs go in the housekeeping tier too, so the sheet has one column", () => {
const { list } = painted(3, { move: true });
assert.deepEqual(list.children.map(child => child.className), ["seatMenuKeeping"]);
});
// "Seat 3 — Alexandra" sets 116px against "Sit out"'s 50, so this grid asks for a
// different column from the one the tiles use.
test("and they ask for a column of their own width", () => {
const { list } = painted(3, { move: true });
const grid = list.children[0].children[0];
assert.deepEqual(classes(grid), ["seatMenuGrid", "seatMenuGridNames"]);
});
test("the way back out is still the sheet's own foot button", () => {
const { cancel } = painted(3, { move: true });
assert.equal(cancel.textContent, "Back");
assert.equal(painted(3).cancel.textContent, "Cancel");
});
});
describe("upright, nothing moved", () => {
// The wrappers gap exactly as the loose children used to, so the sheet a dealer
// holding a phone the usual way sees is the one it has been since round 7.
test("both tiers are the same column the sheet already was", () => {
const rule = ruleFor(".seatMenuHand,\n.seatMenuKeeping");
assert.match(rule, /flex-direction:\s*column/);
assert.match(rule, /gap:\s*var\(--sp-2\)/);
assert.match(rule, /min-width:\s*0/);
});
test("and the grid is still two columns wherever the screen is not short", () => {
assert.match(ruleFor(".seatMenuGrid"), /grid-template-columns:\s*repeat\(2, minmax\(0, 1fr\)\)/);
});
});
describe("what the short screen does instead", () => {
const block = shortViewportBlock();
// Height and width, not orientation: a short desktop window has the same shortage
// and the same answer, and a narrow one has neither the width to spend nor a second
// column to spend it on.
test("it is scoped on the shortage rather than on which way up the phone is", () => {
assert.ok(!/orientation/.test(SHORT), "orientation says nothing about how much height there is");
});
test("the sheet takes the width it has been leaving empty", () => {
assert.match(block, /max-width:\s*min\(100% - var\(--sp-5\), 46rem\)/);
});
test("the tiers become the two columns, with the way out under the thumb", () => {
assert.match(block, /"header\s+header"/);
assert.match(block, /"keeping hand"/);
assert.match(block, /"keeping cancel"/);
});
test("which means the box that held them stands aside", () => {
const at = block.indexOf("#seatMenuActions {");
assert.ok(at >= 0, "#seatMenuActions is not addressed here");
assert.match(block.slice(at, block.indexOf("}", at)), /display:\s*contents/);
});
// A column reserved for nothing would narrow the names for no reason.
test("a sheet with no hand tier is one column, not two", () => {
const at = block.indexOf("#seatMenuSheet:not(:has(.seatMenuHand))");
assert.ok(at >= 0, "the move picker still reserves a column for a tier it hasn't got");
const rule = block.slice(at, block.indexOf("}", at));
assert.match(rule, /grid-template-columns:\s*minmax\(0, 1fr\)/);
assert.match(rule, /"keeping"/);
});
test("the tiles spread into as many columns as the width holds", () => {
assert.match(block, /repeat\(auto-fill, minmax\(var\(--tileMin\), 1fr\)\)/);
});
// auto-fit throws away the tracks nothing sits in, so a group of two stretched to
// half the sheet each while the group above it was a third — two grids in a column
// lining up with nothing.
test("and they line up with each other, group to group", () => {
// The comment above the rule says the word it is here to rule out, so the code
// is read with the comments stripped out of it.
const code = block.replace(/\/\*[\s\S]*?\*\//g, "");
assert.ok(!/auto-fit/.test(code),
"auto-fit collapses the empty tracks and the grids stop sharing column edges");
});
// The left column is the taller of the two, and stretching left Cancel a 74px
// button on one phone and a 48px one on the next.
test("nothing grows into the slack the taller column leaves", () => {
assert.match(block, /align-items:\s*start/);
});
});
describe("the numbers the columns are chosen from", () => {
// The floor is what the longest of these labels needs, not a round figure that
// happened to work on the phone it was measured on.
test("a tile's narrowest is a token the grid of names can raise", () => {
assert.match(ruleFor(".seatMenuGrid"), /--tileMin:\s*7\.5rem/);
const block = shortViewportBlock();
const at = block.indexOf(".seatMenuGrid.seatMenuGridNames");
assert.ok(at >= 0, "the chairs never ask for more room than a verb");
assert.match(block.slice(at, block.indexOf("}", at)), /--tileMin:\s*8\.25rem/);
});
// Two words. Every pixel over that comes out of the left column, where it is a
// whole extra column of tiles.
test("the hand column is sized for Fold and All-in and no wider", () => {
const block = shortViewportBlock();
assert.match(block, /grid-template-columns:\s*minmax\(0, 1fr\) minmax\(8rem, 10rem\)/);
});
test("what was measured is written beside the rules", () => {
const block = shortViewportBlock();
const note = CSS.slice(CSS.indexOf("A phone on its side: the seat menu goes beside itself"),
CSS.indexOf(SHORT));
assert.match(note, /640x360/);
assert.match(note, /545px/);
assert.match(note, /224px/);
assert.match(block, /10rem it\s+gets 397 and fits three/);
});
});
-279
View File
@@ -1,279 +0,0 @@
// A control that can't act, pressed on the device this tool is for.
//
// Measured on an iPhone, three hands dealt, the card picker open on an empty seat:
// seven of the fifty-two cards were greyed out, none of them could take focus, and
// tapping one changed nothing whatsoever — no toast, no text, not a byte of DOM. The
// tool did have an answer to "who has the ace of spades?"; it was a `title`, and a
// `title` needs a mouse. The same held in the header, where a new dealer's very first
// screen is two dimmed buttons, and on Export CSV, which was greyed out with no
// explanation written anywhere at all.
//
// `disabled` is the wrong attribute whenever there is something to say. It takes the
// control out of the tab order, so a screen reader never lands on it and never hears
// the description; and it eats the press on a touch screen, where the hover that would
// have shown the `title` does not exist. NN/g's rule for the case is explicit — on a
// touch interface, a press on a disabled control should raise the explanation — and
// WCAG 3.3.1 wants the instruction reachable at all. `aria-disabled` announces the same
// state and leaves the control reachable, so the press arrives and is answered.
//
// It also fixed something nobody had noticed. The picker's arrow keys hunted past dealt
// cards, because a `disabled` button cannot hold focus — and hunting did the exact thing
// the grid's no-wrap rule was written to prevent: Right from the ace of spades over a
// dealt ace of hearts landed on the ace of diamonds, so the column silently shifted and
// the next press of Down walked a different suit. A cell that can hold focus needs no
// hunting, so that code is gone and the grid tells the truth about its own shape.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const { Unavailable } = require("./load");
const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8");
const MARKUP = fs.readFileSync(path.join(ROOT, "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
const src = name => fs.readFileSync(path.join(ROOT, "wwwroot", "js", "casino", "holdem", `${name}.js`), "utf8");
const ruleFor = selector => {
const at = CSS.indexOf(`\n${selector} {`);
assert.ok(at >= 0, `there is no \`${selector}\` rule`);
return CSS.slice(at, CSS.indexOf("}", at));
};
// Enough of an element for a module that only ever reads three attributes off one.
const element = (attrs = {}) => ({
dataset: {},
attrs: { ...attrs },
setAttribute(name, value) { this.attrs[name] = String(value); },
getAttribute(name) { return name in this.attrs ? this.attrs[name] : null; },
removeAttribute(name) { delete this.attrs[name]; },
});
// ---------------------------------------------------------------------------
describe("marking a control that cannot act", () => {
test("it says so in the tree, and carries the reason with it", () => {
const btn = element();
Unavailable.set(btn, true, "Nothing to resolve yet — seat a player first.");
assert.equal(btn.getAttribute("aria-disabled"), "true");
assert.equal(Unavailable.reason(btn), "Nothing to resolve yet — seat a player first.");
assert.ok(Unavailable.blocked(btn));
});
// The state and the reason are set and cleared in one call on purpose. A control
// left marked after the reason stopped being true is worse than one never marked:
// it is a live control that refuses to work and explains why in the past tense.
test("and both halves come off together", () => {
const btn = element();
Unavailable.set(btn, true, "Nothing to resolve yet.");
Unavailable.set(btn, false, "Nothing to resolve yet.");
assert.equal(btn.getAttribute("aria-disabled"), null);
assert.equal(Unavailable.reason(btn), "");
assert.ok(!Unavailable.blocked(btn));
});
test("a control nobody marked is not blocked and has nothing to say", () => {
assert.ok(!Unavailable.blocked(element()));
assert.equal(Unavailable.reason(element()), "");
assert.equal(Unavailable.reason(null), "");
assert.ok(!Unavailable.blocked(null));
});
// `aria-disabled="false"` is a real thing to write and it means enabled. Reading the
// attribute's presence rather than its value would have got that backwards.
test("and one marked false is enabled, which is what the attribute means", () => {
const btn = element({ "aria-disabled": "false" });
assert.ok(!Unavailable.blocked(btn));
});
});
describe("what happens when it is pressed", () => {
// The press can land on a span inside the button rather than the button, which is
// most of what the picker's cards are made of.
test("the answer is looked for upwards from whatever was hit", () => {
const btn = element();
Unavailable.set(btn, true, "A♠ — dealt to Maya");
const rank = { closest: selector => (selector === '[aria-disabled="true"]' ? btn : null) };
assert.equal(Unavailable.reason(Unavailable.from(rank)), "A♠ — dealt to Maya");
});
test("a press on nothing in particular finds nothing", () => {
assert.equal(Unavailable.from(null), null);
assert.equal(Unavailable.from({}), null);
});
// Nothing about `aria-disabled` stops a click reaching its own handler — that is the
// trade for staying reachable. One listener does the stopping, in the capture phase,
// so a control's own handler never runs and never has to remember to check.
test("the press is stopped before the control's own handler sees it", () => {
const listener = src("feedback").slice(src("feedback").indexOf("listen(root) {"));
assert.match(listener, /addEventListener\("click"[\s\S]*?\}, true\)/,
"the listener is not in the capture phase, so it cannot stop anything");
assert.match(listener, /event\.preventDefault\(\)/);
assert.match(listener, /event\.stopPropagation\(\)/);
});
test("and it is wired once, for the whole table", () => {
const main = src("main");
assert.match(main, /Unavailable\.listen\(document\)/);
assert.equal((main.match(/Unavailable\.listen\(/g) || []).length, 1);
});
// A control with nothing to say is not a control this is about — the seat sheet's
// own greyed-out fields, say. Swallowing its press would be a new dead end.
test("a marked control with no reason is left entirely alone", () => {
const btn = element();
Unavailable.set(btn, true, "");
assert.equal(Unavailable.reason(btn), "");
});
});
describe("where the answer is written", () => {
// The toast is fixed to the bottom of the window at z-index 80; the card picker's
// scrim is 95 and its sheet stands 88vh tall. A toast raised for a control inside
// that sheet would be behind it, or over the cards if it were not.
test("a sheet that covers the toast says it itself", () => {
assert.match(MARKUP, /id="cardPickerSheet"[^>]*data-why-note="cardPickerNote"/);
assert.match(MARKUP, /id="cardPickerNote"[^>]*role="status"/);
});
test("and everything else falls through to the toast", () => {
const say = src("feedback").slice(src("feedback").indexOf("say(el, why) {"));
assert.match(say, /closest\("\[data-why-note\]"\)/);
assert.match(say, /Toast\.show\(why\)/);
});
// Cleared on the next paint rather than on a timer: a paint is exactly the set of
// things that make the last answer stale, and nothing else in the sheet redraws.
test("the line is cleared by the next thing that changes the sheet", () => {
const picker = src("cardPicker");
const at = picker.indexOf("paint() {");
assert.ok(at >= 0);
const opening = picker.slice(at, picker.indexOf("cardPickerTitle", at));
assert.match(opening, /getElementById\("cardPickerNote"\)/);
assert.match(opening, /note\.textContent = ""/);
});
});
describe("the line holds its place", () => {
// Filling it must not move the grid. It sits between the slots and the cards, so a
// line that appeared from nothing would shove fifty-two targets down the screen
// under a thumb already travelling towards one of them.
test("it is one line tall whether or not it is saying anything", () => {
const rule = ruleFor(".pickerNote");
assert.match(rule, /min-height:\s*1\.35em/);
assert.match(rule, /line-height:\s*1\.35/);
});
// Measured: the note's box is 318px on a Galaxy S8 at 20px of root text, and a name
// is whatever somebody typed. Twenty-four capital Ws draw 434px in it. No wording
// is safe, so the box is what holds.
test("and it cannot become two, whatever the name is", () => {
const rule = ruleFor(".pickerNote");
assert.match(rule, /white-space:\s*nowrap/);
assert.match(rule, /overflow:\s*hidden/);
assert.match(rule, /text-overflow:\s*ellipsis/);
});
test("what it measured is written beside the rule", () => {
const at = CSS.indexOf("/* Which card, and who has it.");
assert.ok(at >= 0, "the rule carries no note saying why");
const note = CSS.slice(at, CSS.indexOf("*/", at));
assert.match(note, /318px/);
assert.match(note, /434px/);
});
// Which is the half that survives the clipping, and the half that does not.
test("the card and where it went come before the name", () => {
const picker = src("cardPicker");
assert.match(picker, /\$\{label\} — face up on the board/);
assert.match(picker, /\$\{label\} — dealt to \$\{takenBy\}/);
});
});
describe("dimmed looks dimmed, however it was said", () => {
test("a button marked either way is painted the same", () => {
const at = CSS.indexOf("\n.holdemBtn:disabled,");
assert.ok(at >= 0, "the two states are painted by separate rules, so they can drift apart");
assert.match(CSS.slice(at, CSS.indexOf("}", at)), /\.holdemBtn\[aria-disabled="true"\]/);
});
test("and so is a card", () => {
const at = CSS.indexOf("\n.pickerCard:disabled,");
assert.ok(at >= 0);
assert.match(CSS.slice(at, CSS.indexOf("}", at)), /\.pickerCard\[aria-disabled="true"\]/);
});
// A press animation on a control that will not act is a lie told in 40ms.
test("neither one presses down under a finger that can't use it", () => {
[".holdemBtn:active", ".pickerCard:active", ".pickerCard:hover"].forEach(base => {
const found = CSS.includes(`${base}:not(:disabled):not([aria-disabled="true"])`);
assert.ok(found, `\`${base}\` still animates for a control marked aria-disabled`);
});
});
// `disabled` is a state the OS palette knows and draws in GrayText by itself.
// `aria-disabled` is a word in the accessibility tree and nothing else, so in
// forced-colors mode a dealt card came out looking exactly like a live one.
test("and the OS is told about it too, since it only knows the other one", () => {
// Whichever of the file's forced-colors blocks it landed in — there is more than
// one of them, and the stylesheet is free to answer a query wherever it likes.
const open = "@media (forced-colors: active) {";
const blocks = [];
for (let at = CSS.indexOf(open); at >= 0; at = CSS.indexOf(open, at + 1)) {
blocks.push(CSS.slice(at, CSS.indexOf("\n}", at)));
}
assert.ok(blocks.length, "nothing in the file answers forced-colors at all");
const answering = blocks.filter(b => /\[aria-disabled="true"\]/.test(b));
assert.equal(answering.length, 1, "the dimmed state has no forced-colors answer");
assert.match(answering[0], /\.pickerCard\[aria-disabled="true"\]/);
assert.match(answering[0], /\.holdemBtn\[aria-disabled="true"\]/);
assert.match(answering[0], /color:\s*GrayText/);
});
});
describe("the three controls that were dead ends", () => {
test("the header's two say why, and are reachable enough to say it", () => {
const render = src("render");
const at = render.indexOf("renderHeaderActions() {");
const body = render.slice(at, render.indexOf("\n },", at));
assert.match(body, /Unavailable\.set\(button, nothingToRunYet, why\)/);
assert.ok(!/button\.disabled = /.test(body), "one of them is still natively disabled");
assert.match(body, /Nothing to resolve yet/);
assert.match(body, /Nothing to move on from/);
});
test("Export CSV has a reason at all, which it never did", () => {
const render = src("render");
const at = render.indexOf('getElementById("exportHistoryBtn")');
assert.ok(at >= 0);
const around = render.slice(at - 200, at + 260);
assert.match(around, /Unavailable\.set\(/);
assert.match(around, /No hands to export yet/);
});
// Found the same afternoon, in the same sheet, by running the whole audit over an
// open overlay for the first time: the two slot buttons above the grid are drawn as
// a card or an em dash, and the em dash was the entire name a screen reader was
// given. "Dash, button", twice, on the sheet where a dealer writes a card down.
test("and the slots above it are called something", () => {
const picker = src("cardPicker");
const at = picker.indexOf("btn.className = \"pickerSlot\"");
const body = picker.slice(at, picker.indexOf("slots.appendChild(btn)", at));
assert.match(body, /this\.boardSlotLabel\(slot\) : `Card \$\{slot \+ 1\}`/,
"a seat's slots still have no name of their own");
assert.match(body, /setAttribute\("aria-label", value \? .* : .*empty.*\)/,
"an empty slot does not say that it is empty");
});
test("and a dealt card names itself rather than saying \"this one\"", () => {
const picker = src("cardPicker");
const at = picker.indexOf("const takenBy = used.get(card)");
const body = picker.slice(at, picker.indexOf("grid.appendChild(btn)", at));
assert.match(body, /Unavailable\.set\(btn, !!takenBy/);
assert.ok(!/btn\.disabled = /.test(body), "the cards are still natively disabled");
// The `title` is the same sentence, and now reaches a screen reader because the
// element it hangs off can be landed on.
assert.match(body, /btn\.title = Unavailable\.reason\(btn\)/);
});
});
-333
View File
@@ -1,333 +0,0 @@
// Undo, and whether a dealer can actually get to it.
//
// This tool prefers undo to confirmation — NN/g's own preference, and `runUndoable`
// says so in as many words: "it now just happens, and the toast offers the way back for
// the few seconds anyone would notice a misclick." That makes the toast's Undo the
// safety net under the biggest things the tool does. Three ways it wasn't one:
//
// * Every whole-step undo clears the per-action history on its way past, so the bar
// has no pill left and this button is the only way back. Measured on a desktop it
// is the 67th of the page's 68 tab stops, and it is gone in eight seconds. `U` —
// the key the sidebar legend advertises as taking back the last action — found
// nothing at all and did nothing.
// * The eight seconds ran whatever the dealer was doing. A cursor resting on the
// toast did not stop it; nor did tabbing onto the button itself. Measured: focus
// the Undo, wait 8.6 seconds, and the toast is gone and focus has been handed to
// <body> — the offer withdrawn and the dealer's place in the page with it.
// * Each toast carried its own role="status" inside #toastHost, which is already a
// live region in the markup. A region nested in a region, announced twice.
//
// The timing rules here are the usual ones: three to four seconds for a plain
// confirmation, six to ten for anything carrying a button, pause on hover and on
// keyboard focus, and always a manual way out — which is what WCAG 2.2.1 asks of
// content that disappears on a clock.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const { Toast } = require("./load");
const SRC = path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem");
const MAIN = fs.readFileSync(path.join(SRC, "main.js"), "utf8");
const FEEDBACK = fs.readFileSync(path.join(SRC, "feedback.js"), "utf8");
const SHORTCUTS = fs.readFileSync(path.join(SRC, "shortcuts.js"), "utf8");
const PAGE = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
// ---------------------------------------------------------------------------
// Enough of a document for a toast: a host it can be put into, elements that can hold
// listeners and focus, and a `:hover` that a test can turn on.
let active = null;
const node = (tag, className) => {
const self = {
tagName: String(tag).toUpperCase(),
className: className || "",
textContent: "",
dataset: {},
children: [],
parent: null,
isConnected: true,
hovered: false,
listeners: {},
attrs: {},
classList: { add() {} },
appendChild(child) { child.parent = self; self.children.push(child); return child; },
addEventListener(type, fn) { (self.listeners[type] = self.listeners[type] || []).push(fn); },
setAttribute(name, value) { self.attrs[name] = String(value); },
getAttribute(name) { return name in self.attrs ? self.attrs[name] : null; },
contains(other) {
for (let n = other; n; n = n.parent) if (n === self) return true;
return false;
},
matches(selector) { return selector === ":hover" ? self.hovered : false; },
focus() { active = self; },
getBoundingClientRect: () => ({ height: 44 }),
// Fire one of the listeners the toast hung on itself.
fire(type, event) { (self.listeners[type] || []).forEach(fn => fn(event || {})); },
find(className) {
if (self.className === className) return self;
for (const child of self.children) {
const hit = child.find(className);
if (hit) return hit;
}
return null;
},
};
Object.defineProperty(self, "firstElementChild", { get: () => self.children[0] || null });
Object.defineProperty(self, "innerHTML", {
get: () => "",
set(value) {
if (value !== "") return;
self.children.forEach(child => { child.parent = null; child.isConnected = false; });
self.children.length = 0;
},
});
return self;
};
let host;
const openDocument = () => {
host = node("div", "toastHost");
host.id = "toastHost";
active = null;
global.document = {
getElementById: id => (id === "toastHost" ? host : null),
createElement: tag => node(tag),
documentElement: { style: { setProperty() {} } },
};
Object.defineProperty(global.document, "activeElement", { get: () => active });
global.requestAnimationFrame = fn => fn();
};
const toastOnScreen = () => host.firstElementChild;
const undoButton = () => toastOnScreen() && toastOnScreen().find("toastAction");
// The toast's own timers are the thing under test, so they are driven rather than
// waited on. `resume` defers to the next tick on purpose — see feedback.js — so every
// release is followed by a tick of 1 to let that land.
const settle = t => t.mock.timers.tick(1);
const show = (options = {}) => Toast.show(options.message || "Button moved on.", options);
test.beforeEach(t => {
t.mock.timers.enable({ apis: ["setTimeout", "Date"] });
openDocument();
});
test.afterEach(() => {
Toast.dismiss();
delete global.document;
delete global.requestAnimationFrame;
});
// ---------------------------------------------------------------------------
describe("a toast nobody touches", () => {
test("stays for the time it was given, and then goes", t => {
show({ duration: 5000 });
t.mock.timers.tick(4900);
assert.ok(toastOnScreen(), "it left early");
t.mock.timers.tick(200);
assert.equal(toastOnScreen(), null, "it outstayed its duration");
});
test("and the default is the one a message with a button in it gets", () => {
// Three to four seconds for a plain confirmation, six to ten for anything
// carrying a button. Every plain one here names its own shorter duration; this
// default is what the undoable steps and the wake lock fall back to.
assert.ok(Toast.DEFAULT_DURATION_MS >= 6000 && Toast.DEFAULT_DURATION_MS <= 10000,
`${Toast.DEFAULT_DURATION_MS}ms is outside the range for a toast with an action in it`);
});
});
describe("a toast the dealer has reached for", () => {
test("a finger on it stops the clock", t => {
show({ duration: 8000 });
t.mock.timers.tick(1000);
const toast = toastOnScreen();
toast.hovered = true;
toast.fire("pointerenter");
t.mock.timers.tick(30000);
assert.ok(toastOnScreen(), "eight seconds ran out under a finger that was holding it");
});
test("and so does tabbing onto the button", t => {
show({ duration: 8000, actionLabel: "Undo", onAction: () => {} });
const toast = toastOnScreen();
undoButton().focus();
toast.fire("focusin", { relatedTarget: null });
t.mock.timers.tick(30000);
assert.ok(toastOnScreen(), "the offer was withdrawn while the keyboard was standing on it");
assert.equal(active, undoButton(), "and focus went with it");
});
test("letting go starts it again", t => {
show({ duration: 8000 });
const toast = toastOnScreen();
toast.hovered = true;
toast.fire("pointerenter");
t.mock.timers.tick(30000);
toast.hovered = false;
toast.fire("pointerleave");
settle(t);
t.mock.timers.tick(7000);
assert.ok(toastOnScreen(), "it left before the time it still had");
t.mock.timers.tick(1200);
assert.equal(toastOnScreen(), null, "letting go did not restart the clock at all");
});
test("and letting go late still leaves time to act", t => {
// The half of this that pausing alone doesn't fix: touch it at 7.9 seconds of
// 8, let go, and what is left of the clock to *notice* the message is 100ms —
// gone while the thumb is still travelling to Undo.
show({ duration: 8000 });
const toast = toastOnScreen();
t.mock.timers.tick(7900);
toast.hovered = true;
toast.fire("pointerenter");
t.mock.timers.tick(3000);
toast.hovered = false;
toast.fire("pointerleave");
settle(t);
t.mock.timers.tick(Toast.RESUME_MS - 500);
assert.ok(toastOnScreen(), "it went in the 100ms that was left rather than the time to act");
t.mock.timers.tick(700);
assert.equal(toastOnScreen(), null, "and then it never went at all");
});
test("a cursor leaving while the keyboard is still inside does not restart it", t => {
// Two ways to hold it open, one timer. The pointer leaving is not an answer to
// "is anybody still using this?"
show({ duration: 8000, actionLabel: "Undo", onAction: () => {} });
const toast = toastOnScreen();
toast.hovered = true;
toast.fire("pointerenter");
undoButton().focus();
toast.fire("focusin", { relatedTarget: null });
toast.hovered = false;
toast.fire("pointerleave");
settle(t);
t.mock.timers.tick(30000);
assert.ok(toastOnScreen(), "the clock restarted under a focused button");
});
});
describe("what happens to focus when it goes", () => {
test("the dealer is put back where they had got to", t => {
const elsewhere = node("button");
show({ actionLabel: "Undo", onAction: () => {} });
const toast = toastOnScreen();
elsewhere.focus();
undoButton().focus();
toast.fire("focusin", { relatedTarget: elsewhere });
Toast.dismiss();
settle(t);
assert.equal(active, elsewhere,
"focus was left on a button that no longer exists, which hands it to <body>");
});
test("and not somewhere they left eight seconds ago", t => {
// Captured when focus crosses into the toast, not when the toast appeared: a
// dealer who carries on working and then tabs back should land where they got
// to, not where they were when the message arrived.
const whenItArrived = node("button");
const whereTheyGotTo = node("button");
whenItArrived.focus();
show({ actionLabel: "Undo", onAction: () => {} });
const toast = toastOnScreen();
whereTheyGotTo.focus();
undoButton().focus();
toast.fire("focusin", { relatedTarget: whereTheyGotTo });
Toast.dismiss();
settle(t);
assert.equal(active, whereTheyGotTo);
});
test("focus is left alone when it was never in the toast", () => {
const elsewhere = node("button");
show({ actionLabel: "Undo", onAction: () => {} });
elsewhere.focus();
Toast.dismiss();
assert.equal(active, elsewhere, "dismissing a toast nobody was in moved the dealer");
});
test("and a control that has gone since is not reached for", () => {
const gone = node("button");
show({ actionLabel: "Undo", onAction: () => {} });
const toast = toastOnScreen();
gone.focus();
undoButton().focus();
toast.fire("focusin", { relatedTarget: gone });
// A re-render between the toast going up and coming down. This happens on every
// undo: restoreState rebuilds the table.
gone.isConnected = false;
assert.doesNotThrow(() => Toast.dismiss());
});
});
describe("the key that reaches it", () => {
test("a toast says which shortcut presses its button", () => {
show({ actionLabel: "Undo", shortcut: "undo", onAction: () => {} });
assert.equal(undoButton().dataset.shortcut, "undo");
});
test("and one that named none is not reachable by a stray keypress", () => {
// "Turn off" on the wake lock and "Reload" on the other-tab warning both live
// in this same strip. Neither is a thing U should be able to do.
show({ actionLabel: "Turn off", onAction: () => {} });
assert.equal(undoButton().dataset.shortcut, undefined);
});
test("both of the undoable paths opt in", () => {
// runUndoable, for whole steps, and seatAction, for a gesture on a seat.
const offers = MAIN.split('actionLabel: "Undo"').length - 1;
const named = MAIN.split('shortcut: "undo"').length - 1;
assert.equal(offers, 2, "the number of Undo toasts has changed");
assert.equal(named, offers, "an Undo the keyboard cannot reach");
});
test("the key presses the button rather than running its own copy of it", () => {
// The house rule for every other shortcut in this file: press what is on the
// screen, so there is no second code path to drift out of step with it.
assert.match(MAIN, /#toastHost \.toastAction\[data-shortcut=/);
assert.match(MAIN, /undo: \(\) => pressToastAction\("undo"\) \|\| pressInBar\("\.actionUndo"\)/,
"the toast is the more recent of the two and has to be asked first");
});
test("and the legend covers both undos, because a dealer has no reason to know there are two", () => {
const line = SHORTCUTS.match(/keys: \["U"\][^}]*what: "([^"]+)"/);
assert.ok(line, "the U shortcut has no legend line");
assert.doesNotMatch(line[1], /last action$/,
"the legend still promises only the bar's pill");
});
});
describe("one live region, and it is in the markup", () => {
test("the host is the region", () => {
assert.match(PAGE, /id="toastHost"[^>]*aria-live="polite"/);
});
test("and a toast does not bring a second one with it", () => {
// A region that arrives already carrying its text has not been watched by
// anything yet, which is the documented reason such a message is never spoken.
// The host is in the markup precisely so that it has been.
assert.ok(!/toast\.setAttribute\("role"/.test(FEEDBACK),
"each toast is its own live region again, nested inside the one that works");
show({});
assert.equal(toastOnScreen().getAttribute("role"), null);
});
});
-224
View File
@@ -1,224 +0,0 @@
// One number, one control, and a ceiling that matches the table.
//
// "Split between" on Quick Split was two controls for one value sitting on top of each
// other: a row of chips 2 3 4 5, and under it a bar reading "2 ways" with a − and a +.
// Measured on an iPhone: 134px with its label, against 74px for "Smallest chip in play"
// right below it, which offers five choices in one row. And the number was written down
// three times at once — chip "2" lit gold, "2 ways" in the bar, and "Each of 2 players
// gets" in the result panel above. Two of those three were controls.
//
// The bar was never meant to be a bar. `.waysStepper` asked for `min-width: 150px` in a
// wrapping flex row, so on any phone it wrapped to a line of its own, and `flex: 1 1
// auto` then gave it all 358px of that line: 252px of empty readout with the − and the
// + pushed 304px apart. One-handed, on a screen 390px wide, they cannot both be reached.
//
// So the chips get a fifth chip. It reads "6+" whatever is behind it, because behind it
// is where the count is written, and the stepper only exists once you are through the
// door. Under six ways — every chop a home game will ever run — the field is one row.
//
// Two more things went with it. Neither stepper on this screen ever disabled an end, so
// − at the floor and + at the ceiling looked live and swallowed the tap; NN/G's stepper
// guidelines ask for exactly the opposite. And the per-pot chop capped its + with
// `Math.max(contenderCount, MAX_WAYS)`, which is always 12: a side pot that only P1 and
// P2 were eligible for would happily be chopped ten ways at $12 each.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const { QuickSplit } = require("./load");
const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "quicksplit.css"), "utf8");
const SRC = fs.readFileSync(path.join(ROOT, "wwwroot", "js", "casino", "holdem", "quickSplit.js"), "utf8");
const ruleFor = selector => {
const at = CSS.indexOf(`\n${selector} {`);
assert.ok(at >= 0, `there is no \`${selector}\` rule`);
return CSS.slice(at, CSS.indexOf("}", at));
};
// ---------------------------------------------------------------------------
describe("where the chips stop and the stepper starts", () => {
test("the door is the first count the chips don't cover", () => {
assert.deepEqual(QuickSplit.WAY_CHOICES, [2, 3, 4, 5]);
assert.equal(QuickSplit.overflowFrom(), 6);
});
// Derived rather than written down twice: adding a chip has to move the door.
test("and it moves if a chip is added or taken away", () => {
const was = QuickSplit.WAY_CHOICES;
try {
QuickSplit.WAY_CHOICES = [2, 3];
assert.equal(QuickSplit.overflowFrom(), 4);
} finally {
QuickSplit.WAY_CHOICES = was;
}
});
test("the stepper's own range is what the door promised", () => {
assert.equal(QuickSplit.clampWays(QuickSplit.overflowFrom() - 1), 5);
assert.equal(QuickSplit.clampWays(QuickSplit.MAX_WAYS + 4), QuickSplit.MAX_WAYS);
assert.equal(QuickSplit.clampWays(0), QuickSplit.WAY_CHOICES[0]);
assert.equal(QuickSplit.clampWays(-3), 2);
});
test("the door is drawn once, from the same number", () => {
const at = SRC.indexOf("renderWayChoices() {");
const body = SRC.slice(at, SRC.indexOf("\n },", at));
assert.match(body, /`\$\{this\.overflowFrom\(\)\}\+`/,
"the chip's label is typed in rather than taken from where the chips stop");
assert.match(body, /aria-label/, "\"6+\" is not a name a screen reader can read out");
});
// The chips and the stepper are one group: when the count is past the chips, no
// chip is lit, and the door is.
test("nothing is lit in two places at once", () => {
const at = SRC.indexOf("renderWayChoices() {");
const body = SRC.slice(at, SRC.indexOf("\n },", at));
assert.match(body, /const beyond = this\.ways >= this\.overflowFrom\(\)/);
assert.match(body, /!beyond && this\.ways === count/,
"a preset chip can still light up while the stepper holds a different number");
assert.match(body, /if \(beyond\) row\.appendChild\(this\.buildWaysStepper\(\)\)/,
"the stepper is drawn even when the chips are the control");
});
test("and the count is written in one place, without a unit the label already gave", () => {
const at = SRC.indexOf("buildWaysStepper() {");
assert.ok(at >= 0);
const body = SRC.slice(at, SRC.indexOf("\n },", at));
assert.match(body, /readout\.textContent = String\(this\.ways\)/);
assert.ok(!/\$\{this\.ways\} ways/.test(body), "the stepper is spelling the count out again");
});
});
describe("both ends of both steppers", () => {
test("a step that cannot happen says so", () => {
const at = SRC.indexOf("makeStepButton(label, onPick, options) {");
assert.ok(at >= 0, "the step buttons still take no disabled state");
assert.match(SRC.slice(at, SRC.indexOf("\n },", at)), /btn\.disabled = /);
});
test("the ways stepper disables its floor and its ceiling", () => {
const at = SRC.indexOf("buildWaysStepper() {");
const body = SRC.slice(at, SRC.indexOf("\n },", at));
assert.match(body, /disabled: this\.ways <= this\.overflowFrom\(\)/);
assert.match(body, /disabled: this\.ways >= this\.MAX_WAYS/);
});
test("so does the per-pot chop", () => {
const at = SRC.indexOf("addChopControl(row, amount, contenderCount) {");
const body = SRC.slice(at, SRC.indexOf("\n updateUneven()", at));
assert.match(body, /disabled: ways <= 2/);
assert.match(body, /disabled: ways >= ceiling/);
});
// 40x44 until the sweep first opened the even mode and read back `hittable 39x43`
// on every touch viewport there is.
test("and both ends are a fingertip wide, not just a fingertip tall", () => {
const rule = ruleFor(".stepBtn");
const px = axis => Number((rule.match(new RegExp(`${axis}:\\s*(\\d+)px`)) || [])[1]);
assert.ok(px("width") >= 44, `a step button is ${px("width")}px wide`);
assert.ok(px("height") >= 44, `a step button is ${px("height")}px tall`);
});
// Disabled has to look disabled, or it is the same silent tap with extra steps.
test("and a disabled end is painted as one", () => {
assert.match(ruleFor(".stepBtn:disabled"), /opacity:\s*0\.\d+/);
assert.match(CSS, /\.stepBtn:not\(:disabled\):hover/,
"a dead button still lights up under the mouse");
});
});
describe("how many ways a pot can go", () => {
// The whole of the bug: `Math.max` where `Math.min` was meant.
test("never more ways than there are players who can win it", () => {
assert.equal(QuickSplit.chopCeiling(2), 2);
assert.equal(QuickSplit.chopCeiling(3), 3);
assert.equal(QuickSplit.chopCeiling(9), 9);
});
test("and never past the screen's own ceiling", () => {
assert.equal(QuickSplit.chopCeiling(20), QuickSplit.MAX_WAYS);
});
// A pot is only offered a chop at two contenders or more, but the floor is stated
// here too so that a caller that gets it wrong can't produce a one-way split.
test("a chop is at least two ways", () => {
assert.equal(QuickSplit.chopCeiling(1), 2);
assert.equal(QuickSplit.chopCeiling(0), 2);
});
test("the chop's + is held to it", () => {
const at = SRC.indexOf("addChopControl(row, amount, contenderCount) {");
const body = SRC.slice(at, SRC.indexOf("\n updateUneven()", at));
assert.match(body, /const ceiling = this\.chopCeiling\(contenderCount\)/);
assert.match(body, /if \(ways < ceiling\)/);
assert.ok(!/Math\.max\(contenderCount/.test(body), "the old ceiling is still there");
});
// Two eligible players is two ways and nothing else. A stepper with both ends dead
// is furniture.
test("a heads-up pot is offered no stepper at all", () => {
const at = SRC.indexOf("addChopControl(row, amount, contenderCount) {");
const body = SRC.slice(at, SRC.indexOf("\n updateUneven()", at));
assert.match(body, /if \(ceiling > 2\) \{/);
assert.ok(body.indexOf("if (ceiling > 2) {") < body.indexOf('className = "unevenChopWays"'));
});
});
describe("the bar that was never meant to be a bar", () => {
test("the stepper is as wide as what is in it", () => {
const rule = ruleFor(".waysStepper");
assert.match(rule, /flex:\s*0 0 auto/);
assert.ok(!/min-width/.test(rule),
"a min-width in a wrapping row is what handed it the whole second line");
});
test("and the readout no longer pushes the two buttons apart", () => {
const rule = ruleFor(".waysReadout");
assert.ok(!/flex:\s*1(\s|;)/.test(rule));
assert.match(rule, /min-width:\s*[\d.]+ch/);
assert.match(rule, /tabular-nums/);
});
// It is standing in for a selected chip, so it is painted like one.
test("the count is painted like the chip it replaces", () => {
const chip = ruleFor(".choiceChip.selected");
// Not `border-color`, which comes first in the rule and is a different gold.
const colour = (chip.match(/(?:^|[^-\w])color:\s*(#[0-9a-f]{3,6})/i) || [])[1];
assert.ok(colour, "the selected chip has no colour to match");
assert.match(ruleFor(".waysReadout"), new RegExp(`color:\\s*${colour}`, "i"));
});
// The measurement that found it, kept where the next round will read it.
test("what it measured is written beside the rule", () => {
const at = CSS.indexOf("/* As wide as the three things in it");
assert.ok(at >= 0, "the rule carries no note saying why");
const note = CSS.slice(at, CSS.indexOf("*/", at));
assert.match(note, /304px/);
assert.match(note, /252px/);
});
});
describe("what the sweep looks at", () => {
const DRIVER = fs.readFileSync(path.join(__dirname, "runUiAudit.js"), "utf8");
// It went straight to the all-in mode, so the mode the screen opens in was audited
// zero times in fourteen rounds.
test("both modes of the screen, not just the second one", () => {
const at = DRIVER.indexOf("const modes = [");
assert.ok(at >= 0, "the quick split pass still audits one mode");
const body = DRIVER.slice(at, DRIVER.indexOf("];", at));
assert.match(body, /"even"/);
assert.match(body, /"all-in"/);
assert.match(DRIVER, /quick split · \$\{mode\}/, "a failure won't say which mode it was in");
});
test("with the stepper open, since it isn't there otherwise", () => {
const at = DRIVER.indexOf('["even", () => {');
assert.ok(at >= 0);
assert.match(DRIVER.slice(at, DRIVER.indexOf("}]", at)), /#waysChoices \.choiceChip/);
});
});

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