Compare commits
64
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
139e2b831c | ||
|
|
96d2dbe5ad | ||
|
|
2c269d075d | ||
|
|
7baa6e9810 | ||
|
|
f76b766d0e | ||
|
|
aad548cd07 | ||
|
|
f4271831f4 | ||
|
|
1e82052324 | ||
|
|
a9efaa2725 | ||
|
|
19bba58f7c | ||
|
|
4006764a23 | ||
|
|
a0b16e7d48 | ||
|
|
059299db74 | ||
|
|
20d0ee8910 | ||
|
|
e6befaff5e | ||
|
|
8305c72843 | ||
|
|
1f297c244d | ||
|
|
a77d985135 | ||
|
|
78650c8ee7 | ||
|
|
d4f12c2b47 | ||
|
|
037e29055a | ||
|
|
27dd0ccb3e | ||
|
|
4c1c81fd2f | ||
|
|
7cc964380b | ||
|
|
3e072dbd5d | ||
|
|
4e686aaf72 | ||
|
|
a75c24fcf2 | ||
|
|
56c041f405 | ||
|
|
f9cf2752d2 | ||
|
|
54f951520a | ||
|
|
64852cf6b7 | ||
|
|
0340c33bfb | ||
|
|
b1ed1b9b13 | ||
|
|
e8f930e9fc | ||
|
|
3326557043 | ||
|
|
edf159325b | ||
|
|
6e7b047d3e | ||
|
|
171e878262 | ||
|
|
0f7213ec2d | ||
|
|
853fcedea0 | ||
|
|
295d9eff4a | ||
|
|
fafbd8df32 | ||
|
|
0d878a176d | ||
|
|
7216f3da4d | ||
|
|
e2bb935b43 | ||
|
|
75ec028c3e | ||
|
|
e90bcccada | ||
|
|
14fb68dc04 | ||
|
|
4a1cb842cb | ||
|
|
e7ddff2fed | ||
|
|
f8ed4abc85 | ||
|
|
942919f3f7 | ||
|
|
02c2a034cb | ||
|
|
cd2db6346e | ||
|
|
6eb97c8a35 | ||
|
|
bd8ee11f77 | ||
|
|
13d704301d | ||
|
|
7bd205a91a | ||
|
|
f07144acd8 | ||
|
|
fbc4bd9a3e | ||
|
|
cd57c16050 | ||
|
|
2a080eab6a | ||
|
|
f93e0935ef | ||
|
|
190ccebf6c |
+6
-1
@@ -366,4 +366,9 @@ MigrationBackup/
|
||||
FodyWeavers.xsd
|
||||
|
||||
# Native chess engine CMake build output (the compiled .dll/.so are committed under Resources/)
|
||||
native/**/build/
|
||||
native/**/build/
|
||||
# Playwright MCP scratch output
|
||||
.playwright-mcp/
|
||||
|
||||
# Private /socialism working files (founding draft, progress log) - repo is public
|
||||
JoshHeaps.Net/Content/Socialism/_*.md
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
---
|
||||
title: When a Locked Co-op Goes Bankrupt
|
||||
section: The Co-op
|
||||
order: 31
|
||||
summary: Federal bankruptcy law would override the lock, so a failed co-op could be sold whole to anyone, and this page lays out the amendment Congress would need to pass, modeled on how it already treats charities, along with what it costs creditors and how workers get a real chance to buy.
|
||||
---
|
||||
|
||||
# When a Locked Co-op Goes Bankrupt
|
||||
|
||||
[The lock](the-lock.md) says a co-op funded by the bank can never be sold to conventional owners. That holds while the co-op is healthy. It can break at the worst moment, when the co-op fails and a bankruptcy court takes charge of what's left.
|
||||
|
||||
This page explains why, what Congress already does for a similar kind of organization, and what the amendment would say. A few terms first:
|
||||
|
||||
- **The lock-holder** is the independent organization that holds the co-op's *lock share*, a single share that pays nothing but can veto a sale ([The Lock](the-lock.md#how-the-lock-is-made-to-hold)).
|
||||
- **A going concern** is a business sold whole and still running, with its customers, contracts and trained staff. It's usually worth much more than the same equipment sold off piece by piece.
|
||||
- **Creditors** are everyone the co-op owes: lenders, suppliers, workers, tax agencies, and anyone it has injured.
|
||||
|
||||
## Why bankruptcy breaks the lock
|
||||
|
||||
When a business files for bankruptcy, everything it owns goes into a pool the court controls, called the *estate*, and is used to pay creditors as much as possible. There are three main routes:
|
||||
|
||||
- **Liquidation (Chapter 7).** A trustee sells what the business owns and pays creditors in order of priority. The trustee can keep the business running for a limited time if that helps sell it ([11 U.S.C. § 721](https://www.law.cornell.edu/uscode/text/11/721)).
|
||||
- **Reorganization (Chapter 11).** The business keeps running under a plan the court approves. Debts are cut or stretched out, and ownership often passes to creditors. Under the *absolute priority rule*, if a group of creditors votes against the plan, the old owners can't keep anything until those creditors are paid in full ([11 U.S.C. § 1129(b)(2)(B)](https://www.law.cornell.edu/uscode/text/11/1129)).
|
||||
- **Small-business reorganization (Subchapter V).** A faster version of Chapter 11 for businesses owing no more than $3,424,000 ([11 U.S.C. §§ 1182(1) and 101(51D)](https://www.law.cornell.edu/uscode/text/11/101), as adjusted in 2025). Here the absolute priority rule doesn't apply. If creditors don't agree to the plan, the owners can still keep the business by paying creditors all of its projected *disposable income*, what's left after the costs of running it, for three to five years ([11 U.S.C. § 1191](https://www.law.cornell.edu/uscode/text/11/1191)). Most locked co-ops would fit under that limit.
|
||||
|
||||
The lock can fail in four places:
|
||||
|
||||
1. **The estate ignores limits on selling.** The Bankruptcy Code says a business's property goes into the estate "notwithstanding any provision" in an agreement or in state law "that restricts or conditions transfer" of it ([11 U.S.C. § 541(c)(1)](https://www.law.cornell.edu/uscode/text/11/541)). The lock is exactly that kind of provision.
|
||||
2. **The court can sell everything, "free and clear."** A trustee can sell the estate's property outside the ordinary course of business, free and clear of other people's interests in it ([11 U.S.C. § 363(b) and (f)](https://www.law.cornell.edu/uscode/text/11/363)). A court could treat the lock-holder's rights as one of those interests and pay them off in cash. In practice, a failed business usually goes to the highest bidder.
|
||||
3. **A reorganization plan can hand the firm over.** A plan can sell the business, merge it or rewrite its charter "notwithstanding any otherwise applicable nonbankruptcy law" ([11 U.S.C. § 1123(a)(5)](https://www.law.cornell.edu/uscode/text/11/1123)). A standard plan cancels the old ownership and issues new shares to creditors. The Code also requires the reorganized company's charter to ban shares that carry no vote ([§ 1123(a)(6)](https://www.law.cornell.edu/uscode/text/11/1123)), while this model relies on outside money that carries no vote ([Capital, After](capital.md)).
|
||||
4. **A lender can sell without going to court at all.** A lender with a claim on all of a business's assets can sell them, or take them, after a default under state law ([UCC § 9-610](https://www.law.cornell.edu/ucc/9/9-610)). No bankruptcy is needed.
|
||||
|
||||
So without a change in the law, a failed co-op's whole business could be bought by a conventional company. The lock would protect only healthy co-ops, which need it least.
|
||||
|
||||
## What Congress already does for charities
|
||||
|
||||
Congress already keeps some organizations' property dedicated to its purpose in bankruptcy. The way it does it is simple: the Bankruptcy Code steps back and lets the ordinary law that governs those organizations decide.
|
||||
|
||||
- **A charity's property can go to a non-charity "only under the same conditions as would apply if the debtor had not filed"** for bankruptcy ([11 U.S.C. § 541(f)](https://www.law.cornell.edu/uscode/text/11/541)). This covers tax-exempt charities.
|
||||
- **Court-approved sales by a nonprofit corporation must follow the law** that governs its transfers outside bankruptcy ([§ 363(d)(1)](https://www.law.cornell.edu/uscode/text/11/363)), and so must transfers under its reorganization plan ([§ 1129(a)(16)](https://www.law.cornell.edu/uscode/text/11/1129)). These three rules were added in 2005.
|
||||
- **Since 1978, creditors can't force a nonprofit into bankruptcy** ([§ 303(a)](https://www.law.cornell.edu/uscode/text/11/303)), and a court can't turn its reorganization into a liquidation unless it asks ([§ 1112(c)](https://www.law.cornell.edu/uscode/text/11/1112)).
|
||||
- **Also since 1978, valid limits on transferring a person's interest in a trust hold in bankruptcy** ([§ 541(c)(2)](https://www.law.cornell.edu/uscode/text/11/541)). That rule keeps some assets away from creditors, so it's a precedent for protecting a purpose at creditors' expense, not a neutral one.
|
||||
|
||||
A locked co-op isn't a charity. It's a business that makes and shares profits. The comparison is narrower: in both cases, assets are meant to stay dedicated to a purpose, not sold for private gain. So the amendment copies the *technique*: the Code defers to the law that governs locked co-ops, and that law, the bank's statute, says what can be sold to whom.
|
||||
|
||||
**Is it constitutional?** Bankruptcy laws must be "uniform." In 1982 the Supreme Court struck down a law written for the workers of one bankrupt railroad, calling it "nothing more than a private bill." But it said a bankruptcy law must "apply uniformly to a defined class of debtors," and that its ruling didn't impair Congress's ability "to define classes of debtors and to structure relief accordingly" ([*Railway Labor Executives' Ass'n v. Gibbons*, 455 U.S. 457](https://www.law.cornell.edu/supremecourt/text/455/457)). A rule for every locked co-op, defined by what it is and not by name, is that kind of class. The parts that reach sales outside bankruptcy (point 9 below) rest on Congress's power over interstate commerce instead.
|
||||
|
||||
## What the amendment does
|
||||
|
||||
### Who it covers
|
||||
|
||||
**1. Every locked co-op on the federal register.** The bank's statute sets up a public register of locked co-ops. A co-op is covered while its lock is on the register. The register decides the question. A notice in the state filing system lenders already search (a UCC financing statement) points to it, but if that notice lapses, the co-op stays locked.
|
||||
|
||||
**2. Old debts are protected.** A lender already secured on the co-op's assets when the lock is registered has to consent in writing before registration. For a business that converts to a co-op, that's part of the buyout deal ([When an Owner Wants to Sell to the Workers](buyouts.md)).
|
||||
|
||||
**3. Creditors who never chose to lend are made whole for the lock's cost.** Some creditors never had a chance to price the lock in: people the co-op injured, tax agencies, and creditors whose unsecured claims came before registration. If the lock makes them recover less than they would have from an open sale, the federation fund pays them the difference. For co-ops outside a federation, the bank pays it. Locked co-ops must also carry standard liability insurance, so injured people are mostly paid by insurers anyway.
|
||||
|
||||
### What can be sold, and to whom
|
||||
|
||||
**4. The Code defers to the lock.** In every chapter, and in plan sales too, the lock's limits hold. The estate takes the co-op's property subject to them. A sale "free and clear" can't strip them. The lock agreements can't be rejected as ordinary contracts. And no plan can override them. The detailed sale rules live in the bank's statute, as the rules for charities live in state law.
|
||||
|
||||
**5. Workers can keep the business running while it's sold.** Argentina changed its bankruptcy law in 2011 to do this. The trustee must report within 20 days on whether the business can keep operating. Workers making up two-thirds of the staff can ask to run it as a co-op while the case goes on, with a plan due within 20 days. The judge decides, and sets a time limit that can be extended once ([Law 26.684, amending articles 190 and 191 of the bankruptcy law](https://www.argentina.gob.ar/normativa/nacional/ley-26684-183856/texto)). The amendment adapts this: workers can ask to run the business as a new co-op, under court supervision, while it's up for sale.
|
||||
|
||||
**6. The whole business goes only to a qualified co-op.**
|
||||
|
||||
- **A qualified bidder** is a locked co-op whose members do at least three-quarters of its work hours, and whose outside money meets the limits in [Capital, After](capital.md): no vote, at most 15 years. A co-op formed by the failed co-op's own workers qualifies from the start. So does the federation. A shell set up by a conventional company doesn't.
|
||||
- **The minimum price is what the assets would bring sold off piece by piece,** set by an appraiser the court appoints. A higher floor, like the full value of a running business, could stop any co-op from buying and force a breakup, which is worse for workers and creditors both. The co-op bidders compete in open bidding, so the price is usually higher than the floor.
|
||||
- **No buying back your own failure cheaply.** People who managed the co-op in the two years before it failed can't get bank financing to buy it ([The Lock](the-lock.md#when-a-co-op-goes-bankrupt)). In a reorganization where the members are running the case and also bidding, an independent officer runs the sale.
|
||||
- **The time limit is 90 days from when the sale is announced, or from when the workers' co-op files its plan, whichever is later.** The court can extend it once if a qualified bidder is lining up financing.
|
||||
|
||||
**7. Workers can pay with what they're owed.** A secured lender can already bid using its claim instead of cash ([§ 363(k)](https://www.law.cornell.edu/uscode/text/11/363)). Argentina lets workers do the same: a workers' co-op can count the wage and severance claims its workers voluntarily hand over toward the price, at their full amount, and the handover happens at a hearing before the judge (Law 26.684, article 27, adding article 203 bis). The amendment does this too, within limits:
|
||||
|
||||
- **Only claims workers choose to hand over,** in writing. Workers who don't join the new co-op keep their claims.
|
||||
- **At full value, up to the amounts bankruptcy law already puts first:** unpaid wages and benefit contributions earned in the last 180 days, up to $17,150 per worker for wages ([11 U.S.C. § 507(a)(4) and (a)(5)](https://www.law.cornell.edu/uscode/text/11/507)). That's a deliberate transfer from other creditors to workers, capped per person, as Argentina chose.
|
||||
- **The insurance pool's claims count too.** The [savings insurance pool](savings-guarantee.md) pays workers' final wages quickly. Normally, whoever pays a wage claim loses its priority ([§ 507(d)](https://www.law.cornell.edu/uscode/text/11/507)). Without a fix, that would leave workers nothing to bid with. So the pool keeps the priority, and it can hand the claims it paid to the workers' co-op for its bid, to be repaid by the new co-op over time.
|
||||
- **No last-minute claims.** Pay deferred by a vote in the 180 days before filing ranks as an ordinary claim, not a priority one, and can't be used to bid.
|
||||
|
||||
**8. If no qualified co-op buys it, the pieces go to anyone, but not the whole.** No buyer, together with anyone financially tied to it, may end up with substantially all of the business. The brand, the core technology or the main customer contracts count as substantially all, whatever their size, as the lock already says. A buyer of the pieces who hires more than a third of the members within two years owes the difference between what it paid and what the running business was worth, as set by the court's appraiser. The sale order says so up front. That money goes to the co-op's creditors, not the federation fund.
|
||||
|
||||
**9. Lenders can't sell around it outside court either.** The same rules apply to any transfer of substantially all of a locked co-op's assets because it can't pay its debts: a lender's sale or taking of its collateral, a state receivership, an assignment for the benefit of creditors, or a tax sale. The [model state law](durability.md#federal-test-state-copies) adds that a sale following these rules counts as "commercially reasonable" under the UCC, so lenders aren't penalized for following them.
|
||||
|
||||
### When the co-op reorganizes
|
||||
|
||||
**10. A reorganized co-op stays a co-op, and creditors are treated as they would be anyway.**
|
||||
|
||||
- **Only working members hold votes.** A plan can give creditors new debt, or voteless claims on the terms in [Capital, After](capital.md), but never ownership, and no loan terms giving a lender approval over the co-op's management or strategy. The rule requiring a ban on voteless shares doesn't apply.
|
||||
- **Small co-ops follow the ordinary small-business rule.** In Subchapter V, owners of any small business can keep it by paying creditors all of its disposable income for three to five years. A co-op gets the same deal, no better. To stop members shrinking that income by paying themselves more, disposable income is worked out with members' pay at the going rate ([Investing for the Long Run](the-long-run.md#the-rules)), and raises in the two years before filing are reversed.
|
||||
- **Larger co-ops pay creditors the business's full value.** If a group of creditors votes against the plan, they must get at least what the business would sell for on the open market, as if the lock didn't exist, in cash, new debt or voteless claims. That's what the absolute priority rule protects in any company. Members can keep a co-op they can't sell, but not at their creditors' expense.
|
||||
- **Members' own accounts go first.** What members have in their capital accounts is wiped out before creditors lose anything. Members were taxed each year on those allocations, so this is a real loss, and it's the cost of being an owner ([Member Capital Accounts](capital-accounts.md#protecting-the-co-op-and-the-people-who-stay)).
|
||||
|
||||
### Everything else
|
||||
|
||||
**11. Former members are paid before current members.** A former member's payout is a fixed debt the co-op owes ([Member Capital Accounts](capital-accounts.md#how-its-paid-out)). It ranks behind every other unsecured creditor, but ahead of current members' accounts. It isn't pushed down to the bottom as if it were a shareholder's claim ([§ 510(b)](https://www.law.cornell.edu/uscode/text/11/510)). Anyone who left within a year before the failure has their balance recalculated first, as the insurance pool already does ([Protecting Members' Savings](savings-guarantee.md#closing-the-loopholes)).
|
||||
|
||||
**12. The lock-holder can go to court, carefully.** The lock-holder can take part in the case. If the trustee declines to challenge a deal the co-op made with insiders before it failed, the lock-holder can ask the court's permission to challenge it, and pays the other side's costs if the court finds the challenge frivolous.
|
||||
|
||||
**What it doesn't do.** Unlike charities, locked co-ops *can* be forced into bankruptcy by their creditors, and a failed reorganization *can* be turned into a liquidation. A business that can't pay its debts shouldn't be able to shelter behind the lock. The amendment only decides who can buy it, not whether it can fail.
|
||||
|
||||
## What it costs, and who pays
|
||||
|
||||
- **Lenders get back less when a co-op fails.** The whole business can only go to a co-op, so it may sell for less than on the open market. Lenders who lend after the lock is registered know that, and they'll charge for it, in higher rates or more collateral.
|
||||
- **The bank bears it first.** The bank is usually the biggest lender, so the pilot publishes its recovery rate on failed loans ([The Pilot, in Numbers](pilot-spec.md#what-gets-measured)). That shows what the lock costs.
|
||||
- **The federation fund bears the rest.** Creditors who never chose to lend are paid the lock's cost from the fund. That's a real call on money the co-ops put in.
|
||||
- **Other lenders may lend less, or not at all.** Some banks will refuse locked co-ops, or lend only against equipment they can take back. Lenders willing to fund a failing co-op through a rescue may be harder to find, because they can't sell the business to anyone. That's a real cost, and it's part of why the model needs its own bank.
|
||||
- **Workers' claims come ahead of other creditors when workers bid.** That's a choice to favor keeping a business running in its workers' hands. It's capped per worker, and it's paid for by other creditors.
|
||||
- **Some viable businesses will still be broken up.** When no co-op can buy a failed co-op whole, a conventional buyer who would have kept it running and kept the jobs can't. While the co-op sector is small, this will happen. Whether to allow a conventional buyer as a last resort, with any surplus going to the federation fund, is an open question ([Open Problems](open-problems.md)).
|
||||
|
||||
## Draft text
|
||||
|
||||
For readers who want to see what this could look like in the statute, here is a first draft of the core Bankruptcy Code changes. The detailed sale rules (points 5 to 9) go in the bank's own statute. This is a starting point for lawyers, not finished legislation.
|
||||
|
||||
> **Sec. 101 (new definition).** The term "locked cooperative" means an entity entered, as of the date of the filing of the petition, on the register of locked cooperatives kept under [the bank's authorizing act].
|
||||
>
|
||||
> **Sec. 541(g).** Notwithstanding subsection (c)(1) and any other provision of this title, property held by a debtor that is a locked cooperative may be transferred only as permitted by the nonbankruptcy law governing locked cooperatives, as if the debtor had not filed a case under this title.
|
||||
>
|
||||
> **Sec. 363(q).** In a case in which the debtor is a locked cooperative, (1) the trustee may use, sell, or lease property under subsection (b) or (c), including under subsection (f), only in accordance with the nonbankruptcy law governing locked cooperatives; (2) the rights of a lock-holder under that law are not an interest that may be sold free and clear under subsection (f); and (3) section 365 does not permit rejection of any agreement required by that law.
|
||||
>
|
||||
> **Sec. 507(d) (amended).** Insert at the end: "except that an insurance pool established under [the bank's authorizing act] that pays a claim of a kind specified in subsection (a)(4) or (a)(5) is subrogated to the holder's right to priority."
|
||||
>
|
||||
> **Sec. 1123(e).** Notwithstanding subsection (a), a plan for a debtor that is a locked cooperative (1) may not provide for any transfer of property, merger, consolidation, or amendment of the debtor's charter except as permitted by the nonbankruptcy law governing locked cooperatives; and (2) need not comply with subsection (a)(6) as to any nonvoting interest permitted by that law.
|
||||
>
|
||||
> **Sec. 1129(a)(17).** If the debtor is a locked cooperative, (A) all transfers of property under the plan are made in accordance with the nonbankruptcy law governing locked cooperatives; and (B) no equity security carrying voting power is issued to any entity other than members who work for the reorganized debtor.
|
||||
>
|
||||
> **Sec. 1129(b)(2)(B)(iii).** In a case in which the debtor is a locked cooperative, membership in the reorganized debtor held by persons who work for it is not property received or retained on account of a junior interest, if each holder of a claim of the class receives or retains under the plan property of a value, as of the effective date of the plan, not less than the amount such holder would receive if all of the debtor's property were sold as a going concern without regard to section 541(g).
|
||||
|
||||
## What this doesn't solve
|
||||
|
||||
- **It needs Congress, and a later Congress can undo it.** The lock's other layers hold if the bank's law is repealed. This one doesn't ([Making It Last](durability.md)).
|
||||
- **Courts will test it.** Treating membership as something other than property the owners keep is new in bankruptcy law. The draft ties it to paying dissenting creditors full open-market value, which answers the fairness point, but the first cases will still be fought.
|
||||
- **Reaching lenders' sales outside court is a federal reach into state law.** It rests on Congress's power over commerce, not bankruptcy, and it will be challenged. States that adopt the model law make it much safer.
|
||||
- **Lenders can route around it.** A co-op that leases its equipment, or sells its unpaid invoices to a lender, doesn't own those assets, so the lock doesn't cover them. Co-ops already report new leases to their members ([Investing for the Long Run](the-long-run.md#running-it-down)). How much of a co-op's business can sit outside the lock this way isn't limited.
|
||||
- **Workers still have to find the money fast.** Keeping the business running and counting workers' claims toward the price help. But a new co-op still needs a lender within a few months, and whether the bank or federation will stand ready isn't settled.
|
||||
- **The register has to exist and be trusted.** It's a new federal job, and every lender to a co-op will have to check it.
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
title: Help Before the First Loan
|
||||
section: Growing the Sector
|
||||
order: 67
|
||||
summary: The pilot's hardest job is getting enough good co-ops started, so it tests, by lottery, whether serious help before launch creates more of them that last, and asks founders for a small stake they can pay in work instead of cash.
|
||||
---
|
||||
|
||||
# Help Before the First Loan
|
||||
|
||||
The pilot aims to fund about 400 new co-ops in five years, about 80 a year. The whole American worker co-op sector grew by about 50 a year over the last decade, after counting the co-ops that closed ([The Pilot, in Numbers](pilot-spec.md#why-about-400-co-ops)). If good groups don't show up, the bank has nobody to lend to, and the whole test stalls.
|
||||
|
||||
The pilot already tests help *after* the first loan, by lottery ([the support lottery](pilot-spec.md#the-support-lottery)). This page is about help *before* it: whether serious support for groups who want to start a co-op makes more of them succeed in starting one that lasts, and whether founders should put in something of their own.
|
||||
|
||||
## What we know
|
||||
|
||||
**Mondragon's help started before a co-op existed.** When a group came with an idea, its bank picked one of them to be the manager, paid that person a salary while a team studied whether the business could work, and later lent the new co-op enough to cover its losses for its first two years ([Has This Been Tried?](precedents.md#mondragons-bank-caja-laboral)). Nobody knows how much of Mondragon's success came from that help and how much from the loans.
|
||||
|
||||
**Light training doesn't last.** At the time, the largest randomized trial of entrepreneurship training ever run was America's GATE project. It assigned 4,197 people who wanted to start businesses to be offered free training or not. People not offered it could still find training elsewhere, so the real difference was modest: about 9 more hours of classes and advice, at about $1,321 per person. Six months later, the trained group was about 5 percentage points more likely to own a business. By 18 months the gap in who currently owned a business was small and no longer clear, and by five years there was no clear difference ([Fairlie, Karlan and Zinman, 2012](https://www.nber.org/papers/w17804)).
|
||||
|
||||
**Heavier help is expensive, and nobody has measured what it does.** New York City has funded community groups that help people start worker co-ops since 2015. Its partners often work with a group for several years. In the program's seventh year, a pandemic year, it cost about $3.1 million, and 11 new co-ops formally launched or converted, meaning they got a tax ID, formation papers and permits. Another 21 were in progress ([NYC Small Business Services, 2021](https://www.nyc.gov/assets/sbs/downloads/pdf/about/reports/worker_coop_report_fy21.pdf)). That's about $280,000 for each co-op launched that year, though the program also helps existing co-ops, so it isn't a true cost per co-op. And there's no comparison group, so nobody can say how many of those co-ops would have started anyway.
|
||||
|
||||
So light help is cheap and fades, and heavier help might work but hasn't been tested. That's exactly the kind of question a lottery answers.
|
||||
|
||||
## The founding lottery
|
||||
|
||||
### Who can apply
|
||||
|
||||
- **A group of at least three people** who plan to work in the business, with an idea and a rough sense of what it would take. There's no loan application yet.
|
||||
- **Or a sponsor applying for a group.** Unions, worker centers, community groups and existing co-ops often bring groups together. Many co-ops start that way. They can apply on a group's behalf, and results are reported by who sponsored the group.
|
||||
- **A short screen, run by the reviewers, not the bank,** checks that the people are real and intend to work there, and that the business is legal. Any government ID is enough, including a taxpayer ID number for people without a Social Security number, or a consular or city ID.
|
||||
- **One entry per person for the whole pilot.** Groups that share a member, a family, an address, an idea or an organizer go into the draw as one unit. Groups that lose the draw can't re-enter under a new name.
|
||||
|
||||
### The draw
|
||||
|
||||
The reviewers, not the bank, draw half the groups by lottery, using a method filed in advance. The draw is balanced by industry, region, and the founders' gender, race and wealth. As with the support lottery, groups agree to the lottery when they apply, and an independent research ethics board approves the design. Groups that weren't drawn are offered the same help once the two-year comparison window ends.
|
||||
|
||||
### What the helped groups get, for up to a year
|
||||
|
||||
- **A stipend for one founding member** for up to six months, so that one of the group can work on getting the business ready full time. That's how Mondragon did it: it paid one of the founders, not an outsider.
|
||||
- **A feasibility study** by an outside specialist: is there a market, what will it cost, and can it pay a loan?
|
||||
- **Legal setup, bylaws and a business plan,** in the group's own language, with interpreters if needed.
|
||||
- **Specialists come from a vetted panel,** assigned by the reviewers, not picked by the group or by anyone who referred it. No specialist can be a member, a member's relative, or someone who referred the group. For three years they can't sell paid services to a group they helped, the same rule that applies to the support lottery's advisers. No one person or organization can advise more than a set number of helped groups.
|
||||
- **About $40,000 per group in all.**
|
||||
|
||||
### What the other groups get
|
||||
|
||||
The same public help any would-be business owner can already get, like the Small Business Administration's free counseling centers, and the bank's published standards. They can apply for a loan whenever they're ready.
|
||||
|
||||
### What it doesn't change
|
||||
|
||||
Help doesn't lower the bar. Every group applies for its loan through the same underwriting. The help doesn't cover early losses. A co-op that needs money to get through its first years borrows it from the bank at market rates, like any other loan.
|
||||
|
||||
### How big
|
||||
|
||||
About 250 groups in each half, so $10 million for the help. With groups that size, the lottery has an 80% chance of detecting a difference of about 12 percentage points in how many groups end up running a co-op, for example 25% against 37%. Smaller effects might be missed.
|
||||
|
||||
**The money comes from the loan budget.** Of the roughly $300 million set aside for loans, $10 million pays for this help instead, which leaves the pilot's cash cushion and running costs untouched. The pilot's capped help budget is $30 million in all: $20 million after the first loan and $10 million before it ([The Pilot, in Numbers](pilot-spec.md#how-much-money-and-why)). Every dollar counts against the bank in the [money test](pilot-spec.md#the-decision-rule), the same as help after the loan.
|
||||
|
||||
## What gets measured
|
||||
|
||||
- **Are they running a co-op?** The main result: the share of all groups in each half that have a co-op operating two years after the draw, with at least three members and a minimum level of sales, however it was financed. It's measured from government business records by the reviewers, not decided by the bank. So the result can't come from helped groups simply being coached to pass the bank's own underwriting.
|
||||
- **Are they still running at year four?** The share of all groups in each half with a co-op still open and paying fairly four years after the draw. That counts every group, so help that pushes weak groups into launching and failing shows up as a cost, not a benefit.
|
||||
- **What did it cost?** The public cost for each extra co-op the help created, and for each extra co-op still open at year four. At the smallest effect the lottery can detect, about 30 extra co-ops for $10 million, that's roughly $330,000 each. A ceiling is filed in advance. Above it, the help isn't recommended for any larger program, however many co-ops it creates.
|
||||
- **Who did it reach?** Founders' prior earnings, wealth, gender, race, immigration status and language, in each half.
|
||||
- **Where else did groups get help?** Groups report other help and funding they used, so any effect from help found elsewhere, or co-ops simply moving from other lenders to this one, can be estimated.
|
||||
|
||||
The co-ops from both halves count in the main test, and the main result is also reported without the helped half's extra co-ops. So help can't quietly change what the main test measures.
|
||||
|
||||
## Should founders put in money of their own?
|
||||
|
||||
Mondragon required it: its founders put in a fifth of the start-up money. Under Italy's Marcora program, which helps workers buy out closing firms, workers put in their own money too, often from their severance pay and unemployment benefits ([Has This Been Tried?](precedents.md)). A stake makes founders care what happens to the loan. But a cash buy-in shuts out workers with no savings, who are the people co-ops most often serve.
|
||||
|
||||
**The model's answer: a small stake, payable in work.** The founding members together put in at least 5% of the first loan. They can pay it in cash, in work, or both.
|
||||
|
||||
- **Work is valued at the local median wage for the job**, from the government's wage survey, not at whatever pay the founders plan to give themselves.
|
||||
- **Only hours logged at the time count,** from the date of application, and a specialist from the panel or the reviewers checks them. Hours paid by the stipend don't count.
|
||||
- **Only work before the co-op is formed can be unpaid.** After that, founders are its employees and are paid at least the minimum wage. Only the difference between the minimum wage and the median wage counts toward the stake ([Owners Are Still Workers](labor-law.md#founders-unpaid-work)).
|
||||
- **It's a real stake.** Unpaid work is recorded as back pay the co-op owes, ranked behind the bank ([The Founder Problem](founder-problem.md#the-tools)). Founders lose it if the co-op fails.
|
||||
|
||||
For a typical first loan of $500,000, that's $25,000 in all. Five founders could meet it with about $5,000 each, or with a few months of evenings and weekends. Three founders would need about $8,300 each, or more hours.
|
||||
|
||||
**What the stake does and doesn't do.** It shows commitment, and it makes sure founders have something to lose. It doesn't stop fraud. A group that plans to take the money and disappear gives up only work it never intended to be paid for. The defense against that is elsewhere: loans paid out in stages as the business hits milestones, and the rules in [Guarding the Pilot](pilot-safeguards.md).
|
||||
|
||||
## What this doesn't solve
|
||||
|
||||
- **It tests help for groups that come forward.** Many workers never think of starting a co-op at all. Sponsors like unions and community groups help reach them, and results are reported by sponsor. But the lottery can't test outreach to people nobody reaches.
|
||||
- **A year's help may not be enough.** New York's partners often work with groups for several years. If the effect needs longer, this design will understate it.
|
||||
- **Groups that lose the draw may find help elsewhere.** That makes the helped half look less different, so the result may understate the real effect, as happened in GATE.
|
||||
- **Four years of survival is a small sample.** Only the groups that launch can survive, perhaps 60 to 90 in each half. So the year-four result will be rough.
|
||||
- **The work stake still costs time.** Founders who can't spare unpaid hours, because they need every paid hour they can get, still face a barrier. The stipend in the helped half partly makes up for that. Outside it, nothing does.
|
||||
- **Help only for co-ops will be called unfair competition.** It's a research cost, time-limited to the pilot, published each year, and counted against the bank. That's the honest answer. It won't stop the attack.
|
||||
- **$10 million is $10 million.** If the help doesn't create more co-ops that last, the pilot will have spent it to learn that.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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§ionNum=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§ionNum=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§ionNum=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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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);
|
||||
@@ -11,29 +11,54 @@
|
||||
phone the felt did not start until 225px down — a quarter of the screen spent
|
||||
before the dealer saw the table. Only two of the six are per-hand controls.
|
||||
The rest have gone where they belong rather than into a "more" menu, which
|
||||
would have relocated the crowding rather than removed it: Reset Table to the
|
||||
Session panel beside Clear history and the save/load pair, Quick Split down
|
||||
would have relocated the crowding rather than removed it: Reset Table down to
|
||||
the sidebar with the save/load pair — in a panel called Save & reset, so the
|
||||
danger is on the label rather than at the end of a scroll — Quick Split down
|
||||
with Common rulings where the other "open something else" link already lives,
|
||||
and the way back to the site up top where a back link goes. *@
|
||||
@* The word is its own element so a phone can drop it and keep the arrow, and the
|
||||
aria-label is what a screen reader gets either way. It contains the visible
|
||||
text on the sizes that still show it, which is what WCAG 2.5.3 asks of a name
|
||||
that says more than the label does. *@
|
||||
<div id="holdemHeaderLeft">
|
||||
<a href="/casino" class="holdemBackLink">← Casino</a>
|
||||
<a href="/casino" class="holdemBackLink" aria-label="Back to Casino">←<span class="backLinkWord"> Casino</span></a>
|
||||
<h1>Texas Hold'em Dealer</h1>
|
||||
<span id="handCounterBadge" class="handCounterBadge">Hand #1</span>
|
||||
</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>
|
||||
<button id="nextHandBtn" class="holdemBtn" title="Move the button, small blind and big blind to the next occupied seat">Next Hand ⟳</button>
|
||||
<button id="resolvePotBtn" class="holdemBtn holdemBtnAccent">Resolve Pot</button>
|
||||
@* Not the accent any more. It is a shortcut to a sheet, available all night,
|
||||
and it is never the next step — the bar always has that, and at showdown
|
||||
the bar says so in as many words. See .actionAdvance in holdem.css. *@
|
||||
<button id="resolvePotBtn" class="holdemBtn">Resolve Pot</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@* The seat sheet was the only one of the tool's seven overlays with nothing behind
|
||||
it. On a phone it covers the felt from 250px down and the action bar with it, so
|
||||
it is modal in everything but appearance — and what stayed lit above it was the
|
||||
header, with Misdeal, Next Hand and Resolve Pot live under a thumb that was
|
||||
reaching for a name field. NN/g and Material both say the same thing about this:
|
||||
a modal sheet with an invisible scrim tells the user they can still touch what
|
||||
they can still see, and they can't.
|
||||
|
||||
Not an `*Overlay` id, deliberately — Modal watches those and would take the
|
||||
sidebar panel this becomes on a desktop for a dialog. It is driven entirely by
|
||||
body.seatSheetOpen, and it is `display: none` above 700px where the panel is
|
||||
just a panel. *@
|
||||
<div id="seatSheetScrim"></div>
|
||||
|
||||
<div id="holdemLayout">
|
||||
<section id="tableSection">
|
||||
<div id="boardStrip" hidden></div>
|
||||
<div id="pokerTableWrap">
|
||||
<div id="pokerTable">
|
||||
<div id="potBadge">
|
||||
<span id="potBadgeLabel">Pot</span>
|
||||
<span id="potBadgeAmount">$0</span>
|
||||
<div id="tableCentre">
|
||||
<div id="boardRow" hidden aria-label="Community cards"></div>
|
||||
<div id="potBadge">
|
||||
<span id="potBadgeLabel">Pot</span>
|
||||
<span id="potBadgeAmount">$0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="betsLayer" aria-hidden="true"></div>
|
||||
@@ -63,7 +88,7 @@
|
||||
loaded: open on a table nobody has set up yet, shut on one already
|
||||
half-way through a night. *@
|
||||
<details class="sidebarPanel" id="tableSetupPanel">
|
||||
<summary>Table Setup</summary>
|
||||
<summary><h2>Table Setup</h2> <span class="panelGlance" id="glanceTableSetup" hidden></span></summary>
|
||||
<div class="panelBody">
|
||||
<label class="fieldRow">
|
||||
<span>Seats at table</span>
|
||||
@@ -81,6 +106,22 @@
|
||||
<input type="checkbox" id="dedicatedDealerToggle" />
|
||||
</label>
|
||||
<p class="fieldHint">Turn this on if a person outside the game deals — no seat shows the dealer button, but blinds still rotate automatically each hand.</p>
|
||||
<label class="fieldRow">
|
||||
<span>Ask me for the cards as they come out</span>
|
||||
<input type="checkbox" id="trackBoardToggle" />
|
||||
</label>
|
||||
@* The label used to lead with "Track the board", which is the feature's name
|
||||
rather than what pressing it does to your evening. What it does is put a
|
||||
card sheet in front of you at four moments: each street, and the showdown. *@
|
||||
<p class="fieldHint">Every time you deal a street, a sheet asks which cards came off the deck, and puts them on the felt. At the showdown it asks for each hand that turns over — 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 — and the tool says so the first time it takes hold.</p>
|
||||
<div class="blindsRow">
|
||||
<label class="fieldRow">
|
||||
<span>Small blind</span>
|
||||
@@ -136,11 +177,23 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
@* These four were one panel called "Session", which is a container word rather
|
||||
than a name: it held the standings, a waiting list, the hand log and the
|
||||
save/load/reset buttons — a topic, a task, a log and three utilities, which
|
||||
is four kinds of thing under one label. It measured at 1206px and 103
|
||||
elements, and "The table itself" was 1052px down inside it, so reaching
|
||||
Reset table meant opening a panel called Session and scrolling past the
|
||||
money and the log to get there.
|
||||
|
||||
Each panel is named for the thing a dealer came looking for, and the
|
||||
subheads that used to do that job are gone, because the summary says it
|
||||
now. They are in the order of a night: set the table up, watch the money,
|
||||
fill the seats, look back at a hand — and, last and apart from the rest,
|
||||
the two buttons that end it. *@
|
||||
<details class="sidebarPanel">
|
||||
<summary>Session</summary>
|
||||
<summary><h2>Standings</h2> <span class="panelGlance" id="glanceStandings" hidden></span></summary>
|
||||
<div class="panelBody">
|
||||
<p id="storageWarning" class="storageWarning" hidden></p>
|
||||
<h3 class="panelSubhead">Standings</h3>
|
||||
<div class="standingsTableWrap">
|
||||
<div id="standingsTable">
|
||||
<div class="standingsRow standingsHeader">
|
||||
@@ -156,14 +209,6 @@
|
||||
</div>
|
||||
<p id="standingsEmptyHint" class="fieldHint">Everyone who has bought in shows up here, with what they'd be owed if the night ended now.</p>
|
||||
|
||||
<h3 class="panelSubhead">Waiting list</h3>
|
||||
<form id="waitingListForm" class="waitingAdd">
|
||||
<input type="text" id="waitingNameInput" maxlength="24" placeholder="Who's waiting?" autocomplete="off" />
|
||||
<button type="submit" id="waitingAddBtn" class="holdemBtn holdemBtnSmall">Add</button>
|
||||
</form>
|
||||
<ol id="waitingList" class="waitingList"></ol>
|
||||
<p id="waitingEmptyHint" class="fieldHint">Nobody waiting. Add the people standing behind the chairs, and the tool will offer them the next seat that opens.</p>
|
||||
|
||||
<div id="bankReconcile" class="bankReconcile" hidden>
|
||||
<div class="bankReconcileRow">
|
||||
<span>Cash taken</span>
|
||||
@@ -194,8 +239,24 @@
|
||||
<ul id="settleUpList" class="settleUpList"></ul>
|
||||
<p id="settleUpNote" class="fieldHint"></p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<h3 class="panelSubhead">Hands</h3>
|
||||
<details class="sidebarPanel">
|
||||
<summary><h2>Waiting list</h2> <span class="panelGlance" id="glanceWaiting" hidden></span></summary>
|
||||
<div class="panelBody">
|
||||
<form id="waitingListForm" class="waitingAdd">
|
||||
<input type="text" id="waitingNameInput" maxlength="24" placeholder="Who's waiting?" autocomplete="off" />
|
||||
<button type="submit" id="waitingAddBtn" class="holdemBtn holdemBtnSmall">Add</button>
|
||||
</form>
|
||||
<ol id="waitingList" class="waitingList"></ol>
|
||||
<p id="waitingEmptyHint" class="fieldHint">Nobody waiting. Add the people standing behind the chairs, and the tool will offer them the next seat that opens.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="sidebarPanel">
|
||||
<summary><h2>Hands</h2> <span class="panelGlance" id="glanceHands" hidden></span></summary>
|
||||
<div class="panelBody">
|
||||
<div class="handHistoryFilterRow">
|
||||
<input id="handHistoryFilter" type="search" class="handHistoryFilter"
|
||||
placeholder="Find a hand — name, amount or #" aria-label="Filter the hand log by player, amount or hand number" />
|
||||
@@ -209,27 +270,11 @@
|
||||
<button id="exportHistoryBtn" class="holdemBtn holdemBtnSmall">Export CSV</button>
|
||||
<button id="clearHistoryBtn" class="holdemBtn holdemBtnSmall holdemBtnDanger">Clear history</button>
|
||||
</div>
|
||||
|
||||
<h3 class="panelSubhead">The table itself</h3>
|
||||
<p class="fieldHint">
|
||||
The night lives in this browser and nowhere else. Save it to a file to carry it
|
||||
to another device, or to have something to come back to if this one dies.
|
||||
</p>
|
||||
<div class="handHistoryActions">
|
||||
<button id="saveTableBtn" class="holdemBtn holdemBtnSmall">Save table file</button>
|
||||
<button id="loadTableBtn" class="holdemBtn holdemBtnSmall">Load table file</button>
|
||||
<input id="loadTableInput" type="file" accept="application/json,.json" hidden />
|
||||
@* Down from the header, where it sat in the same weight as Resolve Pot
|
||||
— the most-pressed control in a session next to the one that throws
|
||||
the night away. Here it is beside Clear history, which is the other
|
||||
thing that destroys records, and behind a panel you have to open. *@
|
||||
<button id="resetTableBtn" class="holdemBtn holdemBtnSmall holdemBtnDanger">Reset table</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="sidebarPanel">
|
||||
<summary>Chip Legend</summary>
|
||||
<summary><h2>Chip Legend</h2> <span class="panelGlance" id="glanceChips" hidden></span></summary>
|
||||
<div class="panelBody">
|
||||
<div id="chipLegendList"></div>
|
||||
<button id="addChipColorBtn" class="holdemBtn holdemBtnSmall">+ Add chip color</button>
|
||||
@@ -244,7 +289,7 @@
|
||||
</details>
|
||||
|
||||
<details class="sidebarPanel" id="keyboardPanel">
|
||||
<summary>Keyboard</summary>
|
||||
<summary><h2>Keyboard</h2></summary>
|
||||
<div class="panelBody">
|
||||
<dl id="shortcutLegend" class="shortcutLegend"></dl>
|
||||
<p class="fieldHint">Nothing fires while you're typing in a field, or while a sheet is open over the table.</p>
|
||||
@@ -252,11 +297,45 @@
|
||||
</details>
|
||||
|
||||
<details class="sidebarPanel">
|
||||
<summary>Table Notes</summary>
|
||||
<summary><h2>Table Notes</h2> <span class="panelGlance" id="glanceNotes" hidden></span></summary>
|
||||
<div class="panelBody">
|
||||
<textarea id="tableNotes" rows="4" aria-label="Table notes" placeholder="Anything else worth tracking — side bets, IOUs, running totals..."></textarea>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
@* Last, and on its own, because everything in it is about the night as a whole
|
||||
rather than the hand in front of you — and because two of the three buttons
|
||||
are the ones you cannot take back.
|
||||
|
||||
Reset table came down from the header a while ago, where it sat in the same
|
||||
weight as Resolve Pot: the most-pressed control in a session beside the one
|
||||
that throws the night away. It used to be buried at the bottom of Session,
|
||||
1052px down, which hid it but also meant nobody could find it on purpose.
|
||||
Naming the panel for both jobs puts the danger on the label, where it is
|
||||
read before the panel is opened rather than met at the end of a scroll. *@
|
||||
<details class="sidebarPanel">
|
||||
<summary><h2>Save & reset</h2></summary>
|
||||
<div class="panelBody">
|
||||
<p class="fieldHint">
|
||||
The night lives in this browser and nowhere else. Save it to a file to carry it
|
||||
to another device, or to have something to come back to if this one dies.
|
||||
</p>
|
||||
@* That sentence has always been true, and always been missing its
|
||||
second half: a browser is allowed to throw site data away when the
|
||||
device runs short, and Safari does it on a timer. So the panel now
|
||||
says which of the two situations this device is actually in, and
|
||||
offers the one thing that changes it. See requestDurable() in
|
||||
state.js for why the ask is a button rather than a page-load call. *@
|
||||
<p class="fieldHint" id="storageDurabilityHint" hidden></p>
|
||||
<button id="keepStorageBtn" class="holdemBtn holdemBtnSmall" hidden>Ask this browser to keep it</button>
|
||||
<div class="handHistoryActions">
|
||||
<button id="saveTableBtn" class="holdemBtn holdemBtnSmall">Save table file</button>
|
||||
<button id="loadTableBtn" class="holdemBtn holdemBtnSmall">Load table file</button>
|
||||
<input id="loadTableInput" type="file" accept="application/json,.json" hidden />
|
||||
<button id="resetTableBtn" class="holdemBtn holdemBtnSmall holdemBtnDanger">Reset table</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
@@ -275,6 +354,20 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@* Four things stacked flat used to be five: name, in/out, two hole-card slots, stack,
|
||||
a five-box chip counter and a list of custom fields, every one of them at the same
|
||||
weight, all of it open at once. Measured on a 390px phone the sheet came to 594px,
|
||||
and the three controls a dealer touches on nearly every visit — the name, the two
|
||||
status boxes, the stack — were 132px of it. The chip counter alone was 174px, the
|
||||
tallest thing in the sheet, for a job most tables do once a night if at all; the two
|
||||
card slots were 62px of dashed grey on every table that never asked to be shown a
|
||||
card.
|
||||
|
||||
So the flat list is now three rows and three disclosures, and which of the three
|
||||
stand open is a question about *this seat*, answered in renderSeatDisclosures: a
|
||||
seat holding cards opens its cards, a seat with chips counted opens its count, a
|
||||
seat with notes on it opens its notes. Anything with nothing in it stays a one-line
|
||||
summary that says so. Nothing was removed and nothing moved to another screen. *@
|
||||
<template id="seatEditorTemplate">
|
||||
<div class="seatEditorForm">
|
||||
<label class="fieldRow">
|
||||
@@ -291,25 +384,45 @@
|
||||
<span>Fold</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="seatCards">
|
||||
<button type="button" class="cardSlot card1Slot" aria-label="Choose first card"></button>
|
||||
<button type="button" class="cardSlot card2Slot" aria-label="Choose second card"></button>
|
||||
</div>
|
||||
<label class="fieldRow stackRow">
|
||||
<span>Stack</span>
|
||||
<input type="number" class="stackInput" min="0" step="1" placeholder="table default" />
|
||||
</label>
|
||||
<div class="seatBuyIns" hidden></div>
|
||||
<div class="seatContribution">
|
||||
<div class="chipCounts"></div>
|
||||
<label class="fieldRow amountOverrideRow">
|
||||
<span>$ amount</span>
|
||||
<input type="number" class="amountOverrideInput" min="0" step="1" placeholder="auto" />
|
||||
</label>
|
||||
<div class="seatTotal">Total: <span class="seatTotalAmount">$0</span></div>
|
||||
</div>
|
||||
<div class="customFields"></div>
|
||||
<button class="holdemBtn holdemBtnSmall addFieldBtn">+ field</button>
|
||||
|
||||
<details class="subPanel seatSubPanel cardsPanel">
|
||||
<summary><span class="seatSubPanelName">Hole cards</span> <span class="seatGlance cardsGlance"></span></summary>
|
||||
<div class="subPanelBody">
|
||||
<div class="seatCards">
|
||||
<button type="button" class="cardSlot card1Slot" aria-label="Choose first card"></button>
|
||||
<button type="button" class="cardSlot card2Slot" aria-label="Choose second card"></button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
@* Named for the question it answers rather than for the boxes it contains. The
|
||||
counter, the typed override and the total are one calculation — what this
|
||||
seat has put in the pot — and the answer now sits on the summary, so a shut
|
||||
panel still says it. *@
|
||||
<details class="subPanel seatSubPanel contributionPanel">
|
||||
<summary><span class="seatSubPanelName">What they've put in</span> <span class="seatGlance contributionGlance"></span></summary>
|
||||
<div class="subPanelBody seatContribution">
|
||||
<div class="chipCounts"></div>
|
||||
<label class="fieldRow amountOverrideRow">
|
||||
<span>$ amount</span>
|
||||
<input type="number" class="amountOverrideInput" min="0" step="1" placeholder="auto" />
|
||||
</label>
|
||||
<div class="seatTotal">Total: <span class="seatTotalAmount">$0</span></div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="subPanel seatSubPanel notesPanel">
|
||||
<summary><span class="seatSubPanelName">Notes on this seat</span> <span class="seatGlance notesGlance"></span></summary>
|
||||
<div class="subPanelBody">
|
||||
<div class="customFields"></div>
|
||||
<button class="holdemBtn holdemBtnSmall addFieldBtn">+ field</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -321,9 +434,14 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@* The value used to be a `title` attribute, which is a tooltip — and a tooltip is
|
||||
nothing at all on the device this is used on. Five coloured dots with five number
|
||||
boxes and no way to tell which one was the $25 without opening the Chip Legend and
|
||||
matching colours by eye. It is text now. *@
|
||||
<template id="chipCountTemplate">
|
||||
<label class="chipCountRow">
|
||||
<span class="chipSwatch"></span>
|
||||
<span class="chipCountValue"></span>
|
||||
<input type="number" class="chipCountInput" min="0" step="1" value="0" />
|
||||
</label>
|
||||
</template>
|
||||
@@ -632,6 +750,11 @@
|
||||
<link rel="manifest" href="/casino/holdem.webmanifest" />
|
||||
@* iOS reads none of the manifest's icons and only this. *@
|
||||
<link rel="apple-touch-icon" href="/images/casino/holdem-apple-touch.png" />
|
||||
@* The standard name first, then Apple's older one. Chrome has been printing
|
||||
"<meta name="apple-mobile-web-app-capable"> is deprecated" into this page's
|
||||
console on every load; the replacement is the unprefixed name, and Safari still
|
||||
wants the prefixed one, so both are here. *@
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Hold'em" />
|
||||
@@ -654,8 +777,11 @@
|
||||
<script src="~/js/casino/holdem/feedback.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/potCalculator.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/state.js?v=@ViewData["cssVersion"]"></script>
|
||||
@* After feedback.js, because the first lock it takes announces itself with a Toast. *@
|
||||
<script src="~/js/casino/holdem/wakeLock.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/settleUp.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/render.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/handRank.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/cardPicker.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/seatMenu.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/addChips.js?v=@ViewData["cssVersion"]"></script>
|
||||
|
||||
@@ -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">← 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 →</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 →</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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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>();
|
||||
|
||||
@@ -53,6 +54,39 @@ if (!app.Environment.IsDevelopment())
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
// One spelling of the casino URLs, because the Hold'em tool's offline support depends
|
||||
// on it and nothing warns you when it breaks.
|
||||
//
|
||||
// Razor Pages routing is case-insensitive, so /Casino/TexasHoldem and
|
||||
// /casino/texasholdem both serve the dealer tool and look identical. A service worker's
|
||||
// scope is not: the spec matches it as a plain string prefix, so a worker scoped to
|
||||
// "/casino/" does not control a page at "/Casino/TexasHoldem". Measured, that meant the
|
||||
// capital-C spelling registered the worker, activated it, logged nothing — and then left
|
||||
// the cache completely empty, because the page was never controlled and the precache
|
||||
// handshake it waited on could not resolve. A dealer who bookmarked that URL had a tool
|
||||
// that looked loaded and had nothing stored for the night the signal went.
|
||||
//
|
||||
// So one spelling wins and the other is redirected to it, permanently, before anything
|
||||
// else in the pipeline gets a look.
|
||||
//
|
||||
// Deliberately scoped to /casino rather than applied site-wide. Every file under
|
||||
// wwwroot/casino and wwwroot/images/casino is already lowercase, so lowercasing a path
|
||||
// here can never miss a real file on a case-sensitive filesystem — a guarantee that does
|
||||
// not hold for the rest of the site, and one that /chessHub in particular would break.
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
var path = context.Request.Path.Value;
|
||||
if (path is not null
|
||||
&& path.StartsWith("/casino", StringComparison.OrdinalIgnoreCase)
|
||||
&& path.Any(char.IsUpper))
|
||||
{
|
||||
context.Response.Redirect(path.ToLowerInvariant() + context.Request.QueryString, permanent: true);
|
||||
return;
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
|
||||
// .webmanifest isn't in the default MIME map, and an unknown extension isn't served
|
||||
// with the wrong type — it isn't served at all. Without this the Hold'em tool's
|
||||
// manifest 404s, and a 404 manifest is exactly the silent failure that stops a browser
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -32,11 +32,23 @@ const ActionBar = {
|
||||
|
||||
const publish = () => {
|
||||
const visible = !host.hidden;
|
||||
const height = visible ? host.getBoundingClientRect().height : 0;
|
||||
const rail = this.isRail(host);
|
||||
// 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 = visible && !rail ? host.getBoundingClientRect().height : 0;
|
||||
document.documentElement.style.setProperty("--actionBarHeight", `${Math.round(height)}px`);
|
||||
this.publishFeltRoom(height);
|
||||
// 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.
|
||||
this.publishFeltRoom(rail ? this.roomBelowFelt() : height);
|
||||
};
|
||||
|
||||
// Kept so anything that moves the table without touching the bar can ask for
|
||||
// the felt to be measured again — the board strip appearing above it does
|
||||
// exactly that, and the felt's height is worked out from where the table starts.
|
||||
this.publishHeight = publish;
|
||||
if (typeof ResizeObserver === "function") {
|
||||
this.heightWatcher = new ResizeObserver(publish);
|
||||
this.heightWatcher.observe(host);
|
||||
@@ -46,6 +58,33 @@ const ActionBar = {
|
||||
publish();
|
||||
},
|
||||
|
||||
// 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
|
||||
// a media query — orientation, height and width together — and a second copy of
|
||||
// that query written in JavaScript is a copy that drifts: the first thing anyone
|
||||
// changes is the breakpoint, in one of the two places. So the CSS declares which
|
||||
// layout it picked in a custom property and this reads the answer back.
|
||||
isRail(host) {
|
||||
if (!host || typeof getComputedStyle !== "function") return false;
|
||||
return getComputedStyle(host).getPropertyValue("--actionBarLayout").trim() === "rail";
|
||||
},
|
||||
|
||||
// What sits between the bottom of the felt and the bottom of the window when the
|
||||
// bar is off to one side: the table hint and the row of links under it. Measured,
|
||||
// because both come and go — the hint is empty mid-hand and the links wrap on the
|
||||
// narrowest column.
|
||||
// Taken as the distance from the bottom of the felt to the bottom of the section it
|
||||
// sits in, which is a difference rather than a height — so it doesn't move when the
|
||||
// felt's own height changes, and this can't chase itself around the screen.
|
||||
roomBelowFelt() {
|
||||
const wrap = document.getElementById("pokerTableWrap");
|
||||
const section = wrap && wrap.parentElement;
|
||||
if (!section) return 0;
|
||||
const gap = section.getBoundingClientRect().bottom - wrap.getBoundingClientRect().bottom;
|
||||
return Math.max(0, Math.round(gap));
|
||||
},
|
||||
|
||||
// Half a seat, the gap left under the lowest one, and the smallest felt worth
|
||||
// drawing. A seat is about 73px tall at every width — the puck is text-sized, not
|
||||
// table-sized — so the overhang is a constant rather than something read back off
|
||||
@@ -86,13 +125,27 @@ const ActionBar = {
|
||||
// header in normal flow, so its own height never moves its top and this can't
|
||||
// chase itself around the screen.
|
||||
const topInDocument = wrap.getBoundingClientRect().top + window.scrollY;
|
||||
document.documentElement.style.setProperty(
|
||||
"--feltRoomPx",
|
||||
`${this.feltRoomFor(window.innerHeight, barHeight, topInDocument)}px`);
|
||||
const root = document.documentElement;
|
||||
const was = root.style.getPropertyValue("--feltRoomPx");
|
||||
const now = `${this.feltRoomFor(window.innerHeight, barHeight, topInDocument)}px`;
|
||||
root.style.setProperty("--feltRoomPx", now);
|
||||
// The felt has just changed size, so how much rim each seat gets has changed
|
||||
// with it. Told rather than left to be noticed on the next render, which on a
|
||||
// bar that grew mid-hand could be several actions away.
|
||||
if (typeof Render !== "undefined" && Render.updateSeatDensity) Render.updateSeatDensity();
|
||||
if (typeof Render === "undefined" || !Render.state) return;
|
||||
if (Render.updateSeatDensity) Render.updateSeatDensity();
|
||||
|
||||
// And when it really did change size, the seats are laid out again rather than
|
||||
// only re-measured.
|
||||
//
|
||||
// The seats follow a felt on their own — they are placed in percentages of it —
|
||||
// but a bet chip anchored inside a seat is placed in pixels off that seat's
|
||||
// centre, worked out against the felt as it was. Nothing re-ran that. Measured
|
||||
// at 640x360 with five players: the chips were positioned for a felt 83px tall,
|
||||
// painted on one of 60, and seat 3's $5 came to rest on seat 4's puck. The bar
|
||||
// growing mid-hand does this too — a side pot appearing is 55px off the felt —
|
||||
// so it is not only the board strip that could reach it.
|
||||
if (now !== was && Render.renderSeats) Render.renderSeats();
|
||||
},
|
||||
|
||||
// Anything to act on? A hand with nobody dealt in doesn't get a bar — and that
|
||||
@@ -224,10 +277,29 @@ const ActionBar = {
|
||||
return `${name} to act, ${cost}. Pot ${Money.format(Betting.potSoFar(state))}.`;
|
||||
},
|
||||
|
||||
// Pure, so the wording can be checked without a page to hang it on — the same reason
|
||||
// Render.panelGlances is. A table that has never dealt is on hand one, not hand
|
||||
// zero and not hand undefined, and that fallback is the whole of what there is to
|
||||
// get wrong here.
|
||||
handLabel(state) {
|
||||
return `#${(state && state.handNumber) || 1}`;
|
||||
},
|
||||
|
||||
renderStatus() {
|
||||
const row = document.createElement("div");
|
||||
row.className = "actionStatus";
|
||||
|
||||
// Which hand this is. It used to be a gold pill beside the page title, and it
|
||||
// was the wrong fact in the wrong place: a counter that changes once a hand,
|
||||
// read after the fact, holding a line of a 390px phone's header all night. It
|
||||
// belongs with the street, the pot and the player count — the other three
|
||||
// things that say what hand you are in — and here it costs nothing, because
|
||||
// this row was already drawn.
|
||||
const hand = document.createElement("span");
|
||||
hand.className = "actionHand";
|
||||
hand.textContent = this.handLabel(this.state);
|
||||
row.appendChild(hand);
|
||||
|
||||
const street = document.createElement("span");
|
||||
street.className = "actionStreet";
|
||||
street.textContent = Betting.streetLabel(this.state.street);
|
||||
@@ -255,8 +327,12 @@ const ActionBar = {
|
||||
if (Betting.isRunningOut(this.state) && !Betting.isHandOver(this.state)) {
|
||||
const runout = document.createElement("span");
|
||||
runout.className = "actionRunout";
|
||||
const stillIn = Betting.liveSeats(this.state).length;
|
||||
runout.textContent = `All-in — running out the board (${stillIn} players)`;
|
||||
// It used to end "(12 players)", which is the number the chip immediately to
|
||||
// its left already says, in the same row, at the same moment. Saying it
|
||||
// twice in one breath reads like two different figures being quoted, and on
|
||||
// a 360px phone it cost the sentence a second line — which the felt paid
|
||||
// for, on the one table shape that has twelve seats to fit.
|
||||
runout.textContent = "All-in — running out the board";
|
||||
row.appendChild(runout);
|
||||
}
|
||||
|
||||
@@ -553,10 +629,20 @@ const ActionBar = {
|
||||
this.state.seats.map((player, index) => [player.id, HoldemState.displayName(player, index)])
|
||||
);
|
||||
|
||||
// Four is what the bar can hold without taking the felt with it — see
|
||||
// buildPotBreakdown for the measurements, and uiAudit's fixedFurniture check
|
||||
// for the ceiling this is keeping the bar under.
|
||||
//
|
||||
// Two, once the run-out has started, for the reason spelled out over there: the
|
||||
// list is decision support, and during a run-out there is no decision left to
|
||||
// support. The felt gets the difference, on the one table shape that has the
|
||||
// most seats to fit into it.
|
||||
return Render.buildPotBreakdown(
|
||||
PotCalculator.layers(contributions),
|
||||
PotCalculator.uncalledReturns(contributions),
|
||||
id => nameById.get(id)
|
||||
id => nameById.get(id),
|
||||
null,
|
||||
{ maxLayers: 4, summarise: Betting.isRunningOut(this.state) }
|
||||
);
|
||||
},
|
||||
|
||||
@@ -611,6 +697,19 @@ const ActionBar = {
|
||||
// recorded so it can be taken back. A misclick on Fold is the one mistake in this
|
||||
// tool a dealer can't talk their way out of.
|
||||
commit(label, mutate) {
|
||||
// A toast is news about the last thing that happened, and this is the next
|
||||
// thing: whatever it says is now out of date. Two reasons it goes rather than
|
||||
// waiting out its eight seconds. It lies — "Next hand — button on seat 2.
|
||||
// [Undo]" reads as an offer to move the button back, but the snapshot behind
|
||||
// that Undo predates the hand you have since dealt, so taking it would throw
|
||||
// the hand away without saying so. And it is in the way: measured across the
|
||||
// sweep, a toast left up over a dealt table covered seats, the clock button
|
||||
// or the bar's own Undo on thirty of the small screens it was checked at,
|
||||
// because there is nowhere on a 360x640 phone for a 560px strip to go. The
|
||||
// bar's own Undo control takes over from here, and it names the step it
|
||||
// would actually take back.
|
||||
if (typeof Toast !== "undefined" && Toast.dismiss) Toast.dismiss();
|
||||
|
||||
this.hooks.beginAction(label);
|
||||
mutate();
|
||||
this.hooks.save(this.state);
|
||||
@@ -938,7 +1037,7 @@ const ActionBar = {
|
||||
if (!field) return;
|
||||
const panel = field.closest("details");
|
||||
if (panel) panel.open = true;
|
||||
field.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||
Render.bringIntoView(field, { block: "center" });
|
||||
field.focus();
|
||||
field.select();
|
||||
}));
|
||||
@@ -1069,6 +1168,11 @@ const ActionBar = {
|
||||
// happens, too — say the straddle, then deal.
|
||||
this.straddleButtons(preview).forEach(button => row.appendChild(button));
|
||||
row.appendChild(this.button("Post blinds & deal", "actionDeal", () => {
|
||||
// The first hand is the moment this stops being a setup screen and starts
|
||||
// being a night somebody could lose. It is also a tap, which is what every
|
||||
// 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();
|
||||
this.commit("dealing the hand", () => Betting.beginHand(this.state));
|
||||
}));
|
||||
wrap.appendChild(row);
|
||||
@@ -1098,6 +1202,20 @@ const ActionBar = {
|
||||
return line;
|
||||
},
|
||||
|
||||
// The cards come over before anybody is paid.
|
||||
//
|
||||
// Same shape as the street button above: when the dealer has asked to be asked, the
|
||||
// moment they press this is the moment they are holding the hands, so the sheet
|
||||
// opens here rather than leaving them to find the seat editor for each player with
|
||||
// the pot already on the table. Off by default, and unchanged when it's off.
|
||||
toShowdown() {
|
||||
if (this.state.trackBoard && typeof Render !== "undefined") {
|
||||
Render.openShowdownPicker(() => this.hooks.resolvePot());
|
||||
return;
|
||||
}
|
||||
this.hooks.resolvePot();
|
||||
},
|
||||
|
||||
renderStreetDone() {
|
||||
const wrap = document.createElement("div");
|
||||
|
||||
@@ -1113,10 +1231,14 @@ const ActionBar = {
|
||||
row.className = "actionButtons";
|
||||
row.appendChild(this.button(label, "actionAdvance", () => {
|
||||
if (isRiver) {
|
||||
this.hooks.resolvePot();
|
||||
this.toShowdown();
|
||||
return;
|
||||
}
|
||||
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.
|
||||
if (this.state.trackBoard && typeof Render !== "undefined") Render.openBoardPicker();
|
||||
}));
|
||||
wrap.appendChild(row);
|
||||
|
||||
@@ -1141,7 +1263,7 @@ const ActionBar = {
|
||||
row.appendChild(this.button(
|
||||
winner ? `Award pot to ${winner}` : "Resolve pot",
|
||||
"actionAdvance",
|
||||
() => this.hooks.resolvePot()
|
||||
() => this.toShowdown()
|
||||
));
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
|
||||
@@ -13,6 +13,15 @@
|
||||
const Betting = {
|
||||
STREETS: ["preflop", "flop", "turn", "river"],
|
||||
|
||||
// How many community cards are face up once a street has been dealt. The flop is
|
||||
// three at once and everything after it is one at a time, which is why this is a
|
||||
// table rather than an index.
|
||||
BOARD_CARDS: { preflop: 0, flop: 3, turn: 4, river: 5 },
|
||||
|
||||
boardCardsFor(street) {
|
||||
return this.BOARD_CARDS[street] ?? 0;
|
||||
},
|
||||
|
||||
streetLabel(street) {
|
||||
return { preflop: "Pre-flop", flop: "Flop", turn: "Turn", river: "River" }[street] || "Pre-flop";
|
||||
},
|
||||
@@ -557,6 +566,7 @@ const Betting = {
|
||||
state.actingSeatIndex = null;
|
||||
state.streetAggressorIndex = null;
|
||||
state.handAggressorIndex = null;
|
||||
HoldemState.clearBoard(state);
|
||||
// Nothing was posted, as far as the next rotation is concerned: the hand this
|
||||
// would have counted as is being dealt again from the same button.
|
||||
state.postedBlinds = null;
|
||||
@@ -593,6 +603,10 @@ const Betting = {
|
||||
state.street = "preflop";
|
||||
state.currentBet = 0;
|
||||
state.lastRaiseSize = 0;
|
||||
// A fresh deal starts on an empty felt. Dealing without moving the button —
|
||||
// after a misdeal, or a table set up and dealt twice — would otherwise leave
|
||||
// the last hand's board sitting there being ranked against the new cards.
|
||||
HoldemState.clearBoard(state);
|
||||
// Posting a blind isn't aggression — a hand limped and checked all the way
|
||||
// down has no aggressor at all, which is exactly the case the showdown rule
|
||||
// sends to the seat left of the button.
|
||||
|
||||
@@ -16,75 +16,147 @@ const CardPicker = {
|
||||
|
||||
state: null,
|
||||
hooks: null,
|
||||
// Which set of slots is being filled: a seat's two hole cards, or the five
|
||||
// community cards. The grid, the duplicate check and the tap-to-fill behaviour are
|
||||
// the same job either way — what changes is where the card is written and how many
|
||||
// slots there are to walk along.
|
||||
target: null,
|
||||
seatIndex: null,
|
||||
cardIndex: 0,
|
||||
|
||||
open(state, hooks, seatIndex, cardIndex) {
|
||||
this.state = state;
|
||||
this.hooks = hooks;
|
||||
this.target = "seat";
|
||||
this.seatIndex = seatIndex;
|
||||
this.cardIndex = cardIndex;
|
||||
this.render();
|
||||
document.getElementById("cardPickerOverlay").hidden = false;
|
||||
},
|
||||
|
||||
close() {
|
||||
document.getElementById("cardPickerOverlay").hidden = true;
|
||||
// The board. `limit` is how many of the five are actually out — three on the flop,
|
||||
// four on the turn — so the sheet never offers a slot for a card the dealer has not
|
||||
// turned over yet.
|
||||
openBoard(state, hooks, cardIndex, limit = 5) {
|
||||
this.state = state;
|
||||
this.hooks = hooks;
|
||||
this.target = "board";
|
||||
this.seatIndex = null;
|
||||
this.boardLimit = Math.max(1, Math.min(5, limit));
|
||||
this.cardIndex = Math.min(cardIndex, this.boardLimit - 1);
|
||||
this.render();
|
||||
document.getElementById("cardPickerOverlay").hidden = false;
|
||||
},
|
||||
|
||||
// Every card sitting in front of anyone else. The slot being edited is excluded,
|
||||
// so re-picking the card that's already there isn't treated as a duplicate.
|
||||
onBoard() {
|
||||
return this.target === "board";
|
||||
},
|
||||
|
||||
// The slots the sheet is filling, whichever set that is.
|
||||
slotValues() {
|
||||
return this.onBoard()
|
||||
? (this.state.board || []).slice(0, this.boardLimit)
|
||||
: this.state.seats[this.seatIndex].cards;
|
||||
},
|
||||
|
||||
write(index, card) {
|
||||
if (this.onBoard()) {
|
||||
if (!Array.isArray(this.state.board)) this.state.board = ["", "", "", "", ""];
|
||||
this.state.board[index] = card;
|
||||
return;
|
||||
}
|
||||
this.state.seats[this.seatIndex].cards[index] = card;
|
||||
},
|
||||
|
||||
// Why the sheet is closing, because one caller needs to tell the difference.
|
||||
//
|
||||
// "filled" is the sheet finishing its own job — the last empty slot it was offering
|
||||
// got a card. "dismissed" is the dealer leaving: Done, Escape, or a tap on the scrim.
|
||||
// A single seat's cards don't care which. A showdown does: it walks the players one
|
||||
// at a time, and the two answers are "next player" and "stop asking".
|
||||
//
|
||||
// The hooks are dropped before the callback runs, so a caller that opens the sheet
|
||||
// again from inside `onDone` installs its own and this close can't fire twice.
|
||||
close(reason = "dismissed") {
|
||||
document.getElementById("cardPickerOverlay").hidden = true;
|
||||
const done = this.hooks && this.hooks.onDone;
|
||||
this.hooks = null;
|
||||
if (done) done(reason);
|
||||
},
|
||||
|
||||
// Every card already dealt somewhere — in front of a player or face up in the
|
||||
// middle. The slot being edited is excluded, so re-picking the card that's already
|
||||
// there isn't treated as a duplicate.
|
||||
//
|
||||
// The board belongs in here as much as the hands do, and in both directions: the
|
||||
// seven of clubs cannot be on the flop and in somebody's hand, whichever of the two
|
||||
// the dealer is filling in at the time.
|
||||
cardsInUse() {
|
||||
const used = new Map();
|
||||
this.state.seats.forEach((player, seat) => {
|
||||
(player.cards || []).forEach((card, slot) => {
|
||||
if (!card) return;
|
||||
if (seat === this.seatIndex && slot === this.cardIndex) return;
|
||||
if (!this.onBoard() && seat === this.seatIndex && slot === this.cardIndex) return;
|
||||
used.set(card.toUpperCase(), HoldemState.displayName(player, seat));
|
||||
});
|
||||
});
|
||||
(this.state.board || []).forEach((card, slot) => {
|
||||
if (!card) return;
|
||||
if (this.onBoard() && slot === this.cardIndex) return;
|
||||
used.set(card.toUpperCase(), "the board");
|
||||
});
|
||||
return used;
|
||||
},
|
||||
|
||||
assign(card) {
|
||||
const player = this.state.seats[this.seatIndex];
|
||||
player.cards[this.cardIndex] = card;
|
||||
this.write(this.cardIndex, card);
|
||||
this.hooks.onPick(this.seatIndex, this.cardIndex, card);
|
||||
|
||||
// Straight on to the second card — a dealer picking hole cards is always
|
||||
// picking two, and making them re-open the picker doubles the taps.
|
||||
if (this.cardIndex === 0 && !player.cards[1]) {
|
||||
this.cardIndex = 1;
|
||||
// Straight on to the next empty slot — a dealer picking hole cards is always
|
||||
// picking two and a flop is always three, and making them re-open the picker
|
||||
// between each one multiplies the taps by the only thing the sheet is for.
|
||||
const values = this.slotValues();
|
||||
const next = values.findIndex((value, i) => i > this.cardIndex && !value);
|
||||
if (next !== -1) {
|
||||
this.cardIndex = next;
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
this.close();
|
||||
this.close("filled");
|
||||
},
|
||||
|
||||
clearSlot() {
|
||||
const player = this.state.seats[this.seatIndex];
|
||||
player.cards[this.cardIndex] = "";
|
||||
this.write(this.cardIndex, "");
|
||||
this.hooks.onPick(this.seatIndex, this.cardIndex, "");
|
||||
this.render();
|
||||
},
|
||||
|
||||
render() {
|
||||
const player = this.state.seats[this.seatIndex];
|
||||
const used = this.cardsInUse();
|
||||
// What this slot is called out loud: the flop's three cards, then the turn, then
|
||||
// the river — which is what a dealer is holding when they open this, rather than
|
||||
// "card 4".
|
||||
boardSlotLabel(slot) {
|
||||
return ["Flop 1", "Flop 2", "Flop 3", "Turn", "River"][slot] || `Card ${slot + 1}`;
|
||||
},
|
||||
|
||||
document.getElementById("cardPickerTitle").textContent =
|
||||
`${HoldemState.displayName(player, this.seatIndex)} — card ${this.cardIndex + 1}`;
|
||||
render() {
|
||||
const used = this.cardsInUse();
|
||||
const values = this.slotValues();
|
||||
|
||||
document.getElementById("cardPickerTitle").textContent = this.onBoard()
|
||||
? `Board — ${this.boardSlotLabel(this.cardIndex).toLowerCase()}`
|
||||
: `${HoldemState.displayName(this.state.seats[this.seatIndex], this.seatIndex)} — card ${this.cardIndex + 1}`;
|
||||
|
||||
// Which slot the next tap fills, switchable without closing the sheet.
|
||||
const slots = document.getElementById("cardPickerSlots");
|
||||
slots.innerHTML = "";
|
||||
[0, 1].forEach(slot => {
|
||||
values.forEach((_, slot) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "pickerSlot";
|
||||
btn.classList.toggle("active", slot === this.cardIndex);
|
||||
const value = player.cards[slot] || "";
|
||||
const value = values[slot] || "";
|
||||
btn.textContent = value || "—";
|
||||
if (this.onBoard()) btn.title = this.boardSlotLabel(slot);
|
||||
btn.classList.toggle("cardRed", /[HD]$/i.test(value));
|
||||
btn.addEventListener("click", () => {
|
||||
this.cardIndex = slot;
|
||||
@@ -105,8 +177,12 @@ const CardPicker = {
|
||||
btn.className = "pickerCard";
|
||||
btn.classList.toggle("cardRed", suit.red);
|
||||
btn.disabled = !!takenBy;
|
||||
if (takenBy) btn.title = `Already dealt to ${takenBy}`;
|
||||
if ((player.cards[this.cardIndex] || "").toUpperCase() === card) {
|
||||
if (takenBy) {
|
||||
btn.title = takenBy === "the board"
|
||||
? "Already face up on the board"
|
||||
: `Already dealt to ${takenBy}`;
|
||||
}
|
||||
if ((values[this.cardIndex] || "").toUpperCase() === card) {
|
||||
btn.classList.add("selected");
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,10 @@ const Toast = {
|
||||
|
||||
host.appendChild(toast);
|
||||
// Next frame, so the entrance transition actually runs.
|
||||
requestAnimationFrame(() => toast.classList.add("toastVisible"));
|
||||
requestAnimationFrame(() => {
|
||||
toast.classList.add("toastVisible");
|
||||
this.publishHeight();
|
||||
});
|
||||
|
||||
this.timer = setTimeout(
|
||||
() => this.dismiss(),
|
||||
@@ -61,6 +64,24 @@ const Toast = {
|
||||
}
|
||||
const host = document.getElementById("toastHost");
|
||||
if (host) host.innerHTML = "";
|
||||
this.publishHeight();
|
||||
},
|
||||
|
||||
// How much of the bottom of the window this is standing on.
|
||||
//
|
||||
// The toast is fixed, so it is not in the flow and nothing scrolling a control into
|
||||
// view knows it is there — measured on the desktop sweep, tabbing to Common rulings
|
||||
// while a toast was up left the focus ring entirely behind it. The stylesheet adds
|
||||
// this to the bar's own height to work out where a focused control has to stop, so
|
||||
// it is published rather than guessed: the toast is one line or two depending on the
|
||||
// message, and on a narrow screen the same message is taller again.
|
||||
publishHeight() {
|
||||
if (typeof document === "undefined" || !document.documentElement) return;
|
||||
const host = document.getElementById("toastHost");
|
||||
const height = host && host.firstElementChild
|
||||
? Math.round(host.getBoundingClientRect().height)
|
||||
: 0;
|
||||
document.documentElement.style.setProperty("--toastHeight", `${height}px`);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
// What beats what.
|
||||
//
|
||||
// The dealer has always been the one who decides who won: the pot wizard asks "who won
|
||||
// the hand?" and pays whoever gets ticked. That is the right default — most hands are
|
||||
// won without a showdown, and at a home game the table reads the cards out loud between
|
||||
// them. But when the board is on the felt and the hands are on their backs, the tool
|
||||
// knows the answer, and reading it off a screen beats three people counting kickers.
|
||||
//
|
||||
// Cards are the same two-character strings the rest of the tool has always stored:
|
||||
// a rank from `RANKS` and a suit from S, H, D, C. Case is not significant.
|
||||
//
|
||||
// Nothing in here touches the DOM, the table, or money. It compares cards, and the pot
|
||||
// wizard decides what to do about it.
|
||||
const HandRank = {
|
||||
RANKS: "23456789TJQKA",
|
||||
|
||||
// High to low. The number is the hand's category and nothing else — kickers and
|
||||
// the rest of the tie-breaking live in the `tiebreak` array beside it.
|
||||
STRAIGHT_FLUSH: 8,
|
||||
FOUR_OF_A_KIND: 7,
|
||||
FULL_HOUSE: 6,
|
||||
FLUSH: 5,
|
||||
STRAIGHT: 4,
|
||||
THREE_OF_A_KIND: 3,
|
||||
TWO_PAIR: 2,
|
||||
ONE_PAIR: 1,
|
||||
HIGH_CARD: 0,
|
||||
|
||||
// 2 through 14. Null for anything that isn't a card, which is how a half-filled
|
||||
// slot gets told apart from a bad one.
|
||||
rankValue(card) {
|
||||
if (typeof card !== "string" || card.length < 2) return null;
|
||||
const index = this.RANKS.indexOf(card[0].toUpperCase());
|
||||
return index < 0 ? null : index + 2;
|
||||
},
|
||||
|
||||
suitOf(card) {
|
||||
if (typeof card !== "string" || card.length < 2) return null;
|
||||
const suit = card[card.length - 1].toUpperCase();
|
||||
return "SHDC".includes(suit) ? suit : null;
|
||||
},
|
||||
|
||||
isCard(card) {
|
||||
return this.rankValue(card) !== null && this.suitOf(card) !== null;
|
||||
},
|
||||
|
||||
// Singular and plural, for reading a hand out loud.
|
||||
NAMES: {
|
||||
14: ["ace", "aces"], 13: ["king", "kings"], 12: ["queen", "queens"],
|
||||
11: ["jack", "jacks"], 10: ["ten", "tens"], 9: ["nine", "nines"],
|
||||
8: ["eight", "eights"], 7: ["seven", "sevens"], 6: ["six", "sixes"],
|
||||
5: ["five", "fives"], 4: ["four", "fours"], 3: ["three", "threes"],
|
||||
2: ["two", "twos"],
|
||||
},
|
||||
|
||||
name(rank, plural = false) {
|
||||
const pair = this.NAMES[rank];
|
||||
return pair ? pair[plural ? 1 : 0] : "?";
|
||||
},
|
||||
|
||||
// ---- The five-card hand -------------------------------------------------------
|
||||
|
||||
// Exactly five cards, ranked. Everything else is built on this.
|
||||
//
|
||||
// Five rather than seven because five is the hand a player actually holds, and it
|
||||
// is small enough to check by hand — which is the whole argument for doing it this
|
||||
// way. The seven-card version below tries all twenty-one five-card hands and keeps
|
||||
// the best, which is a hand of poker's own definition of "your best five" written
|
||||
// out literally. A dealer resolves one pot a hand with at most a dozen players in
|
||||
// it; two hundred and fifty comparisons of five cards is not a cost worth trading
|
||||
// a subtler algorithm's correctness for.
|
||||
evaluate5(cards) {
|
||||
if (!Array.isArray(cards) || cards.length !== 5) return null;
|
||||
if (!cards.every(card => this.isCard(card))) return null;
|
||||
|
||||
const ranks = cards.map(card => this.rankValue(card)).sort((a, b) => b - a);
|
||||
const suits = cards.map(card => this.suitOf(card));
|
||||
const flush = suits.every(suit => suit === suits[0]);
|
||||
|
||||
// How many of each rank, ordered by how many first and by rank second — so
|
||||
// the front of this list is always the rank that decides the hand.
|
||||
const counts = new Map();
|
||||
ranks.forEach(rank => counts.set(rank, (counts.get(rank) || 0) + 1));
|
||||
const groups = [...counts.entries()]
|
||||
.sort((a, b) => (b[1] - a[1]) || (b[0] - a[0]));
|
||||
const shape = groups.map(([, count]) => count);
|
||||
const byGroup = groups.map(([rank]) => rank);
|
||||
|
||||
const straightHigh = this.straightHigh(ranks);
|
||||
|
||||
const made = (category, tiebreak) => ({ category, tiebreak, cards: [...cards] });
|
||||
|
||||
if (flush && straightHigh) return made(this.STRAIGHT_FLUSH, [straightHigh]);
|
||||
if (shape[0] === 4) return made(this.FOUR_OF_A_KIND, byGroup);
|
||||
if (shape[0] === 3 && shape[1] === 2) return made(this.FULL_HOUSE, byGroup);
|
||||
if (flush) return made(this.FLUSH, ranks);
|
||||
if (straightHigh) return made(this.STRAIGHT, [straightHigh]);
|
||||
if (shape[0] === 3) return made(this.THREE_OF_A_KIND, byGroup);
|
||||
if (shape[0] === 2 && shape[1] === 2) return made(this.TWO_PAIR, byGroup);
|
||||
if (shape[0] === 2) return made(this.ONE_PAIR, byGroup);
|
||||
return made(this.HIGH_CARD, ranks);
|
||||
},
|
||||
|
||||
// The top card of the straight these five ranks make, or null.
|
||||
//
|
||||
// The wheel is the exception the rest of the ordering has to be told about: A-2-3-4-5
|
||||
// is a straight, and it is the *lowest* one, so its ace counts as five rather than
|
||||
// as fourteen. Getting that wrong is how A-2-3-4-5 comes to beat 10-J-Q-K-A.
|
||||
straightHigh(sortedDesc) {
|
||||
const distinct = [...new Set(sortedDesc)];
|
||||
if (distinct.length !== 5) return null;
|
||||
if (distinct[0] - distinct[4] === 4) return distinct[0];
|
||||
// Ace, five, four, three, two.
|
||||
if (distinct[0] === 14 && distinct[1] === 5 && distinct[4] === 2) return 5;
|
||||
return null;
|
||||
},
|
||||
|
||||
// ---- The best five out of however many ----------------------------------------
|
||||
|
||||
// The best five-card hand available from five, six or seven cards — hole cards and
|
||||
// board together, which is what Hold'em asks for. Returns null if there aren't five
|
||||
// real cards, which is the ordinary state of a hand still being dealt.
|
||||
best(cards) {
|
||||
const real = (cards || []).filter(card => this.isCard(card));
|
||||
if (real.length < 5) return null;
|
||||
// A card in two places at once is a mis-entry, not a hand — say so rather than
|
||||
// ranking a board that has the same king on it twice.
|
||||
if (new Set(real.map(card => card.toUpperCase())).size !== real.length) return null;
|
||||
|
||||
let best = null;
|
||||
this.eachFive(real, five => {
|
||||
const hand = this.evaluate5(five);
|
||||
if (!best || this.compare(hand, best) > 0) best = hand;
|
||||
});
|
||||
return best;
|
||||
},
|
||||
|
||||
// Every five-card subset, without building an array of them.
|
||||
eachFive(cards, visit) {
|
||||
const n = cards.length;
|
||||
for (let a = 0; a < n - 4; a++) {
|
||||
for (let b = a + 1; b < n - 3; b++) {
|
||||
for (let c = b + 1; c < n - 2; c++) {
|
||||
for (let d = c + 1; d < n - 1; d++) {
|
||||
for (let e = d + 1; e < n; e++) {
|
||||
visit([cards[a], cards[b], cards[c], cards[d], cards[e]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Positive if `a` wins, negative if `b` does, zero for a chop. A chop is a real
|
||||
// answer here rather than a rounding of one: two players playing the same board
|
||||
// split the pot, and the fifth card decides it far more often than a table expects.
|
||||
compare(a, b) {
|
||||
if (!a && !b) return 0;
|
||||
if (!a) return -1;
|
||||
if (!b) return 1;
|
||||
if (a.category !== b.category) return a.category - b.category;
|
||||
const length = Math.max(a.tiebreak.length, b.tiebreak.length);
|
||||
for (let i = 0; i < length; i++) {
|
||||
const diff = (a.tiebreak[i] || 0) - (b.tiebreak[i] || 0);
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
|
||||
// ---- Saying it out loud -------------------------------------------------------
|
||||
|
||||
// What a dealer says when they turn the hand over. Named to the level that settles
|
||||
// it and no further: "two pair, aces and eights" is the call, and the kicker only
|
||||
// gets a mention where the tool has had to use one to break a tie.
|
||||
describe(hand) {
|
||||
if (!hand) return "";
|
||||
const [first, second] = hand.tiebreak;
|
||||
switch (hand.category) {
|
||||
case this.STRAIGHT_FLUSH:
|
||||
return first === 14 ? "Royal flush" : `Straight flush, ${this.name(first)} high`;
|
||||
case this.FOUR_OF_A_KIND:
|
||||
return `Four of a kind, ${this.name(first, true)}`;
|
||||
case this.FULL_HOUSE:
|
||||
return `Full house, ${this.name(first, true)} full of ${this.name(second, true)}`;
|
||||
case this.FLUSH:
|
||||
return `Flush, ${this.name(first)} high`;
|
||||
case this.STRAIGHT:
|
||||
return `Straight, ${this.name(first)} high`;
|
||||
case this.THREE_OF_A_KIND:
|
||||
return `Three of a kind, ${this.name(first, true)}`;
|
||||
case this.TWO_PAIR:
|
||||
return `Two pair, ${this.name(first, true)} and ${this.name(second, true)}`;
|
||||
case this.ONE_PAIR:
|
||||
return `Pair of ${this.name(first, true)}`;
|
||||
default:
|
||||
return `${this.name(first).charAt(0).toUpperCase()}${this.name(first).slice(1)} high`;
|
||||
}
|
||||
},
|
||||
|
||||
// The same call with the article a table would put in front of it. Not derivable
|
||||
// from the first letter: it is "a full house" and "a flush", but "four of a kind"
|
||||
// and "two pair" take none at all, and "an ace high" is not something anybody says.
|
||||
// So it belongs to the category, like the name does.
|
||||
ARTICLED: {
|
||||
8: hand => (hand.tiebreak[0] === 14 ? "a royal flush" : "a"),
|
||||
7: () => null,
|
||||
6: () => "a",
|
||||
5: () => "a",
|
||||
4: () => "a",
|
||||
3: () => null,
|
||||
2: () => null,
|
||||
1: () => "a",
|
||||
0: () => null,
|
||||
},
|
||||
|
||||
// "wins with X" — the phrase, ready to drop into a sentence.
|
||||
phrase(hand) {
|
||||
if (!hand) return "the best hand";
|
||||
const described = this.describe(hand);
|
||||
const article = (this.ARTICLED[hand.category] || (() => null))(hand);
|
||||
if (article === null) return described.toLowerCase();
|
||||
if (article !== "a") return article;
|
||||
return `a ${described.toLowerCase()}`;
|
||||
},
|
||||
|
||||
// ---- The showdown -------------------------------------------------------------
|
||||
|
||||
// Rank a set of players against a board.
|
||||
//
|
||||
// `players` is [{ id, cards }, ...] — the two in front of each of them. Every player
|
||||
// is returned whether or not they could be ranked, in the order they were given, so
|
||||
// the caller can show a row for each and say which ones it could not read. `place`
|
||||
// is 1 for the winner and shared by a chop; a player whose cards aren't known has a
|
||||
// null hand and no place.
|
||||
//
|
||||
// `complete` is the question the pot wizard actually asks: can this be trusted to
|
||||
// pay the table. It is only true when the board is all five cards and every single
|
||||
// player handed in has two of their own — one unknown hand and the tool cannot say
|
||||
// who won, only who is winning so far, which is not the same thing and must not be
|
||||
// dressed up as it.
|
||||
showdown(board, players) {
|
||||
const boardCards = (board || []).filter(card => this.isCard(card));
|
||||
const boardComplete = boardCards.length === 5;
|
||||
|
||||
const ranked = (players || []).map(player => {
|
||||
const hole = (player.cards || []).filter(card => this.isCard(card));
|
||||
const hand = hole.length === 2 && boardComplete
|
||||
? this.best([...hole, ...boardCards])
|
||||
: null;
|
||||
return { id: player.id, cards: hole, hand, place: null };
|
||||
});
|
||||
|
||||
const readable = ranked.filter(row => row.hand);
|
||||
// A duplicated card anywhere means somebody has been mis-entered, and the
|
||||
// ranking would be of a deck that doesn't exist.
|
||||
const all = [...boardCards, ...ranked.flatMap(row => row.cards)].map(c => c.toUpperCase());
|
||||
const duplicated = new Set(all).size !== all.length;
|
||||
|
||||
const sorted = [...readable].sort((a, b) => this.compare(b.hand, a.hand));
|
||||
let place = 0;
|
||||
sorted.forEach((row, i) => {
|
||||
if (i === 0 || this.compare(row.hand, sorted[i - 1].hand) !== 0) place = i + 1;
|
||||
row.place = place;
|
||||
});
|
||||
|
||||
const winners = sorted.filter(row => row.place === 1);
|
||||
const complete = boardComplete && !duplicated &&
|
||||
ranked.length > 0 && readable.length === ranked.length;
|
||||
|
||||
return {
|
||||
players: ranked,
|
||||
winners: complete ? winners.map(row => row.id) : [],
|
||||
boardComplete,
|
||||
duplicated,
|
||||
complete,
|
||||
// Who the tool could not read, so the dialog can name them rather than
|
||||
// just declining to help.
|
||||
unknown: ranked.filter(row => !row.hand).map(row => row.id),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = HandRank;
|
||||
}
|
||||
@@ -5,6 +5,32 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
// `hidden` attribute they all already toggle.
|
||||
Modal.mount();
|
||||
|
||||
// ---- A scroll is not an edit ----
|
||||
//
|
||||
// A focused `<input type="number">` is a spinbutton, and a browser lets the mouse
|
||||
// wheel drive it. Every other way of driving one is deliberate: the arrow keys, the
|
||||
// little stepper arrows, typing. Scrolling is the one actuation nobody means — the
|
||||
// dealer is reaching for another panel and the cursor happens to be over a field
|
||||
// they touched a moment ago.
|
||||
//
|
||||
// Measured on the running page, at 1280x900 with the sidebar open. Focus the small
|
||||
// blind, scroll: $5 becomes $5.50. Focus a seat's stack, scroll: $500 becomes $501.
|
||||
// Both are written straight through to the saved table, because these fields save on
|
||||
// `input` like everything else here. Nothing is announced, nothing looks wrong, and
|
||||
// the tool has invented a chip.
|
||||
//
|
||||
// Blur rather than preventDefault. Cancelling the event would stop the value moving
|
||||
// and stop the page moving with it, which turns a silent bug into a sidebar that
|
||||
// mysteriously will not scroll. Dropping focus lets the scroll do what the dealer
|
||||
// asked for and leaves the number alone — and losing focus on a field you have just
|
||||
// scrolled away from is honest rather than surprising.
|
||||
//
|
||||
// Captured at the document, so it covers the fields the seat editor and the chip
|
||||
// legend build on the fly as well as the ones in the markup.
|
||||
document.addEventListener("wheel", event => {
|
||||
if (Money.wheelWouldEdit(event.target, document.activeElement)) event.target.blur();
|
||||
}, { passive: true });
|
||||
|
||||
// Undo works by swapping the whole table back to a snapshot. `state` is handed out
|
||||
// by reference to every module at mount time, so it has to be refilled in place —
|
||||
// reassigning the variable would leave the renderer and the betting bar pointing at
|
||||
@@ -86,6 +112,54 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
() => HoldemState.removeFromWaitingList(state, id));
|
||||
},
|
||||
onGiveButton: index => seatMenuHooks.makeDealer(index),
|
||||
|
||||
// Removing a chip colour, which is only sometimes a small thing.
|
||||
//
|
||||
// A colour nobody has counted is a row in a legend, and taking it out should
|
||||
// cost a click and nothing else. A colour seats are holding is money: the
|
||||
// legend is what prices their chips, so the row leaving takes their chips off
|
||||
// the table with it — and it cannot be put back, because a colour added again
|
||||
// is a new one and the counts belong to the old.
|
||||
//
|
||||
// So the friction is on the second case only. A confirmation that fires every
|
||||
// time is one nobody reads by the third night, and the one that matters here
|
||||
// would go the same way as the four before it that didn't.
|
||||
removeChipColor: async id => {
|
||||
const usage = HoldemState.chipColorUsage(state, id);
|
||||
if (!usage.chip) return;
|
||||
const name = Number(usage.chip.value) > 0
|
||||
? `${Money.format(usage.chip.value)} chip`
|
||||
: "chip colour";
|
||||
|
||||
if (usage.total !== 0) {
|
||||
const seatCount = `${usage.seats.length} ${usage.seats.length === 1 ? "seat" : "seats"}`;
|
||||
const held = usage.seats.map(seat => `${seat.name} ${seat.count}`).join(", ");
|
||||
const ok = await Dialog.confirm({
|
||||
// Both directions are named rather than only the loss. Going up is
|
||||
// the odd one — seats falling back to the table's standing ante once
|
||||
// their chips are gone — and a dealer who is told the total will move
|
||||
// and then finds it moved the other way has been told something false.
|
||||
title: usage.total > 0
|
||||
? `Remove the ${name} and ${Money.format(usage.total)} with it?`
|
||||
: `Remove the ${name} and put the table up ${Money.format(-usage.total)}?`,
|
||||
message:
|
||||
`${usage.chips} of them are counted onto ${seatCount} — ${held} — and the table ` +
|
||||
`${usage.total > 0 ? "loses" : "gains"} ${Money.format(Math.abs(usage.total))} ` +
|
||||
`when the colour goes. Undo will put it back; adding the colour again will not.`,
|
||||
confirmLabel: "Remove it",
|
||||
danger: usage.total > 0,
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
runUndoable(
|
||||
usage.total > 0
|
||||
? `${name} removed — ${Money.format(usage.total)} off the table.`
|
||||
: usage.total < 0
|
||||
? `${name} removed — table up ${Money.format(-usage.total)}.`
|
||||
: `${name} removed.`,
|
||||
() => HoldemState.removeChipColor(state, id));
|
||||
},
|
||||
};
|
||||
|
||||
const seatMenuHooks = {
|
||||
@@ -172,6 +246,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
},
|
||||
|
||||
makeDealer: index => {
|
||||
// Moving the button to where it already is is not a move. Saying so costs a
|
||||
// toast nobody asked for, and mid-hand it would take the blinds back off the
|
||||
// felt and re-post the same chips to say nothing happened.
|
||||
if (index === state.dealerSeatIndex) return;
|
||||
|
||||
// Before the deal there's nothing posted, so this is just moving a marker.
|
||||
// After it, the blinds have to follow the button or the badges and the
|
||||
// money disagree — but that's a real thing happening to real chips, and
|
||||
@@ -316,6 +395,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
save: hooks.save,
|
||||
rerender: hooks.rerender,
|
||||
resolvePot: openPotWizard,
|
||||
keepStorage: () => askToKeepStorage(),
|
||||
|
||||
// The first thing a dealer does on a table nobody is sitting at. Opens the
|
||||
// seat editor on the first free chair — the same panel tapping the seat gives
|
||||
@@ -324,7 +404,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const index = state.seats.findIndex(seat => !HoldemState.seatInPlay(seat));
|
||||
Render.toggleSeatEditor(index < 0 ? 0 : index);
|
||||
const panel = document.getElementById("seatEditorPanel");
|
||||
if (panel) panel.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
Render.bringIntoView(panel, { block: "nearest" });
|
||||
},
|
||||
|
||||
beginAction: label => {
|
||||
@@ -455,9 +535,24 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
Render.renderTableHint();
|
||||
});
|
||||
|
||||
document.getElementById("trackBoardToggle").addEventListener("change", e => {
|
||||
state.trackBoard = e.target.checked;
|
||||
// Turning it off leaves whatever was entered in place rather than wiping it —
|
||||
// a dealer who taps this by mistake mid-hand can tap it back and the flop is
|
||||
// still there. The next hand clears it the way it clears everything else.
|
||||
hooks.save(state);
|
||||
Render.renderAll();
|
||||
});
|
||||
|
||||
document.getElementById("tableNotes").addEventListener("input", e => {
|
||||
state.notes = e.target.value;
|
||||
hooks.save(state);
|
||||
// The one field on the page that must not go through renderAll: it would write
|
||||
// the textarea's own value back into it on every keystroke and put the caret at
|
||||
// the end. The panel's shut-state summary still has to keep up, though, and it
|
||||
// is the only thing on the page that reads the notes — so it gets called on its
|
||||
// own. It touches the six <span>s in the sidebar headers and nothing else.
|
||||
Render.renderPanelGlances();
|
||||
});
|
||||
|
||||
document.getElementById("addChipColorBtn").addEventListener("click", () => {
|
||||
@@ -607,6 +702,70 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
}, 0);
|
||||
});
|
||||
|
||||
// Focus that lands underneath something fixed is moved out from under it.
|
||||
//
|
||||
// scroll-padding-bottom in the stylesheet is half of WCAG 2.4.11 and only half. It
|
||||
// works when the browser has to scroll to reach the control at all — then the
|
||||
// padding tells it where to stop. A control the browser already considers visible is
|
||||
// one it will not scroll to at all, and a fixed overlay sitting on top of it does
|
||||
// not change that: measured on a 1280x900 desktop, tabbing to Common rulings while a
|
||||
// toast was up left it 100% covered, and scrollIntoView({block:"nearest"}) moved
|
||||
// nothing, because "nearest" was already satisfied.
|
||||
//
|
||||
// So the overlap is worked out here and the page is scrolled by exactly it. Named
|
||||
// overlays rather than every fixed element on the page, because this runs on every
|
||||
// focus change and walking the whole DOM to find three known things would be work
|
||||
// done ten times a second to reach the same answer. Anything inside one of them is
|
||||
// left alone — the bar's own buttons are meant to be on top of the page.
|
||||
const FIXED_OVERLAYS = "#actionBar, #toastHost, #seatEditorPanel";
|
||||
document.addEventListener("focusin", event => {
|
||||
const el = event.target;
|
||||
if (!el || typeof el.getBoundingClientRect !== "function") return;
|
||||
if (el.closest && el.closest(FIXED_OVERLAYS)) return;
|
||||
|
||||
const box = el.getBoundingClientRect();
|
||||
if (box.width === 0 || box.height === 0) return;
|
||||
|
||||
let lift = 0;
|
||||
document.querySelectorAll(FIXED_OVERLAYS).forEach(node => {
|
||||
const style = getComputedStyle(node);
|
||||
if (node.hidden || style.visibility === "hidden" || style.display === "none") return;
|
||||
if (style.position !== "fixed") return;
|
||||
const over = node.getBoundingClientRect();
|
||||
if (over.width === 0 || over.height === 0) return;
|
||||
// Only what is actually in the way: an overlay off to one side, like the
|
||||
// rail on a phone held sideways, is not covering a control in the middle.
|
||||
if (box.left >= over.right || box.right <= over.left) return;
|
||||
if (box.bottom <= over.top || box.top >= over.bottom) return;
|
||||
lift = Math.max(lift, box.bottom - over.top + 8);
|
||||
});
|
||||
|
||||
// Instant rather than smooth. This is the page getting out of the way of
|
||||
// something the dealer has already arrived at, and animating it would put the
|
||||
// control they are typing into in motion under them.
|
||||
if (lift > 0) window.scrollBy({ top: lift, behavior: "auto" });
|
||||
|
||||
// Scrolling is not always enough. The page only has as much room below its
|
||||
// content as the padding under the app gives it, and that is sized for the bar
|
||||
// — the toast stands on top of the bar, so a control at the very end of the page
|
||||
// can be lifted clear of one and not the other. Measured at 1280x900: 94px of
|
||||
// lift wanted, 26px of page to do it in.
|
||||
//
|
||||
// Sizing the padding for the toast as well would fix it by making the page grow
|
||||
// and shrink by 64px every time anything is announced, which is a worse thing to
|
||||
// do to a dealer than the fault. So the toast gives way instead: it is an
|
||||
// eight-second message about something that has already happened, and it is
|
||||
// standing on a control somebody has just navigated to. Only after the scroll
|
||||
// has been tried, so a toast that is not in the way is left alone.
|
||||
const toast = document.querySelector("#toastHost .toast");
|
||||
if (!toast) return;
|
||||
const after = el.getBoundingClientRect();
|
||||
const over = toast.getBoundingClientRect();
|
||||
const stillCovered = after.left < over.right && after.right > over.left &&
|
||||
after.bottom > over.top && after.top < over.bottom;
|
||||
if (stillCovered) Toast.dismiss();
|
||||
});
|
||||
|
||||
document.getElementById("resolvePotBtn").addEventListener("click", openPotWizard);
|
||||
|
||||
[
|
||||
@@ -642,6 +801,27 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
document.getElementById("closeSeatEditorBtn").addEventListener("click", () => Render.closeSeatEditor());
|
||||
|
||||
// The two ways out of the sheet that every other overlay in the tool already had.
|
||||
//
|
||||
// Modal.mount() covers the six whose ids end in "Overlay"; this one cannot join
|
||||
// them, because above 700px it is not a sheet at all — it is the sidebar panel the
|
||||
// seat's fields live in, and a dialog is the wrong thing for Modal to make of it.
|
||||
// So the two behaviours it was missing are wired here against the same state the
|
||||
// scrim is drawn from.
|
||||
document.getElementById("seatSheetScrim")
|
||||
.addEventListener("click", () => Render.closeSeatEditor());
|
||||
|
||||
// Not in capture, and behind Modal's own check: a card picker opened *from* the
|
||||
// sheet is on top of it, and Escape there means "stop picking a card", not "throw
|
||||
// the seat away". Modal stops that event before this listener ever sees it; the
|
||||
// guard is for the case where it hasn't, and costs nothing.
|
||||
document.addEventListener("keydown", event => {
|
||||
if (event.key !== "Escape") return;
|
||||
if (Modal.open.length > 0) return;
|
||||
if (!document.body.classList.contains("seatSheetOpen")) return;
|
||||
Render.closeSeatEditor();
|
||||
});
|
||||
|
||||
document.getElementById("addChipsConfirmBtn").addEventListener("click", () => AddChips.confirm());
|
||||
document.getElementById("addChipsCancelBtn").addEventListener("click", () => AddChips.close());
|
||||
document.getElementById("addChipsOverlay").addEventListener("click", e => {
|
||||
@@ -724,7 +904,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
// costs a screen reader the sentence.
|
||||
if (section.dataset.short) jump.setAttribute("aria-label", heading.textContent);
|
||||
jump.addEventListener("click", () => {
|
||||
section.scrollIntoView({ block: "start", behavior: "smooth" });
|
||||
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");
|
||||
@@ -874,6 +1054,74 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
{ duration: 12000 });
|
||||
}
|
||||
|
||||
// ---- Keeping the screen on ----
|
||||
//
|
||||
// The lock itself is taken and dropped in renderAll, because that is the one place
|
||||
// every start and end of a hand already passes through. All that lives here is the
|
||||
// dealer's say in it: the toggle, and the way out offered by the toast the first
|
||||
// time it takes hold.
|
||||
WakeLock.hooks = {
|
||||
turnOff: () => {
|
||||
state.keepScreenAwake = false;
|
||||
hooks.save(state);
|
||||
Render.renderAll();
|
||||
Toast.show("The screen will sleep as usual. Table Setup has it if you want it back.");
|
||||
},
|
||||
};
|
||||
WakeLock.start(() => state);
|
||||
|
||||
document.getElementById("keepScreenAwakeToggle").addEventListener("change", e => {
|
||||
state.keepScreenAwake = e.target.checked;
|
||||
hooks.save(state);
|
||||
// renderAll does the acquiring and releasing, so turning this off mid-hand
|
||||
// drops the lock in the same tick rather than at the end of the hand.
|
||||
Render.renderAll();
|
||||
});
|
||||
|
||||
// ---- Asking the browser to keep the night ----
|
||||
//
|
||||
// Reported rather than announced. A dealer opening Save & reset is already thinking
|
||||
// about losing the table, and that is the only moment this is worth a sentence.
|
||||
const showStorageDurability = async () => {
|
||||
const hint = document.getElementById("storageDurabilityHint");
|
||||
const button = document.getElementById("keepStorageBtn");
|
||||
if (!hint || !button) return;
|
||||
|
||||
if (!HoldemState.supportsDurableStorage()) {
|
||||
// Nothing to say and nothing to offer. A browser this old still keeps the
|
||||
// table exactly as well as it did yesterday.
|
||||
hint.hidden = true;
|
||||
button.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const durable = await HoldemState.isDurable();
|
||||
hint.hidden = false;
|
||||
hint.textContent = durable
|
||||
? "This browser has promised to keep it: the table survives the phone running short on space."
|
||||
: "This browser may clear the table on its own if the phone runs short on space.";
|
||||
button.hidden = durable;
|
||||
};
|
||||
|
||||
// Fires on the first deal of every session, which costs nothing after the first
|
||||
// time it is granted — persisted() is checked before persist() is asked for.
|
||||
const askToKeepStorage = async () => {
|
||||
await HoldemState.requestDurable();
|
||||
showStorageDurability();
|
||||
};
|
||||
|
||||
document.getElementById("keepStorageBtn").addEventListener("click", async () => {
|
||||
const granted = await HoldemState.requestDurable();
|
||||
showStorageDurability();
|
||||
// Said either way, because a button that sometimes does nothing visible is a
|
||||
// button a dealer presses four more times.
|
||||
Toast.show(granted
|
||||
? "This browser will keep the table now."
|
||||
: "This browser wouldn't promise. Save the table to a file if tonight matters.");
|
||||
});
|
||||
|
||||
showStorageDurability();
|
||||
|
||||
// ---- Working with no signal ----
|
||||
//
|
||||
// The night already survives anything — it lives in localStorage — but the page
|
||||
@@ -887,33 +1135,78 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
// cannot drift from the markup, which is the way a hand-kept one always fails: it
|
||||
// stays right until somebody adds a script tag, and then the tool is quietly
|
||||
// offline-broken until a dealer finds out the hard way.
|
||||
const registerWorker = () => {
|
||||
const MANIFEST_URL = "/casino/holdem.webmanifest";
|
||||
|
||||
// The icons are the one part of the shell the document doesn't mention: the browser
|
||||
// reads them out of the manifest itself, so no <link> or <script> in the DOM points
|
||||
// at them and the sweep above misses all four. An installed app that was launched
|
||||
// with no signal then had no icon to show for itself.
|
||||
//
|
||||
// Read out of the manifest rather than typed out here, for the same reason the rest
|
||||
// of the list is read out of the document — a copy of a list is a list that is one
|
||||
// edit away from being wrong, and this particular wrongness is invisible until
|
||||
// somebody is standing in a back room with no signal.
|
||||
const manifestIcons = async () => {
|
||||
try {
|
||||
// The cache before the network, which is backwards from how the rest of this
|
||||
// worker thinks, and right here. Offline, a plain fetch for the manifest goes
|
||||
// to the network, fails, and logs ERR_INTERNET_DISCONNECTED to the console on
|
||||
// every single launch — noise on the one screen where a real error needs to
|
||||
// be visible, in service of re-reading a list of four icon paths that has not
|
||||
// changed since the manifest was written and is already cached anyway.
|
||||
const res = (typeof caches !== "undefined" && await caches.match(MANIFEST_URL))
|
||||
|| await fetch(MANIFEST_URL);
|
||||
if (!res || !res.ok) return [];
|
||||
const manifest = await res.json();
|
||||
return (manifest.icons || [])
|
||||
.map(icon => icon && icon.src)
|
||||
.filter(Boolean)
|
||||
.map(src => new URL(src, location.origin).href);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const registerWorker = async () => {
|
||||
if (!("serviceWorker" in navigator)) return;
|
||||
|
||||
const shell = [
|
||||
location.pathname,
|
||||
"/casino/holdem.webmanifest",
|
||||
...[...document.querySelectorAll('link[rel="stylesheet"][href]')].map(el => el.href),
|
||||
MANIFEST_URL,
|
||||
...await manifestIcons(),
|
||||
// Stylesheets, plus the icons the document points at itself — iOS reads
|
||||
// none of the manifest's icons and only <link rel="apple-touch-icon">, so
|
||||
// an installed iPhone launched with no signal needs this one specifically.
|
||||
...[...document.querySelectorAll(
|
||||
'link[rel="stylesheet"][href], link[rel="apple-touch-icon"][href], link[rel="icon"][href]'
|
||||
)].map(el => el.href),
|
||||
...[...document.querySelectorAll("script[src]")].map(el => el.src),
|
||||
].filter(url => new URL(url, location.origin).origin === location.origin);
|
||||
|
||||
navigator.serviceWorker.register("/casino/sw.js", { scope: "/casino/" })
|
||||
.then(reg => {
|
||||
// The worker that will answer for this page — `controller` is still null
|
||||
// on the very first visit, before activate has claimed it.
|
||||
const worker = reg.active || navigator.serviceWorker.controller;
|
||||
if (worker) {
|
||||
worker.postMessage({ type: "precache", urls: shell });
|
||||
return;
|
||||
}
|
||||
navigator.serviceWorker.ready.then(ready => {
|
||||
if (ready.active) ready.active.postMessage({ type: "precache", urls: shell });
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// No worker means no offline, and nothing else. Every other part of the
|
||||
// tool works exactly as it did, so this is not worth a toast at a table.
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.register("/casino/sw.js", { scope: "/casino/" });
|
||||
|
||||
// Whichever worker this registration has, at whatever stage it has reached.
|
||||
//
|
||||
// This used to fall back on navigator.serviceWorker.ready, which resolves
|
||||
// with the registration *controlling this page* — and so never resolves at
|
||||
// all on a page outside the worker's scope. That is not hypothetical: it is
|
||||
// exactly what /Casino/TexasHoldem did before the redirect in Program.cs,
|
||||
// and the symptom was the worst kind. The registration succeeded, the worker
|
||||
// activated, nothing was logged, and the cache stayed completely empty — a
|
||||
// dealer who had "loaded the tool for tonight" had loaded nothing.
|
||||
const worker = reg.installing || reg.waiting || reg.active;
|
||||
if (!worker) return;
|
||||
|
||||
const send = () => worker.postMessage({ type: "precache", urls: shell });
|
||||
if (worker.state === "activated") send();
|
||||
else worker.addEventListener("statechange", () => {
|
||||
if (worker.state === "activated") send();
|
||||
});
|
||||
} catch {
|
||||
// No worker means no offline, and nothing else. Every other part of the
|
||||
// tool works exactly as it did, so this is not worth a toast at a table.
|
||||
}
|
||||
};
|
||||
|
||||
// After load, not during it: registering competes with the page's own scripts for
|
||||
|
||||
@@ -156,6 +156,29 @@ const Money = {
|
||||
return type === "text" && String(field.inputMode || "").toLowerCase() === "decimal";
|
||||
},
|
||||
|
||||
// Would a wheel over this field change what it says?
|
||||
//
|
||||
// True only for a focused `<input type="number">`, which a browser treats as a
|
||||
// spinbutton and lets the wheel drive. Every other way of driving one is deliberate
|
||||
// — the arrow keys, the stepper arrows, typing — and all of them are left alone.
|
||||
// Scrolling is the one nobody means: the dealer is reaching for another panel and
|
||||
// the cursor happens to still be over a field they touched a moment ago.
|
||||
//
|
||||
// A predicate rather than a listener so the rule can be tested without a browser,
|
||||
// the same way selectsOnFocus is. main.js does the blurring.
|
||||
wheelWouldEdit(field, activeElement) {
|
||||
// The tag check is belt and braces and known to be so: nothing but an <input>
|
||||
// carries type="number", so the line below already excludes every textarea,
|
||||
// select and button on the page. Deleting it passes every test in this file,
|
||||
// which was checked rather than assumed. It stays for symmetry with
|
||||
// selectsOnFocus above, not because it is load-bearing.
|
||||
if (!field || field.tagName !== "INPUT") return false;
|
||||
if (String(field.type || "").toLowerCase() !== "number") return false;
|
||||
// An unfocused spinbutton ignores the wheel, so there is nothing to defend and
|
||||
// blurring something that isn't focused would be a no-op with a side effect.
|
||||
return activeElement === field;
|
||||
},
|
||||
|
||||
// "+$40.00" / "-$40.00" rather than "$-40.00" — a standings column is read at a
|
||||
// glance, and the sign belongs out front where the eye lands first.
|
||||
formatSigned(amount) {
|
||||
|
||||
@@ -76,6 +76,39 @@ const PotWizard = {
|
||||
},
|
||||
|
||||
renderStep() {
|
||||
this.renderStepInto();
|
||||
this.markOverflow();
|
||||
},
|
||||
|
||||
// Say when the list runs on past the bottom of the dialog.
|
||||
//
|
||||
// The body scrolls and the footer stays put, which is the right shape and was as far
|
||||
// as it went: nothing on screen said there was anything below. Measured on a Galaxy
|
||||
// S8, a three-way pot showed two names and cut the third off on a row boundary, just
|
||||
// under a hairline that reads as the end of the list. A dealer would have ticked the
|
||||
// two they could see. On the dialog that pays the table, a list that silently
|
||||
// continues is not a small thing.
|
||||
markOverflow() {
|
||||
const body = document.getElementById("potWizardBody");
|
||||
const modal = document.getElementById("potWizardModal");
|
||||
if (!body || !modal) return;
|
||||
|
||||
const update = () => {
|
||||
const below = body.scrollHeight - body.clientHeight - body.scrollTop;
|
||||
modal.classList.toggle("potWizardMoreBelow", below > 1);
|
||||
};
|
||||
if (!this.watchingOverflow) {
|
||||
body.addEventListener("scroll", update, { passive: true });
|
||||
window.addEventListener("resize", update);
|
||||
this.watchingOverflow = true;
|
||||
}
|
||||
update();
|
||||
// Again once the browser has laid the step out — on the first open the body has
|
||||
// no height yet, and an answer taken then is always "nothing below".
|
||||
requestAnimationFrame(update);
|
||||
},
|
||||
|
||||
renderStepInto() {
|
||||
// Auto-resolve any round where only one player is left standing — nothing to ask.
|
||||
let contenders = PotCalculator.getContenders(this.entries);
|
||||
while (contenders.length === 1) {
|
||||
@@ -124,13 +157,25 @@ const PotWizard = {
|
||||
const round = document.createElement("div");
|
||||
round.className = "potWizardRound";
|
||||
|
||||
const showdown = this.readShowdown(contenders);
|
||||
const decided = showdown && showdown.complete ? new Set(showdown.winners) : null;
|
||||
const handById = new Map((showdown ? showdown.players : []).map(p => [p.id, p.hand]));
|
||||
|
||||
const prompt = document.createElement("p");
|
||||
prompt.className = "potWizardPrompt";
|
||||
prompt.innerHTML = this.log.length === 0
|
||||
? `Who won the hand? <strong>${Money.format(remaining)}</strong> is in play.`
|
||||
: `That side pot still leaves <strong>${Money.format(remaining)}</strong> unassigned — who's next best?`;
|
||||
prompt.innerHTML = decided
|
||||
// It has read the hands, so it stops asking a question it knows the answer
|
||||
// to. The list stays — the dealer confirms, and a hand entered wrong is
|
||||
// corrected by unticking rather than by starting over.
|
||||
? `<strong>${Money.format(remaining)}</strong> in play.`
|
||||
: this.log.length === 0
|
||||
? `Who won the hand? <strong>${Money.format(remaining)}</strong> is in play.`
|
||||
: `That side pot still leaves <strong>${Money.format(remaining)}</strong> unassigned — who's next best?`;
|
||||
round.appendChild(prompt);
|
||||
|
||||
const verdict = this.renderVerdict(showdown, contenders);
|
||||
if (verdict) round.appendChild(verdict);
|
||||
|
||||
const choices = document.createElement("div");
|
||||
choices.className = "potWizardChoices";
|
||||
contenders.forEach(c => {
|
||||
@@ -140,6 +185,10 @@ const PotWizard = {
|
||||
const checkbox = document.createElement("input");
|
||||
checkbox.type = "checkbox";
|
||||
checkbox.value = c.id;
|
||||
if (decided && decided.has(c.id)) {
|
||||
checkbox.checked = true;
|
||||
label.classList.add("chosen");
|
||||
}
|
||||
// The row is the target, not the box — so the row has to look chosen too.
|
||||
checkbox.addEventListener("change", () => {
|
||||
label.classList.toggle("chosen", checkbox.checked);
|
||||
@@ -154,11 +203,25 @@ const PotWizard = {
|
||||
name.textContent = this.nameById.get(c.id);
|
||||
text.appendChild(name);
|
||||
|
||||
// How this hand was read, under the name — on every row rather than only
|
||||
// the winning one, because a dealer checking the tool's answer is reading
|
||||
// the losers' hands, and a row that says nothing is a row they have to work
|
||||
// out for themselves. It takes the line the ceiling used to have; the
|
||||
// ceiling moves out beside the name, which is where the phone layout had
|
||||
// already put it to save exactly this line.
|
||||
const read = handById.get(c.id);
|
||||
if (read) {
|
||||
const ranked = document.createElement("span");
|
||||
ranked.className = "potWizardChoiceHand";
|
||||
ranked.textContent = HandRank.describe(read);
|
||||
text.appendChild(ranked);
|
||||
}
|
||||
label.appendChild(text);
|
||||
|
||||
const upTo = document.createElement("span");
|
||||
upTo.className = "potWizardChoiceUpTo";
|
||||
upTo.textContent = `up to ${Money.format(c.remaining)}`;
|
||||
text.appendChild(upTo);
|
||||
label.appendChild(text);
|
||||
label.appendChild(upTo);
|
||||
|
||||
// Cards sit apart from the name rather than inside it, so a dealer reading
|
||||
// a row across the table sees the hand, not a sentence.
|
||||
@@ -187,7 +250,11 @@ const PotWizard = {
|
||||
|
||||
const confirmBtn = document.createElement("button");
|
||||
confirmBtn.className = "holdemBtn holdemBtnAccent";
|
||||
confirmBtn.textContent = "Confirm Winner(s)";
|
||||
confirmBtn.textContent = decided
|
||||
? (showdown.winners.length === 1
|
||||
? `Pay ${this.nameById.get(showdown.winners[0])}`
|
||||
: "Split the pot")
|
||||
: "Confirm Winner(s)";
|
||||
confirmBtn.addEventListener("click", () => {
|
||||
const selected = Array.from(choices.querySelectorAll("input:checked")).map(i => i.value);
|
||||
if (selected.length === 0) return;
|
||||
@@ -200,6 +267,84 @@ const PotWizard = {
|
||||
footer.appendChild(confirmBtn);
|
||||
},
|
||||
|
||||
// How the tool reads the hands still in for this round of the pot.
|
||||
//
|
||||
// Asked per round rather than once for the whole hand, because a side pot has its
|
||||
// own contenders: the player who won the main pot is out of the question for the
|
||||
// next one, and the best hand among who is left is a different answer.
|
||||
//
|
||||
// Only when the dealer asked to track the board — everything here is off by default
|
||||
// and the wizard behaves exactly as it always has.
|
||||
readShowdown(contenders) {
|
||||
if (!this.state.trackBoard || typeof HandRank === "undefined") return null;
|
||||
return HandRank.showdown(this.state.board, contenders.map(c => ({
|
||||
id: c.id,
|
||||
cards: this.cardsById.get(c.id) || [],
|
||||
})));
|
||||
},
|
||||
|
||||
// What the winners would actually be paid if this round were confirmed now.
|
||||
//
|
||||
// Worked out from the same function that does the paying rather than from a second
|
||||
// one that agrees with it today — a preview that can drift from the payout is worse
|
||||
// than no preview, because it is the number the dealer says out loud.
|
||||
previewPayout(winnerIds) {
|
||||
try {
|
||||
const result = PotCalculator.resolveRound(this.entries, this.orderFromButton(winnerIds));
|
||||
return result.perWinner || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
// "Alexandra wins $25 with a full house, kings full of threes."
|
||||
//
|
||||
// The whole point of the feature, in one line: who won and how much. It goes above
|
||||
// the list rather than replacing it, because the dealer still confirms — the tool
|
||||
// reads the cards, a person still says the pot is good.
|
||||
renderVerdict(showdown, contenders) {
|
||||
if (!showdown) return null;
|
||||
|
||||
const line = document.createElement("p");
|
||||
line.className = "potWizardVerdict";
|
||||
|
||||
if (!showdown.boardComplete) {
|
||||
line.classList.add("potWizardVerdictUnsure");
|
||||
line.textContent = "The board isn't all five cards yet, so pick the winner yourself — or fill it in on the felt and this will read it.";
|
||||
return line;
|
||||
}
|
||||
if (showdown.duplicated) {
|
||||
line.classList.add("potWizardVerdictUnsure");
|
||||
line.textContent = "The same card is in two places, so this can't be read. Check the cards on the felt.";
|
||||
return line;
|
||||
}
|
||||
if (!showdown.complete) {
|
||||
const missing = showdown.unknown.map(id => this.nameById.get(id)).filter(Boolean);
|
||||
line.classList.add("potWizardVerdictUnsure");
|
||||
line.textContent = missing.length
|
||||
? `${this.listOf(missing)} ${missing.length === 1 ? "hasn't" : "haven't"} turned over, so who won is still yours to say.`
|
||||
: "Not enough cards to read this one.";
|
||||
return line;
|
||||
}
|
||||
|
||||
const paid = this.previewPayout(showdown.winners);
|
||||
const amount = paid.length ? Money.format(paid[0].amount) : "";
|
||||
const names = showdown.winners.map(id => this.nameById.get(id)).filter(Boolean);
|
||||
const hand = showdown.players.find(p => p.id === showdown.winners[0]);
|
||||
const described = HandRank.phrase(hand && hand.hand);
|
||||
|
||||
line.classList.add("potWizardVerdictSure");
|
||||
line.textContent = names.length === 1
|
||||
? `${names[0]} wins ${amount} with ${described}.`
|
||||
: `${this.listOf(names)} split it, ${amount} each, with ${described}.`;
|
||||
return line;
|
||||
},
|
||||
|
||||
listOf(names) {
|
||||
if (names.length <= 1) return names[0] || "";
|
||||
return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
|
||||
},
|
||||
|
||||
// The row of buttons that never scrolls away.
|
||||
footer() {
|
||||
return document.getElementById("potWizardFooter");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -58,6 +58,19 @@ const HoldemState = {
|
||||
bigBlind: 2,
|
||||
defaultStack: null,
|
||||
street: "preflop",
|
||||
// The community cards, in the order they hit the felt: three, then one,
|
||||
// then one. Empty strings for the ones that haven't been turned yet, the
|
||||
// same way a seat's two hole cards are stored.
|
||||
board: ["", "", "", "", ""],
|
||||
// Whether the dealer wants to be asked for them at all. Off is the tool as
|
||||
// it has always been: no board on the felt, and the pot wizard asks who
|
||||
// won. On, it reads the showdown and answers that question itself.
|
||||
trackBoard: false,
|
||||
// Whether to stop the screen sleeping while a hand is live. On, because a
|
||||
// dealer who has to wake their phone between the flop and the turn is the
|
||||
// single most common way this tool gets in the way of the game it is meant
|
||||
// to be running. Only ever held while betting is open — see wakeLock.js.
|
||||
keepScreenAwake: true,
|
||||
currentBet: 0,
|
||||
lastRaiseSize: 0,
|
||||
actingSeatIndex: null,
|
||||
@@ -216,6 +229,17 @@ const HoldemState = {
|
||||
// A table saved before the dead button existed has neither flag. False is
|
||||
// the right guess for both: it means the rotation carries on the ordinary
|
||||
// way, which is what that table was already doing.
|
||||
// A table saved before the board existed has no board and was not tracking
|
||||
// one. Off is also the right guess for a dealer who never asked for it.
|
||||
parsed.trackBoard = parsed.trackBoard === true;
|
||||
parsed.board = this.normaliseBoard(parsed.board);
|
||||
|
||||
// Note the direction: `!== false` rather than `=== true`. A table saved
|
||||
// before this setting existed has no opinion about it, and the useful guess
|
||||
// for a dealer who has never been asked is the helpful one. Only somebody
|
||||
// who has actually turned it off gets it off.
|
||||
parsed.keepScreenAwake = parsed.keepScreenAwake !== false;
|
||||
|
||||
parsed.deadButton = parsed.deadButton === true;
|
||||
parsed.deadSmallBlind = parsed.deadSmallBlind === true;
|
||||
// An old save has no record of who was dealt last hand. Empty is the
|
||||
@@ -305,6 +329,48 @@ const HoldemState = {
|
||||
//
|
||||
// So the table can be written out as a file and read back in. Same shape that goes
|
||||
// into storage, wrapped in enough to recognise it later.
|
||||
// ---- Asking the browser to actually keep it -------------------------------
|
||||
//
|
||||
// "The night lives in this browser and nowhere else" is truer than it sounds.
|
||||
// localStorage is *evictable*: when a device runs short, the browser clears
|
||||
// site data to make room, and it starts with the sites it thinks nobody cares
|
||||
// about. Safari goes further and deletes script-created storage after seven days
|
||||
// without a visit — so a table saved at last month's game can simply not be there
|
||||
// at this one, with nothing having gone wrong and nobody having deleted anything.
|
||||
//
|
||||
// `persist()` is the way to say this data is not that. Granted, the storage can
|
||||
// only be cleared by the person whose browser it is. Chromium and Safari decide
|
||||
// silently on how much the site has been used and whether it's installed; Firefox
|
||||
// asks. All three want the request to come from something the dealer did, which is
|
||||
// why this fires on the first hand dealt rather than on page load.
|
||||
supportsDurableStorage() {
|
||||
return typeof navigator !== "undefined"
|
||||
&& !!navigator.storage
|
||||
&& typeof navigator.storage.persist === "function";
|
||||
},
|
||||
|
||||
async isDurable() {
|
||||
if (!this.supportsDurableStorage()) return false;
|
||||
try {
|
||||
return await navigator.storage.persisted();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// Returns whether the browser said yes. A no is not a failure worth reporting at a
|
||||
// table — the tool behaves identically either way, and the only honest difference
|
||||
// is how confident the Save & reset panel is allowed to sound.
|
||||
async requestDurable() {
|
||||
if (!this.supportsDurableStorage()) return false;
|
||||
try {
|
||||
if (await navigator.storage.persisted()) return true;
|
||||
return await navigator.storage.persist();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
BACKUP_KIND: "casino.holdem.table",
|
||||
BACKUP_VERSION: 1,
|
||||
|
||||
@@ -705,6 +771,7 @@ const HoldemState = {
|
||||
// that stayed on would take chips off somebody who never said the word.
|
||||
state.straddleCount = 0;
|
||||
state.postedStraddles = [];
|
||||
this.clearBoard(state);
|
||||
state.seats.forEach(p => {
|
||||
p.folded = false;
|
||||
p.cards = ["", ""];
|
||||
@@ -717,6 +784,21 @@ const HoldemState = {
|
||||
this.ensureButtonOnPlayer(state);
|
||||
},
|
||||
|
||||
// Exactly five slots of either a two-character card or an empty string, whatever
|
||||
// arrived. A board read off a file is the one that has been somewhere this tool
|
||||
// cannot see.
|
||||
normaliseBoard(board) {
|
||||
const source = Array.isArray(board) ? board : [];
|
||||
return Array.from({ length: 5 }, (_, i) => {
|
||||
const card = typeof source[i] === "string" ? source[i].trim().toUpperCase() : "";
|
||||
return /^[23456789TJQKA][SHDC]$/.test(card) ? card : "";
|
||||
});
|
||||
},
|
||||
|
||||
clearBoard(state) {
|
||||
state.board = ["", "", "", "", ""];
|
||||
},
|
||||
|
||||
// Appends a resolved-pot record to the running session log a dealer can point to
|
||||
// for chip-count disputes, then advances the hand counter. `payouts` is
|
||||
// [{ id, label, amount }, ...] — who got paid what out of this hand's pot(s).
|
||||
@@ -1110,6 +1192,84 @@ const HoldemState = {
|
||||
return Math.max(0, Money.round(Betting.stackOf(state, player)));
|
||||
},
|
||||
|
||||
// What a chip colour is holding up.
|
||||
//
|
||||
// Removing one is not a change to a legend. computeContribution() prices a seat by
|
||||
// running the legend over that seat's chip counts, so a colour that leaves the
|
||||
// legend takes every chip counted in it off the table. Measured on a three-handed
|
||||
// table counting real chips: deleting the $500 row took $22,500 of $26,874 with it,
|
||||
// on one click of a 24px button, with nothing said and nothing shown.
|
||||
//
|
||||
// It does not come back, either. A colour added again is a new colour with a new
|
||||
// id, while the counts stay keyed to the one that left — so "put it back" restores
|
||||
// the row and none of the money.
|
||||
//
|
||||
// Hence this: the cost is answerable before the click rather than discovered at the
|
||||
// end of the night, when the box is short and nobody knows why.
|
||||
// The number here is the *difference*, asked by pricing every seat twice — once with
|
||||
// the colour and once without — rather than by multiplying the chips by their value.
|
||||
//
|
||||
// Those two are not the same, and the multiplication is the wrong one, because
|
||||
// computeContribution() does not always reach the chips. A seat with a typed amount
|
||||
// is priced from that and its chip counts are ignored, so a colour it holds is worth
|
||||
// nothing to it; and a seat whose chips all leave falls through to the table's base
|
||||
// pot — the standing ante — rather than to zero, so it loses less than its chips
|
||||
// were worth.
|
||||
// Both would put a number in the confirmation that the table then failed to match,
|
||||
// which is worse than no number at all — this is a warning whose whole job is to be
|
||||
// the truth.
|
||||
chipColorUsage(state, chipId) {
|
||||
const chip = (state.chipLegend || []).find(entry => entry.id === chipId);
|
||||
if (!chip) return { chip: null, total: 0, chips: 0, seats: [] };
|
||||
|
||||
const legend = state.chipLegend || [];
|
||||
const without = legend.filter(entry => entry.id !== chipId);
|
||||
|
||||
const seats = state.seats
|
||||
.map((player, index) => {
|
||||
const before = this.computeContribution(player, legend, state.basePotAmount);
|
||||
const after = this.computeContribution(player, without, state.basePotAmount);
|
||||
return {
|
||||
index,
|
||||
name: this.displayName(player, index),
|
||||
count: Number((player.chipCounts || {})[chipId]) || 0,
|
||||
amount: Money.round(before - after),
|
||||
};
|
||||
})
|
||||
// Signed, and not filtered to losses, because removing a colour can put the
|
||||
// table *up*. A seat whose chips are worth less than the table's standing
|
||||
// ante falls through to the ante once they are gone: found by the randomised
|
||||
// check at 14 chips of a $1 colour against a $200 base pot, where deleting it
|
||||
// moved the table by +186. Rare, but a warning that said "nothing changes"
|
||||
// there would be as wrong as one that named the wrong number.
|
||||
.filter(row => row.amount !== 0);
|
||||
|
||||
return {
|
||||
chip,
|
||||
seats,
|
||||
chips: seats.reduce((sum, row) => sum + row.count, 0),
|
||||
total: Money.round(seats.reduce((sum, row) => sum + row.amount, 0)),
|
||||
};
|
||||
},
|
||||
|
||||
// Take a colour out, and take its counts with it.
|
||||
//
|
||||
// Leaving them behind is what the old code did — the seats kept counts against an
|
||||
// id no legend entry answers to any more, so the file carried money that nothing
|
||||
// could price and nothing could show. Clearing them makes the saved table say what
|
||||
// the screen says. The usage is measured before anything moves, so a caller can
|
||||
// report what it cost.
|
||||
removeChipColor(state, chipId) {
|
||||
const usage = this.chipColorUsage(state, chipId);
|
||||
if (!usage.chip) return usage;
|
||||
|
||||
state.chipLegend = (state.chipLegend || []).filter(entry => entry.id !== chipId);
|
||||
state.seats.forEach(player => {
|
||||
if (player.chipCounts && chipId in player.chipCounts) delete player.chipCounts[chipId];
|
||||
});
|
||||
return usage;
|
||||
},
|
||||
|
||||
// Seats playing with chips the box has no cash against.
|
||||
//
|
||||
// Two kinds, and they are not the same problem. A seat holding the table's starting
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// Keeps the screen on while a hand is live.
|
||||
//
|
||||
// The dealer's phone spends the night face-up on the felt with nine people round it,
|
||||
// and the one thing it reliably does between the flop and the turn is dim, then lock.
|
||||
// Every hand, all night. A dozen rounds of work have gone into making the next step
|
||||
// readable at a glance, and none of it survives a black screen and a passcode with
|
||||
// four people waiting to know whether the bet is called.
|
||||
//
|
||||
// Held only while betting is actually open. A phone left on the table through a break,
|
||||
// a re-buy, or the twenty minutes where everyone is arguing about pizza sleeps exactly
|
||||
// like any other phone — which is the difference between a tool that respects a battery
|
||||
// and one a dealer stops trusting with their evening.
|
||||
|
||||
const WakeLock = {
|
||||
// The browser's promise to stay awake. Null whenever we aren't holding one, which
|
||||
// includes every moment the browser has taken it back on us — see `start()`.
|
||||
sentinel: null,
|
||||
|
||||
// What the last sync decided we want. Kept separate from `sentinel` because
|
||||
// requesting one is asynchronous: a hand can be dealt and folded out inside the
|
||||
// await, and the answer to "should we still be holding this" has to be the current
|
||||
// one rather than the one that was true when the request went out.
|
||||
wanted: false,
|
||||
|
||||
// One request at a time. `sync()` runs off renderAll, which fires many times a
|
||||
// second during a busy street; without this, two overlapping requests both get
|
||||
// past the "already holding one?" check and the second sentinel leaks — the screen
|
||||
// then stays awake after the hand ends, with nothing left holding a reference to
|
||||
// turn it off.
|
||||
pending: false,
|
||||
|
||||
// Whether the dealer has been told this is happening. Once per page load, not once
|
||||
// per hand: the same toast at the top of all forty hands of a session would be the
|
||||
// single most annoying thing the tool does.
|
||||
told: false,
|
||||
|
||||
// Set by main.js so the toast's "Turn off" can actually turn it off.
|
||||
hooks: null,
|
||||
|
||||
supported() {
|
||||
return typeof navigator !== "undefined" && "wakeLock" in navigator;
|
||||
},
|
||||
|
||||
// The whole decision, with no browser in it, so it can be tested as what it is:
|
||||
// a rule about when a poker table needs the screen.
|
||||
shouldHold(state) {
|
||||
if (!state) return false;
|
||||
if (state.keepScreenAwake === false) return false;
|
||||
return state.bettingOpen === true;
|
||||
},
|
||||
|
||||
// Called after anything that could start or end a hand. Safe to call constantly.
|
||||
sync(state) {
|
||||
this.wanted = this.supported() && this.shouldHold(state);
|
||||
if (this.wanted) this.acquire();
|
||||
else this.release();
|
||||
},
|
||||
|
||||
async acquire() {
|
||||
if (this.sentinel || this.pending) return;
|
||||
this.pending = true;
|
||||
try {
|
||||
const sentinel = await navigator.wakeLock.request("screen");
|
||||
// The hand can have ended while that was in flight. Letting this one go
|
||||
// immediately is the only correct answer; holding it "just until the next
|
||||
// sync" is how a phone stays lit on an empty table.
|
||||
if (!this.wanted) {
|
||||
try { await sentinel.release(); } catch { /* already gone */ }
|
||||
return;
|
||||
}
|
||||
this.sentinel = sentinel;
|
||||
// The browser releases this itself when the tab is hidden, and tells us so.
|
||||
// Without this listener our `sentinel` would be a stale object and the
|
||||
// re-acquire in `start()` would decline to do anything.
|
||||
sentinel.addEventListener("release", () => {
|
||||
if (this.sentinel === sentinel) this.sentinel = null;
|
||||
});
|
||||
this.tell();
|
||||
} catch {
|
||||
// Battery saver, a hidden tab, a browser that simply says no. All of these
|
||||
// are the browser doing its job, and none of them is worth an error at a
|
||||
// poker table — the tool works exactly as it did before, the screen just
|
||||
// dims the way the phone's own settings say it should.
|
||||
} finally {
|
||||
this.pending = false;
|
||||
}
|
||||
},
|
||||
|
||||
async release() {
|
||||
const sentinel = this.sentinel;
|
||||
this.sentinel = null;
|
||||
if (!sentinel) return;
|
||||
try { await sentinel.release(); } catch { /* already released */ }
|
||||
},
|
||||
|
||||
// Said once, at the moment it first becomes true, with the way out attached.
|
||||
//
|
||||
// The alternative was a permanent indicator in the header, and the header is the
|
||||
// one part of this page that has been fought over for crowding more than any other.
|
||||
// A dealer needs to know their phone is behaving unusually exactly once; after that
|
||||
// it is a setting, and settings live in the sidebar.
|
||||
tell() {
|
||||
if (this.told) return;
|
||||
this.told = true;
|
||||
if (typeof Toast === "undefined") return;
|
||||
Toast.show("Keeping the screen on while a hand is live.", {
|
||||
actionLabel: "Turn off",
|
||||
onAction: () => { if (this.hooks && this.hooks.turnOff) this.hooks.turnOff(); },
|
||||
});
|
||||
},
|
||||
|
||||
// A screen wake lock is not something you take once. The browser drops it whenever
|
||||
// the document stops being visible — switching apps to check a rule, the phone
|
||||
// locking on its own before the first hand was dealt, a notification pulled down —
|
||||
// and it never comes back on its own. A tool that requests it at the start of the
|
||||
// hand and assumes it still has it is a tool that works until the first interruption.
|
||||
start(getState) {
|
||||
if (!this.supported()) return;
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
this.sync(getState());
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = WakeLock;
|
||||
@@ -0,0 +1,86 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
addHeadingAnchors();
|
||||
trackReadingProgress();
|
||||
highlightCurrentSection();
|
||||
setUpSidebarFilter();
|
||||
setUpMobileMenu();
|
||||
});
|
||||
|
||||
// Hover "#" links on headings so any section can be linked to directly.
|
||||
function addHeadingAnchors() {
|
||||
document.querySelectorAll('.soc-content h2[id], .soc-content h3[id]').forEach(heading => {
|
||||
const anchor = document.createElement('a');
|
||||
anchor.className = 'soc-anchor';
|
||||
anchor.href = '#' + heading.id;
|
||||
anchor.textContent = '#';
|
||||
anchor.setAttribute('aria-label', 'Link to this section');
|
||||
heading.prepend(anchor);
|
||||
});
|
||||
}
|
||||
|
||||
function trackReadingProgress() {
|
||||
const bar = document.getElementById('socProgress');
|
||||
if (!bar) return;
|
||||
|
||||
const update = () => {
|
||||
const scrollable = document.documentElement.scrollHeight - window.innerHeight;
|
||||
bar.style.width = scrollable > 0 ? (window.scrollY / scrollable) * 100 + '%' : '0';
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', update, { passive: true });
|
||||
update();
|
||||
}
|
||||
|
||||
// Highlights the "On this page" entry for the section currently being read.
|
||||
function highlightCurrentSection() {
|
||||
const links = [...document.querySelectorAll('.soc-toc a[data-toc-id]')];
|
||||
if (links.length === 0) return;
|
||||
|
||||
const headings = links
|
||||
.map(link => document.getElementById(link.dataset.tocId))
|
||||
.filter(Boolean);
|
||||
|
||||
const update = () => {
|
||||
let current = headings[0];
|
||||
for (const heading of headings) {
|
||||
if (heading.getBoundingClientRect().top <= 120) current = heading;
|
||||
}
|
||||
links.forEach(link => link.classList.toggle('active', link.dataset.tocId === current?.id));
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', update, { passive: true });
|
||||
update();
|
||||
}
|
||||
|
||||
function setUpSidebarFilter() {
|
||||
const input = document.getElementById('socFilter');
|
||||
if (!input) return;
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
const query = input.value.trim().toLowerCase();
|
||||
document.querySelectorAll('.soc-nav-link').forEach(link => {
|
||||
link.classList.toggle('hidden', query !== '' && !link.dataset.filterText.includes(query));
|
||||
});
|
||||
document.querySelectorAll('.soc-nav-group').forEach(group => {
|
||||
const anyVisible = group.querySelector('.soc-nav-link:not(.hidden)');
|
||||
group.style.display = anyVisible ? '' : 'none';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setUpMobileMenu() {
|
||||
const toggle = document.getElementById('socMenuToggle');
|
||||
const sidebar = document.getElementById('socSidebar');
|
||||
if (!toggle || !sidebar) return;
|
||||
|
||||
const setOpen = open => {
|
||||
sidebar.classList.toggle('open', open);
|
||||
toggle.setAttribute('aria-expanded', String(open));
|
||||
toggle.textContent = open ? 'Close' : 'Contents';
|
||||
};
|
||||
|
||||
toggle.addEventListener('click', () => setOpen(!sidebar.classList.contains('open')));
|
||||
sidebar.addEventListener('click', event => {
|
||||
if (event.target.closest('a')) setOpen(false);
|
||||
});
|
||||
}
|
||||
+963
-6
@@ -20,6 +20,13 @@ other in the page, in the load order from `TexasHoldem.cshtml`.
|
||||
| `betting.test.js` | Blinds, street completion, raises reopening the action, all-in run-outs |
|
||||
| `state.test.js` | Button rotation, which seats post blinds, session standings |
|
||||
| `splitCalculator.test.js` | Chopped pots, odd chips, unmakeable denominations |
|
||||
| `handRank.test.js` | What beats what: categories, the wheel, kickers, best five of seven |
|
||||
| `readingTheShowdown.test.js` | The board, the picker that fills it, and who wins a side pot |
|
||||
| `theRail.test.js` | A phone on its side: which shape the bar is in, what it leaves under the felt, how a name is shortened, how the ring is pulled back onto the table |
|
||||
| `feltShape.test.js` | What shape the felt is at each phone size, resolved through the media queries in source order (see below) |
|
||||
| `potBreakdown.test.js` | How each pot says who can win it, and the nesting property that wording rests on (see below) |
|
||||
| `sidebarPanels.test.js` | What the sidebar's panels are called and what lives in each (see below) |
|
||||
| `chipLegend.test.js` | Removing a chip colour, and whether the warning's number is the one the table loses (see below) |
|
||||
| `invariants.test.js` | 1000 randomised hands per property (see below) |
|
||||
|
||||
## The layout audit
|
||||
@@ -33,10 +40,85 @@ node tests/holdem/runUiAudit.js # needs Playwright and the site running
|
||||
|
||||
It checks WCAG AA contrast, touch target sizes, touch-action and tap highlights, that fields stay selectable and are large
|
||||
enough not to trigger iOS zoom, that nothing important is covered or off-screen with
|
||||
no way to scroll to it, that no grid column has collapsed to nothing, and that no
|
||||
text is painted outside its box. It runs over the table, all five overlays and Quick
|
||||
Split, at thirteen viewports from a 360px phone to a 1600px desktop and the same
|
||||
phones on their side — each viewport twice, once with a mouse and once with a thumb.
|
||||
no way to scroll to it, that no grid column has collapsed to nothing, that no
|
||||
text is painted outside its box, that no seat covers another seat's writing, and that
|
||||
no bet chip is hidden behind a seat or another chip. It
|
||||
runs over the table, the raise pad, all five overlays and Quick Split, at thirteen
|
||||
viewports from a
|
||||
360px phone to a 1600px desktop and the same phones on their side — each viewport
|
||||
twice, once with a mouse and once with a thumb, and at three table shapes, mid-hand:
|
||||
the sweep posts blinds, deals and calls three times before it measures anything, so
|
||||
the bets layer has chips on it. It used to audit a table nobody had ever bet at,
|
||||
which is a third of what is on the felt during a hand left unlooked at.
|
||||
|
||||
The raise pad is newer than the rest and was worth every second it costs. It is part
|
||||
of the bar rather than an overlay, so nothing here had a step that opened it, and it is
|
||||
both the tallest thing the bar ever holds and the only place in the tool a dealer types
|
||||
an amount. The first sweep that opened it found a 36px-tall field on every phone in the
|
||||
list: the `hover: none` rule that sizes fields for a thumb names the settings panels and
|
||||
the seat form, and the one field on the one surface built for thumbs was not in the
|
||||
list. It also found the rail squeezing its own status line under the pad, which is the
|
||||
kind of thing that only happens in the state nobody was looking at.
|
||||
|
||||
"Covered" and "out of view" are told apart before anything is hit-tested. An element
|
||||
clipped away by a panel that scrolls is not covered by whatever happens to be painted
|
||||
at that height, and reporting it that way is a diagnosis of something that isn't
|
||||
happening: the pot wizard's third winner, below the fold in a list, came back for four
|
||||
viewports running as "fully covered by button Confirm Winner(s)".
|
||||
|
||||
Text is measured where it was painted, not where its box is. The two are different
|
||||
things and the difference is not academic: a name is centred, so its box is wider than
|
||||
its ink, and a name kept clear of the dealer's chip by a float is out of the way on the
|
||||
line beside the chip and full width on the line below. The check used to model that as
|
||||
"box minus padding", which is one way of keeping text out of somewhere and not the only
|
||||
one — it called every seat in the sweep an offender while the text sat exactly where it
|
||||
was meant to. Range rects ask the question directly, so padding, floats, centring,
|
||||
wrapping and a short label in a wide box all come out right without any of them being
|
||||
modelled.
|
||||
|
||||
Offenders say how much is covered rather than that something is. "Is under a badge"
|
||||
reads the same for a stack a dealer cannot read and for a badge's rounded corner
|
||||
grazing the last digit, and the difference decides whether it is worth fixing. Probing
|
||||
the whole line and counting turned the residual this sweep has carried for three rounds
|
||||
into "1/7 under SB" — one probe of seven, on one configuration, in a thumb context
|
||||
only.
|
||||
|
||||
More than one table shape is also newer than it sounds. The sweep ran on one — five
|
||||
players at a nine-handed table — for its whole life, and how much room the felt has
|
||||
depends on how many people are sitting down far more than on anything a viewport can
|
||||
report. A full table is where the seats crowd and it had never been looked at: the
|
||||
first sweep that did found one player's name or stack painted under another on eighteen
|
||||
of the configurations, on every phone in the list.
|
||||
|
||||
The third shape is the same five players with the board turned on and five cards on it.
|
||||
It is deliberately the same roster in the same chairs as the second, so the only thing
|
||||
that differs between the two rows is the board — a seventh player would have told us
|
||||
that a seven-handed table crowds, which is true, already known, and not what that row
|
||||
is there to find out. It earned its place immediately: the board takes the middle of
|
||||
the felt, or a strip above it where the middle is too small, and the strip costs the
|
||||
felt height. The seats follow a felt that changes size on their own, being placed in
|
||||
percentages of it; the chips anchored inside them do not, being placed in pixels. They
|
||||
were laid out for a felt 23px taller than the one they were painted on.
|
||||
|
||||
## What the sweep taught the layout
|
||||
|
||||
For a long run of rounds the seat ring was *predicted* onto the table: work out the
|
||||
widest ring whose outermost puck still finishes inside the rail, place the seats, and
|
||||
trust it. The prediction is correct arithmetic and it was wrong on the page anyway,
|
||||
because every input to it moves after it is taken — the width search settles on a
|
||||
narrower puck, a name wraps to a second line or gets shortened out of one, the board
|
||||
appears in the middle. Each of those is up to 15px of puck height arriving after the
|
||||
answer. Ninety-six of the ninety-eight offenders this sweep could find were that.
|
||||
|
||||
So render.js now finishes the way the audit judges it. After the seats are placed it
|
||||
measures where they actually landed, and if the worst of them hangs 6px past the rail
|
||||
the ring comes in by 6px and they are placed again — the same question this file asks,
|
||||
asked by the code that has to get it right. Two passes, bounded, because each one is a
|
||||
forced layout of the whole felt and it runs after every tap a dealer makes.
|
||||
|
||||
That is the general shape of the lesson here: a layout that predicts itself has to be
|
||||
right about the future, and a layout that measures itself only has to be right about
|
||||
the present.
|
||||
|
||||
That last part, and two of those checks, are newer than this paragraph claiming them.
|
||||
`runAll()` did not include the contrast check at all, and touch targets sat behind an
|
||||
@@ -53,8 +135,8 @@ offenders, which reads identically to a pass. The same goes for the two exemptio
|
||||
that exist: a disabled control, which WCAG itself excuses, and a link inside a
|
||||
sentence, whose size is set by the line it sits in.
|
||||
|
||||
Deliberately not in CI: the unit suite is dependency-free and runs in a second, while
|
||||
this one needs a browser and a running site. Run it after any change that adds or
|
||||
Deliberately not in CI: the unit suite is dependency-free and runs in about five
|
||||
seconds, while this one needs a browser and a running site. Run it after any change that adds or
|
||||
resizes a control. With no Playwright to hand, paste `uiAudit.js` into devtools and
|
||||
call `LayoutAudit.runAll()`.
|
||||
|
||||
@@ -77,6 +159,881 @@ something changed, and each time they only covered whatever had just been touche
|
||||
That is how the standings table shipped with a player-name column zero pixels wide
|
||||
and kept it for two rounds.
|
||||
|
||||
## Which rule wins
|
||||
|
||||
`feltShape.test.js` is here because of a change that measured as correct and was a
|
||||
no-op. The felt on a phone was made an upright oval by setting `--feltAspect` inside
|
||||
`@media (max-width: 700px)`, and forty lines below it `@media (max-width: 900px)` set
|
||||
the same property back to 1. Both queries match a phone, the declarations have the same
|
||||
specificity, and the later one wins — so the property was 1 on every phone on the site
|
||||
while the rule that was supposed to change it sat there reading perfectly.
|
||||
|
||||
Nothing in the suite could have caught it and nothing in review did. A stylesheet is not
|
||||
a list of rules for viewports; it is a list of rules in an order, and the question "what
|
||||
does a 390px phone get?" has an answer that no single rule knows. So the test walks the
|
||||
media queries the way the cascade does — every declaration of a property on a selector,
|
||||
in source order, keeping the last one whose query matches — and asks what each of five
|
||||
real phone sizes actually ends up with. It carries just enough of a media-query engine
|
||||
for the forms this stylesheet uses, and refuses loudly rather than guessing when it
|
||||
meets one it doesn't know.
|
||||
|
||||
The same file records a second thing, which is a floor that was added and removed. The
|
||||
`max-height` that keeps the felt above the action bar has no lower bound, so a tall
|
||||
enough bar presses the table flat; flooring it at the desktop oval looked obviously
|
||||
right. Measured, it was worse: twelve players all in with nine side pots takes the bar
|
||||
to 341px, which at 360x640 leaves 142px of room, and a felt floored to 242px keeps its
|
||||
shape by putting five of the twelve seats behind the bar. Unfloored it is flat and all
|
||||
twelve are visible. The slit the floor defended against turned out to be unreachable —
|
||||
it came from forcing the custom property by hand. A test now asserts the floor is
|
||||
*absent*, with the numbers, so it doesn't get re-added by someone reasoning the same way.
|
||||
|
||||
## A property the wording depends on
|
||||
|
||||
`potBreakdown.test.js` covers how the pot breakdown says who can win each pot, and it
|
||||
leans on something `potCalculator.test.js` now proves: **pot eligibility is nested.** The
|
||||
main pot is everyone still live, and each side pot above it drops whoever was all-in
|
||||
below, so every side pot's players are a strict subset of the pot beneath it.
|
||||
|
||||
That makes "who is in" and "who is out" complements, which is what lets the line name
|
||||
whichever of the two is shorter and never spell out more than half the table. Listing
|
||||
every eligible player instead meant the main pot's line was every name at the table and
|
||||
each side pot repeated it minus one — and the lines too long to fit were exactly the ones
|
||||
nobody needed, while the short ones at the bottom were the side pots a dealer actually
|
||||
announces by name.
|
||||
|
||||
The nesting check runs 20,000 random tables, and it asserts on its own generator as well
|
||||
as on the code: if a change to the shapes stopped producing side pots, every assertion in
|
||||
the loop would be vacuous and the test would still pass, so it also requires that more
|
||||
than ten thousand of those tables had one.
|
||||
|
||||
Writing that check is where a comparison against `Set.length` went in — a `Set` has
|
||||
`.size`, so `here.length < below.length` was `2 < undefined`, which is false. As an
|
||||
assertion it failed everything; as a filter in a throwaway script it had passed
|
||||
everything, which is how it survived long enough to be written down. Both readings are
|
||||
silent. Prefer asserting the count you expect over comparing two things whose shapes you
|
||||
have not checked.
|
||||
|
||||
## Testing a fault that broke nothing
|
||||
|
||||
`sidebarPanels.test.js` reads `TexasHoldem.cshtml` rather than any JavaScript, because
|
||||
the fault it covers was not a bug. Every control worked and every id resolved. What was
|
||||
wrong was which panel a thing was in and what that panel was called.
|
||||
|
||||
The sidebar was Table Setup, **Session**, Chip Legend, Keyboard and Table Notes, and
|
||||
Session held the standings, a waiting list, the hand log and the save/load/reset buttons
|
||||
— a topic, a task, a log and three utilities under one word. It measured 1206px and 103
|
||||
visible elements against 555 and 29 for the next biggest, and "The table itself" sat
|
||||
1052px down inside it, so reaching Reset table meant opening a panel called Session and
|
||||
scrolling past the money and the log to find it.
|
||||
|
||||
Three things are asserted, and each of them is a claim a future edit could quietly undo:
|
||||
|
||||
- **The names say what is inside them.** The list is checked literally, because the
|
||||
failure mode is a word like "Session" that could hold anything.
|
||||
- **Each name is a heading.** A `<summary>` is announced as a button, so the sidebar's
|
||||
names — its whole navigation — were not reachable by heading. Splitting Session also
|
||||
removed the four subheads that had been the only headings down there, so without a
|
||||
heading in each summary the page would have come out of this with a worse outline than
|
||||
it went in with.
|
||||
- **No panel goes back to being a grab-bag.** Not a size limit — Table Setup is
|
||||
legitimately long. It counts *subjects*: a panel that needs its own headings to list
|
||||
its parts is holding more than one thing. Two subheads survive, and the test names
|
||||
them, because Settle up is the standings at the end of the night and Stacks in chips
|
||||
is the chip legend counted out — the same subject at a different moment, which is what
|
||||
a subhead is for.
|
||||
|
||||
The panels are split on the opening tag rather than parsed, which is sound only because a
|
||||
sidebar panel never contains another one; Table Setup's sub-panels carry a different
|
||||
class. That assumption is worth knowing about if one ever does.
|
||||
|
||||
## A warning is only worth having if its number is right
|
||||
|
||||
`chipLegend.test.js` covers removing a chip colour, which was one click of an unlabelled
|
||||
24px button and was never a change to a legend. `computeContribution()` prices a seat by
|
||||
running the legend over that seat's chip counts, so a colour that leaves takes every chip
|
||||
counted in it off the table. Measured in a browser on a three-handed table: deleting the
|
||||
$500 row took **$22,500 of $26,874** with it, with no confirmation, nothing said
|
||||
afterwards, and no way back — a colour added again is a new colour with a new id, while
|
||||
the counts stay keyed to the one that left.
|
||||
|
||||
Most of the file is about the number in the warning rather than the removal, because a
|
||||
warning that overstates is worse than none. The obvious way to work it out — chips times
|
||||
value — is wrong in **both** directions:
|
||||
|
||||
- a seat with a typed amount is priced from that and its counts are never read, so a
|
||||
colour it holds is worth nothing to it;
|
||||
- a seat whose chips all leave falls through to the table's standing ante rather than to
|
||||
zero, so it loses less than its chips were worth.
|
||||
|
||||
So the figure is the difference between pricing every seat with the colour and without
|
||||
it, and a randomised check across 2,000 tables asserts that what the warning claims is
|
||||
exactly what the table then does. That check found a third case nobody would have written
|
||||
by hand: fourteen $1 chips against a $200 ante, where removing the colour puts the table
|
||||
**up** $186. The wording says so in that direction too.
|
||||
|
||||
The friction is conditional on purpose. A colour nobody has counted deletes on one click;
|
||||
only one that is holding money asks. A confirmation that fires every time is one nobody
|
||||
reads by the third night, and this is a tool where four other confirmations already
|
||||
compete for that attention.
|
||||
|
||||
## Every control says what it is
|
||||
|
||||
`uiAudit.js` gained `controlNames()`, which is the general form of the bug above: the ×
|
||||
had no accessible name, so it was announced as a button and nothing else — five of them
|
||||
in a column, identical, each deleting a different colour.
|
||||
|
||||
It treats a symbol as no name even though `×` is technically text. The rule is that a
|
||||
name has to contain a letter or a digit, which is the difference between "×" and "Undo".
|
||||
Names are resolved the way the accessibility tree does it — `aria-label`, then
|
||||
`aria-labelledby`, then a field's wrapping or pointing `<label>`, then placeholder, then
|
||||
text content, then an image's `alt`, then `title` — and anything under `aria-hidden` is
|
||||
skipped rather than judged, since it is not in the tree to begin with.
|
||||
|
||||
Removing the one `aria-label` this round added makes it report all five buttons, which is
|
||||
how it was checked.
|
||||
|
||||
## Where the keyboard lands
|
||||
|
||||
Two checks in `uiAudit.js` cover WCAG 2.2 SC 2.4.11, which says a focused control must
|
||||
not be completely hidden by something the page has put on top of it. Tabbing the whole
|
||||
page on a 390x844 phone found **16 stops resting completely covered and 27 more partly**,
|
||||
under the action bar; on the desktop, four more under a toast.
|
||||
|
||||
They are checked as the fix rather than as the symptom, because the symptom needs a
|
||||
keyboard and this file has no automation of its own:
|
||||
|
||||
- **`focusHasRoom()`** — `scroll-padding-bottom` on the scroll container has to reserve at
|
||||
least as much room as the fixed furniture standing at the bottom of the window. Only
|
||||
furniture crossing the middle of the screen counts, or the rail a landscape phone puts
|
||||
down the right-hand side would demand scroll padding the height of the whole window;
|
||||
and sticky elements inside a panel that scrolls on its own are excluded, because
|
||||
scroll-padding on the document would do nothing about them.
|
||||
- **`focusablesReachable()`** — nothing focusable may sit entirely outside the viewport
|
||||
inside a `position: fixed` subtree, since scrolling can never bring it back. The seat
|
||||
editor's bottom sheet was put away with `transform: translateY(105%)`, which moves a
|
||||
thing without putting it away: its Done button sat at y=850 on an 844px screen, still in
|
||||
the tab order and still on the accessibility tree. `visibility: hidden` puts it away.
|
||||
|
||||
### Half a fix looks exactly like a whole one
|
||||
|
||||
`scroll-padding-bottom` alone was written up as the answer and it is only half of it. It
|
||||
applies when the browser has to scroll to reach the control at all; a control the browser
|
||||
already considers visible is one it will not scroll to, and a fixed overlay on top of it
|
||||
does not enter into that decision. Measured: tabbing to Common rulings with a toast up
|
||||
left it 100% covered, and `scrollIntoView({block: "nearest"})` moved nothing, because
|
||||
"nearest" was already satisfied. The sweep had shown clean only because 89 tab presses
|
||||
outlast an eight-second toast.
|
||||
|
||||
So the other half is a `focusin` handler that measures the overlap and scrolls by it —
|
||||
and, when the page has no room left to scroll (26px available against 94px wanted, at
|
||||
1280x900), dismisses the toast instead. Sizing the page's bottom padding for the toast as
|
||||
well would have fixed it by making the page grow and shrink by 64px every time anything
|
||||
was announced, which is worse than the fault.
|
||||
|
||||
## A sweep only measures the states it reaches
|
||||
|
||||
The action bar is fixed over the felt, so its height is the felt's budget — and the bar
|
||||
carries the side-pot breakdown, which drew one row per all-in with no ceiling. Measured
|
||||
on a 360x640 phone: five players idle put the bar at **151px**, six all-in at **303px**,
|
||||
and twelve all-in at **411px** — 64% of the screen, leaving **96px of felt** to draw
|
||||
twelve seats in.
|
||||
|
||||
Nothing here could see it, and the reason is worth writing down. Every check in
|
||||
`uiAudit.js` asks whether some element is covered, clipped or unreachable; not one asked
|
||||
how much room the covering element had taken. But the deeper miss was the sweep itself:
|
||||
all 294 audits drove their table with `Call` and `Check`, so **no table in the history of
|
||||
this file ever had a player all-in**, and the one thing on the bar whose height depends
|
||||
on the table was empty in every single measurement. `runUiAudit.js` now has a fourth
|
||||
shape, twelve players shoved in for twelve different amounts — there is no one-tap shove,
|
||||
so it takes the raise pad's All-in preset and confirms, or `Call all-in` for anyone who
|
||||
cannot cover.
|
||||
|
||||
- **`furnitureBudget()`** — the fixed bottom band, measured against the window. Failing
|
||||
above **50%**, which is the line that needs no taste to defend: furniture larger than
|
||||
the content it serves is a shape fault whatever it is showing. Warning above **30%**,
|
||||
the published guidance for persistent chrome, which the shortest phone does not meet at
|
||||
rest — the bar is 151px of a 640px screen before anything happens — and a sweep that is
|
||||
always red stops being read.
|
||||
|
||||
### Cutting the middle out of a list, and which end to keep
|
||||
|
||||
The cap is four layers, and past it the bar draws the main pot, one line for everything
|
||||
between, and the newest side pot. The two ends are not an arbitrary choice: side pots are
|
||||
paid **from the newest one back down to the main pot**, so the top layer is the next thing
|
||||
that happens and the main pot is the last, and those are exactly the two a live dealer
|
||||
says out loud while the chips are being stacked — *"main pot 7,200, side pot 4,500"*. The
|
||||
layers between are the middle of that count-down, wanted at showdown, which is the pot
|
||||
wizard — the same builder, passing no cap, in a dialog that scrolls.
|
||||
|
||||
`potBreakdownRoom.test.js` checks the parts that could go wrong quietly:
|
||||
|
||||
- the row count is a function of the cap, not of the table — eight, nine, ten, eleven and
|
||||
twelve all-in all draw three lines
|
||||
- the drawn amounts still add up to every pot, so a dealer counting the announcement
|
||||
against the chips in the middle finds no shortfall the tool caused
|
||||
- the elision sits **where the gap is**, not appended after the list, so the amounts still
|
||||
read in the order the pots were made
|
||||
- it never fires when it would not buy anything: at exactly the cap the middle is one row,
|
||||
and swapping one row for one row loses an amount and gains a sentence
|
||||
|
||||
Three injected faults — the cap ignored, the summary carrying only the first elided pot's
|
||||
money, the elision appended rather than kept in place — fail 6, 1 and 5 of the 11 tests
|
||||
respectively, and the audit check reports the original 64% when the cap is taken out.
|
||||
|
||||
## Two empty rows were setting how far apart twelve seats had to sit
|
||||
|
||||
Every seat puck carries four rows: the name, the hole cards, the amount that player has
|
||||
put in, and their stack. Two of those are empty most of the time, and both held their
|
||||
space anyway.
|
||||
|
||||
The amount pill was worse than empty space. It has a background, a pill radius and side
|
||||
padding, so with no text it painted an **8x11px green lozenge** — measured on **seven of
|
||||
nine seats** at an ordinary table. One of those is a component holding its place; nine is
|
||||
a rash, and a dealer has to learn to ignore a colour that on the other two seats means
|
||||
"this player has money out".
|
||||
|
||||
The space was not free either. `seatRingRadii` sizes the ring from the tallest puck on
|
||||
it, so two rows nobody had filled were setting the spacing for the whole table:
|
||||
|
||||
| | before | after |
|
||||
| --- | --- | --- |
|
||||
| iPhone 14, 9 seats | 78x88 puck | **78x63** |
|
||||
| iPhone 14, 12 seats | 44x65 | **66x55** |
|
||||
| Pro Max, 9 seats | 84x88 | **96x63** |
|
||||
| Galaxy S8, 12 seats | 8 overlapping pairs, 1556px² | 4 pairs, **87px²** |
|
||||
|
||||
- **`emptyDecoration()`** — nothing inside `#pokerTableWrap` may paint a background or a
|
||||
border with no content in it. The rule is about paint, not space: an element may hold
|
||||
its place invisibly, and several do. With the old reservation restored it reports all
|
||||
seven pills; live it reports none.
|
||||
|
||||
The one exemption, `.betChipDisc`, is on the list because the check reported it, not
|
||||
because it was predicted — it is a 13x13px circle filled with the denomination colour of
|
||||
the chip beside it, so the colour *is* the content. Three other exemptions were written
|
||||
from memory first and matched no element on the page at all, which is the argument for
|
||||
building that list from a run.
|
||||
|
||||
### The collapse is only safe because the cache key moved
|
||||
|
||||
`resolveSeatLayout` measures ten candidate widths, each one a forced layout, and caches
|
||||
the result against `seatShapeSignature()`. That signature listed the names and the dealer
|
||||
seat — everything that could wrap a name onto a second line. It did not mention the two
|
||||
rows, because they always held their space and so could never change a puck's height.
|
||||
|
||||
Once they collapse, they can. `seatPuckShape.test.js` checks the key moves when the first
|
||||
chips go in and when a hole card is entered, and — just as important — that it does *not*
|
||||
move for the second player to bet, or when an amount merely gets wider. It is a boolean
|
||||
over the whole table because that is what the measurement is: `measuredSeatBox` takes the
|
||||
tallest puck on the ring, so what matters is whether anybody has one. A per-seat key would
|
||||
be correct and would throw away the cache on every single tap.
|
||||
|
||||
Measured on the running page: the ring settles by 24-26px when the blinds are posted —
|
||||
and it settled by **23-25px there before this round too**, because the action bar changes
|
||||
height at that moment and the felt is measured against it. The one place the ring moves
|
||||
is a place it already moved.
|
||||
|
||||
### A test that passed for the wrong reason
|
||||
|
||||
The signature joins names on a control character so that "ann" + "abel" and "anna" + "bel"
|
||||
stay distinguishable. The first version of that test used `Ann`/`Abel` against
|
||||
`Anna`/`Bel` — which can never collide whatever the separator, because the capitals land
|
||||
in different places. It passed with the separator deleted and proved nothing. Lower case
|
||||
is what makes it a test. Four faults were injected in total; the other three (dropping the
|
||||
two flags, letting the count into the key instead of a boolean, and counting only hand
|
||||
bets so a table playing antes is missed) fail 3, 2 and 1 of the eight.
|
||||
|
||||
## Room the felt had and the type never got
|
||||
|
||||
The felt grows with the window and the pucks did not — 96px was the only ceiling there
|
||||
was. Nine-handed, the seats covered 19% of the felt on a phone, 11% on a 1280px desktop
|
||||
and **8% at 1440px and beyond**, with the stack figure a dealer reads at **10px on a
|
||||
2560px monitor**, the same 10px as on a 360px phone.
|
||||
|
||||
Two things stop the fix costing more than it buys, and `seatWidthSearch.test.js` checks
|
||||
both:
|
||||
|
||||
- **Every candidate width is a forced layout.** Raising the ceiling for everybody took
|
||||
the layout pass from 24ms to 73ms twelve-handed on a desktop. A screen that cannot use
|
||||
the wider widths must not measure them, so a phone is checked to measure *exactly* the
|
||||
ten widths it measured before — same list, same order — and the measured cost on a
|
||||
phone is unchanged (10.7ms against 10.4ms, which is noise).
|
||||
- **A wide puck on a narrow felt is worse than a small one.** The first version let
|
||||
phones through, and a 144px puck on an iPhone's 278px ring is half the width of the
|
||||
table: six-handed, all six seats sat over the rail by 15px against 5px before, and it
|
||||
read as a stack of cards rather than a table with people around it.
|
||||
|
||||
So the ceiling is a fifth of the felt's **shorter** span, capped at 144. Five is measured,
|
||||
not chosen: the shorter span is 233-303px on every phone in the sweep including sideways,
|
||||
and 617-714px on tablets and desktops, so any divisor between 3.2 and 6.4 separates them.
|
||||
The test pins both ends, so if the felt ever changes shape enough to bring those together
|
||||
it says so rather than letting the phones quietly start paying.
|
||||
|
||||
Above the ordinary ceiling the puck also takes the larger type — `.seatsRoomy`, two steps
|
||||
up the scale for the two numbers read across the table and one for the chair number.
|
||||
Steps rather than a `clamp()`, for the same reason the tight set is: the width is a
|
||||
continuum and the type is a set of deliberate sizes.
|
||||
|
||||
| nine-handed | before | after |
|
||||
| --- | --- | --- |
|
||||
| iPad puck / name | 96px / 12px | **134px / 15px** |
|
||||
| desktop 1440 puck / name | 96px / 12px | **140px / 15px** |
|
||||
| seats as a share of the felt, 1440 | 8% | **12%** |
|
||||
| every phone, every seat count | unchanged | unchanged |
|
||||
|
||||
### What the rail check is actually measuring, which is not the rail
|
||||
|
||||
Found while checking the above and **not fixed** — written down here because the number is
|
||||
worth having and the fix is not a small one.
|
||||
|
||||
`#pokerTable` is `border-radius: 50%`. Both the settle pass in `render.js` and
|
||||
`seatsOnTheTable()` in the audit compare a puck against the four edges of the table's
|
||||
**bounding box**, which is a different and much slacker question than the one the message
|
||||
"hangs Npx past the rail" claims to answer: at the top and bottom of the ring the oval
|
||||
curves away from its own box, so a puck can be comfortably inside the box and visibly
|
||||
outside the felt.
|
||||
|
||||
Measured against the ellipse instead — a point is outside when `(dx/a)² + (dy/b)² > 1`,
|
||||
and the ray from the centre crosses the boundary at `1/√t` of the way out — across 35
|
||||
configurations the check is seeing **303px of overhang as zero**, including **46px on a
|
||||
phone held sideways** that has been there for as long as the check has.
|
||||
|
||||
Settling against the ellipse was tried and reverted, because it is not the local fix it
|
||||
looks like. Pulling the ring in far enough to clear the oval crowds the seats, and on a
|
||||
twelve-handed phone with everybody all-in it put seat 3's centre inside seat 4 — the one
|
||||
trade the width search explicitly refuses to make, since a dealer can see a puck over the
|
||||
rail but cannot see a tap answering for the wrong player. Guarding against that instead
|
||||
leaves the ring out and the overhang worse: the sweep went from 6 failing configurations
|
||||
to 77, with 355 rail reports.
|
||||
|
||||
Which is the finding. On a crowded table **keeping the seats off the rail and keeping them
|
||||
off each other are in genuine conflict**, and resolving it needs the two settled together
|
||||
rather than one bolted onto the other.
|
||||
|
||||
### And then measured again, and dropped
|
||||
|
||||
Re-probed across 40 configurations — four table shapes at ten viewports — the gap is real
|
||||
and systematic: **31 of 40 sit further past the oval than the box says**, worst 34px
|
||||
against a box overhang of 0 everywhere. The 303px above was the all-in shape, which is the
|
||||
extreme rather than the case.
|
||||
|
||||
Then it was looked at rather than measured, and 12–17px of a rounded puck's corner
|
||||
crossing the rail reads as players *at* a rail — which is what the thing is a picture of.
|
||||
The measurement gap is worth writing down and the appearance is not worth a round, so the
|
||||
lead is closed rather than carried forward. Worth the re-measure to find that out: the
|
||||
number that made it look urgent was the worst cell in the table, not a typical one.
|
||||
|
||||
## Eight rows that said only their own names
|
||||
|
||||
The sidebar is eight `<details>`, and every one measured exactly 60px shut with nothing on
|
||||
it but its label — 480px of a phone's screen, and 480px of a 660px desktop column, spent
|
||||
saying "Standings", "Hands", "Waiting list". Whether the box balances, whether anyone is
|
||||
waiting for a seat, how many hands have been played: the page had **all three worked out
|
||||
already**, and charged a tap for each of them, on rows two thirds of which were empty.
|
||||
|
||||
That is the disclosure pattern's named failure — a header you have to open to find out
|
||||
whether it was worth opening — and the answer the design systems give is the same one:
|
||||
put the status on the header. So each shut panel now carries the one fact you would have
|
||||
opened it for, and the panel keeps the sentence. "Off by $300" is the alarm; *which seat
|
||||
is holding chips nobody wrote down* is a paragraph, and that is the half that belongs
|
||||
behind the tap.
|
||||
|
||||
Two rules hold it together, and `panelGlance.test.js` checks both.
|
||||
|
||||
**A glance appears only when it has something a dealer would act on.** Eight rows that
|
||||
always say something are eight rows that say nothing again, and a row reading "Nobody
|
||||
waiting" all night is the clutter this went in to remove. So no waiting list, no notes and
|
||||
no hands yet are all silence, and the bank check stays quiet over a table nobody has
|
||||
bought into — which is what the panel does with its own check, for the same reason.
|
||||
|
||||
**A glance never says something the panel under it would contradict.** It is read off the
|
||||
same state, never off the panel's DOM — a shut `<details>` has no rendered body to read —
|
||||
and one test walks three buy-in figures asserting the glance's colour agrees with
|
||||
`bankReconciliation`'s verdict and its number carries the same difference.
|
||||
|
||||
The layout half is `glancesFitTheirRows` in the audit, and it took three goes.
|
||||
|
||||
The first version asked only whether a row had grown taller than a row with nothing on it,
|
||||
which sounds like the whole question and is not: a glance floated back into flow and
|
||||
allowed to wrap **sailed straight through it**, because a float does not expand the box it
|
||||
sits in — two lines of note hung out of a row that still measured exactly 44px. Asking
|
||||
where the glance actually ended up, in both axes, is the question that has an answer.
|
||||
|
||||
The second version was wrong in the other direction, and **the sweep caught it on its
|
||||
first clean run**: 94 failing configurations, 91 of the offenders this check's own. It
|
||||
takes the shortest row in the sidebar as its idea of a bare one, and the Keyboard panel is
|
||||
`display: none` under `pointer: coarse` — a touch device has no keys to press — so on
|
||||
every viewport visited with a thumb the bar was a row that wasn't drawn at all, 0px, and
|
||||
all seven real rows read as grown. Seven rows times thirteen viewports is exactly the 91.
|
||||
Rows that aren't rendered aren't rows.
|
||||
|
||||
Which is the point of running the sweep against a check as well as against the page. A
|
||||
narrowing fix has to be shown not to have blinded the thing it quietened, so the four
|
||||
broken versions are re-run **with the Keyboard panel hidden** — the condition that was
|
||||
breaking it — and each is still caught, while the unbroken page stays clean.
|
||||
|
||||
## Half a feature, and the half that was missing
|
||||
|
||||
The card feature has always had two halves. Turn on the setting and every street asks
|
||||
which cards came off the deck, and puts them on the felt. That half worked, on the
|
||||
phone and on the desk, in a normal hand and in an all-in run-out.
|
||||
|
||||
The other half never got asked for at all. A dealer who entered all five community
|
||||
cards, pressed **Showdown — resolve pot**, and got a wizard that said
|
||||
|
||||
> Christopher, Mike from work, Alexandra and Bob haven't turned over, so who won is
|
||||
> still yours to say.
|
||||
|
||||
with nothing anywhere in that sheet to turn anybody over with. The only route in was to
|
||||
close the wizard, tap a seat puck, find the two card slots in the seat editor, do it
|
||||
four times, and re-open the wizard — with the pot on the table and four people waiting.
|
||||
The one moment the tool is certain to know the hands is the moment it stopped asking.
|
||||
|
||||
So the showdown button now asks, exactly the way the street button does: the sheet opens
|
||||
on the first player, takes two cards, and moves to the next. `showdownCards.test.js` is
|
||||
the list it asks from — who is on it and in what order — and three rules hold it
|
||||
together, one test each way:
|
||||
|
||||
**Only when the dealer asked.** The setting is off by default, and with it off the list
|
||||
is empty. A table that never wanted this never sees a sheet.
|
||||
|
||||
**Only what isn't known.** A hand already entered is an answer already given. Asking
|
||||
again offers to overwrite it, so an entered hand drops out of the list and the rest keep
|
||||
their order.
|
||||
|
||||
**In the order the cards come over.** The bar already announces "Alexandra shows first —
|
||||
last to bet or raise" a line above the button. Asking about somebody else first would be
|
||||
the tool contradicting itself in the same breath, so the list starts where the bar says
|
||||
and goes round the table from there.
|
||||
|
||||
### The sheet has to be leavable, and leaving it has to mean something
|
||||
|
||||
Escape, Done and a tap on the scrim all closed the picker already. What they didn't have
|
||||
was a way to say *why*, and a chain of four sheets needs to know the difference: a sheet
|
||||
that filled its last slot means "next player", and a dealer closing one means "stop".
|
||||
|
||||
So `close()` takes a reason. Two callers pass nothing and don't care. The showdown chain
|
||||
does, and it stops rather than skipping — a dealer who closes the sheet is saying the
|
||||
tool has the wrong end of the stick, and the answer to that is to get out of the way, not
|
||||
to put the same sheet in front of them three more times.
|
||||
|
||||
Stopping early is not a dead end, which is the part worth checking by hand. The wizard
|
||||
still names whoever didn't turn over; closing it leaves **Showdown — resolve pot** on the
|
||||
bar; and pressing it again resumes at the player the dealer stopped on, because the list
|
||||
is computed from the cards on the table rather than remembered from the last attempt.
|
||||
|
||||
## A scroll was an edit, and it reached the saved table
|
||||
|
||||
The worst bugs in this tool are the ones that move money without saying so, and this one
|
||||
was triggered by reaching for another panel.
|
||||
|
||||
A focused `<input type="number">` is a spinbutton, and a browser lets the mouse wheel
|
||||
drive it. Measured on the running page at 1280x900 with the sidebar open:
|
||||
|
||||
| gesture | before | after | saved? |
|
||||
|---|---|---|---|
|
||||
| focus the small blind, scroll up | $5 | **$5.50** | yes |
|
||||
| focus a seat's stack, scroll up | $500 | **$501** | yes |
|
||||
|
||||
Both go straight through to `localStorage`, because these fields save on `input` like
|
||||
everything else here. Nothing is announced, nothing looks wrong, and the tool has
|
||||
invented a chip — on a table where the whole point is that the chip count is right.
|
||||
|
||||
The rule that fixes it is narrow on purpose. Every *other* way of driving a spinbutton is
|
||||
deliberate: the arrow keys, the stepper arrows, typing. Scrolling is the one actuation
|
||||
nobody means, so it is the only one taken away. Verified after the change: the value
|
||||
holds, one ArrowUp still moves it, typing still works, **and the page still scrolls** —
|
||||
0 to 300px, which matters, because the obvious fix does not.
|
||||
|
||||
### Why it blurs instead of cancelling
|
||||
|
||||
`preventDefault()` on the wheel stops the value moving and stops the page moving with it.
|
||||
That turns a silent bug into a sidebar that mysteriously refuses to scroll, which is a
|
||||
worse trade: the dealer now has a problem they can see and can't explain, instead of one
|
||||
they can't see. Dropping focus lets the scroll do exactly what was asked and leaves the
|
||||
number alone.
|
||||
|
||||
### One guard that was checked and found to be decoration
|
||||
|
||||
The predicate starts with `field.tagName !== "INPUT"`. Injecting a fault into it — taking
|
||||
it out entirely — changed nothing: every test still passed, because nothing but an
|
||||
`<input>` carries `type="number"`, so the type check below it already excludes every
|
||||
textarea, select and button on the page.
|
||||
|
||||
That is written into the comment rather than quietly kept. A guard that cannot fail is
|
||||
fine to leave in for symmetry with `selectsOnFocus` beside it; a guard that cannot fail
|
||||
and is *presented* as load-bearing is how the next person wastes an afternoon.
|
||||
|
||||
The page's one `<select>` — which way the blinds move — was the obvious next suspect and
|
||||
is not one: focused, hovered and scrolled 300px in Chromium it stays on "clockwise". So
|
||||
it is left out of the rule rather than defended against, and the check keeps meaning
|
||||
exactly one thing.
|
||||
|
||||
## The motion setting was honoured in the three places it mattered least
|
||||
|
||||
The stylesheet had answered `prefers-reduced-motion` for a while. All three places it did
|
||||
were the wrong size of thing: a 2px press-down on a button, a checkbox's tick, and the
|
||||
acting seat's pulse. What none of them covered was motion that moves something *across
|
||||
the screen* — and for anyone with a vestibular disorder that is the part that matters. A
|
||||
button dipping under a thumb is not what makes people ill.
|
||||
|
||||
Four things were doing it, and the three largest were in JavaScript, where a stylesheet
|
||||
cannot reach:
|
||||
|
||||
- the seat editor, a bottom sheet that slides the full height of itself up from
|
||||
off-screen every time a dealer taps a chair, which on a busy table is most of the taps
|
||||
they make all night;
|
||||
- the toast, sliding in from below;
|
||||
- and three `scrollIntoView({ behavior: "smooth" })` calls — the most provocative of the
|
||||
lot, because what moves is the whole viewport rather than one control.
|
||||
|
||||
The scroll calls now go through `Render.bringIntoView`, which reads the setting per call
|
||||
rather than at page load: a dealer who turns it on mid-session should be taken seriously
|
||||
without reloading, and the table is a thing they cannot afford to reload lightly.
|
||||
|
||||
The toast keeps its fade and loses its travel. Deleting the animation outright is the
|
||||
wrong answer — something that appears already in place reads as a rendering glitch rather
|
||||
than as news — and the guidance that says to cut movement says to keep *something*.
|
||||
|
||||
The sheet needed one more thing than the obvious. Its `visibility` transition carries a
|
||||
0.25s delay so the slide-out stays on screen for its own animation; with the slide gone,
|
||||
that same delay leaves a sheet that has already jumped away still painted for a quarter
|
||||
of a second. It flips at once under reduced motion, in both directions.
|
||||
|
||||
### A list whose membership was arbitrary, because it was solving another problem
|
||||
|
||||
The press-down block held nine `:active` selectors and looked deliberate. It wasn't. It
|
||||
had been written to stop a *stuck press state* when a tap turns into a scroll, and it
|
||||
reached for the reduced-motion query as the nearest available proxy — so it covered
|
||||
`.dealerChip` and `.raiseQuickBtn` while missing `.actionBtn`, the class on Fold, Call,
|
||||
Raise and Post blinds & deal, which is to say the most-tapped control in the tool.
|
||||
|
||||
Read as its own rule, membership is not a judgement call: **every `:hover` and `:active`
|
||||
transform in the file belongs there.** There are sixteen. Nine were listed. The other
|
||||
seven are now.
|
||||
|
||||
### The scan that found the last three, and the injection that found the scan
|
||||
|
||||
The general test scans the stylesheet for pointer transforms and fails on any without an
|
||||
escape, so a new one cannot be added without the block being updated. It took two goes,
|
||||
and both faults are worth keeping written down.
|
||||
|
||||
The first version scanned line by line: remember the last line ending in `{`, then look
|
||||
for lines starting with `transform:`. A deliberately broken version was run at it — a new
|
||||
hover lift, uncovered — and the scan **passed**. The rule had been written on one line,
|
||||
so the selector and the declaration were in the same string and neither pattern matched.
|
||||
|
||||
Rewritten to parse blocks, it caught that. It also immediately failed on the *clean*
|
||||
file, which is how the last three came to light: a multi-line comma-separated group had
|
||||
been truncated to its final line by the old scan, so `.toastAction`, `.toastClose` and
|
||||
`.potWizardChoice` had never been looked at by anything. A grouped rule is nine separate
|
||||
promises, and it is now split on commas and checked one at a time — comparing the joined
|
||||
string against the block would only have passed if both lists happened to be written in
|
||||
the same order.
|
||||
|
||||
## Two pixels, and every seat on the felt paid for them
|
||||
|
||||
The sweep had carried the same residual for several rounds: on the two smallest phones
|
||||
with twelve players, seats painted over each other's names and stacks. It read like a
|
||||
geometry problem — twelve pucks, not enough rim — and the layout code had already been
|
||||
round that loop twice, deriving the ring from the felt and searching for a puck width
|
||||
that clears.
|
||||
|
||||
It was a wrapped label.
|
||||
|
||||
`"ALL IN"` is the only thing that ever lands on the stack line with a space in it. A
|
||||
stack figure has no spaces, so it can only overflow, and the `puckAmountLong` and
|
||||
`puckAmountHuge` sizes exist to stop it doing that. Nothing had ever asked what the line
|
||||
does when it *can* break. Measured at 360x640 with twelve players all-in: it wanted 36px
|
||||
and had 34, so it broke onto a second line, and an 11px line became 22.
|
||||
|
||||
That is not a typography complaint, which is why it survived so long — it looks fine.
|
||||
The puck went from 55px tall to 66, and puck height is precisely what the ring is short
|
||||
of. At the ends of an ellipse consecutive seats are separated *vertically*, so a puck
|
||||
11px taller is a puck 11px further into its neighbour. The layout search then did exactly
|
||||
what it is written to do: tried every width it has, found none that cleared, and fell
|
||||
back to the narrowest — which, being the narrowest, is also the one that wraps hardest.
|
||||
|
||||
So the fix is the rule every design system already gives a badge: one line, always.
|
||||
|
||||
### Not wrapping is only half an answer
|
||||
|
||||
A line that cannot wrap and still does not fit paints over the seat next door instead —
|
||||
the same failure, arriving sideways. So the mark has to actually *fit* 34px, and it
|
||||
wanted 36.
|
||||
|
||||
Of the three things that separate "ALL IN" from a grey money figure at a glance — a warm
|
||||
colour, a bold weight, and wider letters — the letter-spacing is the one that costs
|
||||
pixels: 3.6px of the 36. On the tight tier it goes, and the other two stay. Measured
|
||||
after: 33px of ink in a 34px box on a Galaxy S8, and the full spacing everywhere else,
|
||||
because no other puck is short of the room.
|
||||
|
||||
The three of them are pinned in `allInMarker.test.js` against the stylesheet itself —
|
||||
including the assertion that the *ordinary* spacing still doesn't fit, so that if someone
|
||||
later widens the puck and makes the whole problem moot, the test says it is measuring the
|
||||
wrong thing rather than passing quietly.
|
||||
|
||||
### And the bar was standing on the felt while nobody could act
|
||||
|
||||
Halving the overlap was not clearing it, and the rest of the shortage was not on the
|
||||
felt at all. The sweep had been saying so for rounds, in a warning rather than a failure:
|
||||
at 360x640 with twelve players all-in the action bar was **267px of a 640px window — 42%**,
|
||||
against its own "persistent chrome under 30%".
|
||||
|
||||
Two things in it were being shown at a moment when they could not be used.
|
||||
|
||||
The **side-pot list** is decision support. A player working out whether to call is owed
|
||||
the side pot they would be calling into, and that is worth three rows of a fixed bar.
|
||||
Once everyone is all-in there is no decision left to support — the only thing that
|
||||
happens for the rest of the hand is the dealer turning cards — and the list went on
|
||||
standing there. During a run-out it now collapses to the main pot, which is the number a
|
||||
dealer says out loud, and one line for everything else, which is what the pot wizard is
|
||||
for and what that row has always said in as many words.
|
||||
|
||||
The **status row** said the player count twice in the same breath: a `12 players` chip,
|
||||
and then `All-in — running out the board (12 players)` immediately to its right. That
|
||||
reads like two different figures being quoted, and on a 360px phone it cost the sentence
|
||||
a second line.
|
||||
|
||||
Measured across the three, on the table shape with the most seats to fit:
|
||||
|
||||
| | before | after |
|
||||
|---|---|---|
|
||||
| Bar, Galaxy S8 | 267px (42%) | 249px (39%) |
|
||||
| Bar, iPhone SE | 267px (40%) | 227px (34%) |
|
||||
| Bar, Pixel 7 | 267px (32%) | 227px (25%) |
|
||||
| Felt height, Galaxy S8 | 192px | 210px |
|
||||
| Felt height, iPhone SE | 243px | 283px |
|
||||
| Worst seat overlap, iPhone SE | 34px | 4px |
|
||||
| Worst seat overlap, Galaxy S8 | 34px | 17px |
|
||||
|
||||
The summary row is held to the same rule as the elision above it, and it is the rule that
|
||||
matters most for anything that hides a number: **it must not lose a chip.** A dealer
|
||||
counting the announcement against the chips in the middle of the table must not find a
|
||||
shortfall the tool invented, so every row count is checked to add up to the pots it
|
||||
stands for, at every table size from three players to twelve.
|
||||
|
||||
### What is left, and why it is a decision rather than a fix
|
||||
|
||||
**315 of 320 audits pass.** The five that don't are all one root cause and all on the two
|
||||
smallest screens in the sweep:
|
||||
|
||||
- `Galaxy S8 · all in` (360x640), twelve players, every one of them all-in at once
|
||||
- `Galaxy S8 · packed · raise pad` (360x640), twelve players with the pad open
|
||||
- `iPhone SE · all in` (375x667), twelve all-in, on the mouse pass only
|
||||
|
||||
The twelve-handed table at 360x640 now passes with the betting live. It fails in the two
|
||||
states that make the bar tallest, and there the arithmetic is simply short: the ring has
|
||||
about 44px of rim per seat and a puck's footprint is 60. No width in the search clears
|
||||
that, which is why `resolveSeatLayout` has a documented fallback instead of pretending —
|
||||
it keeps the narrowest puck on the widest ring and lets this sweep say so out loud.
|
||||
|
||||
The remaining chip offenders are the same shortage seen from the other layer.
|
||||
`anchoredChipTravel` walks a chip back off a stranger's puck a pixel at a time, and stops
|
||||
at its own puck's edge because there is nowhere further to go. When the pucks themselves
|
||||
overlap, travel reaches zero and the chip is still under a neighbour.
|
||||
|
||||
Getting the last five to pass is a design decision and not another measurement: dropping
|
||||
the bet chips at extreme crowding the way the tight tier already drops the hole cards,
|
||||
capping the seat count by viewport, or letting the felt scroll. Each trades something a
|
||||
dealer can see, so none of them should be picked by whoever is next holding a ruler.
|
||||
|
||||
## The contrast check was passing text nobody could read
|
||||
|
||||
The layout sweep has checked WCAG AA contrast for a long time and the page passed it.
|
||||
That result was worth less than it looked, for two separate reasons.
|
||||
|
||||
The first is the instrument. WCAG 2's contrast ratio is a luminance gap, and a luminance
|
||||
gap overstates readability once one of the two colours is near black — which is every
|
||||
reading taken on this page, whose ground is `#141414` and whose panels are darker than
|
||||
its rows. This is not a quibble about the spec; it is the spec's own working group's
|
||||
position, and it is why APCA exists. Tuning a colour until it clears 4.5:1 on a
|
||||
near-black ground lands on text that passes the audit and still reads as a smudge across
|
||||
a table in a dim room. Several rules in the stylesheet said so in their own comments —
|
||||
one of them "measured 4.5:1 at `#a5a5a5` exactly, so it sits above that" — which is the
|
||||
right instinct aimed through the wrong lens.
|
||||
|
||||
The second is that the check could not see half the backgrounds it was scoring against.
|
||||
It reads `background-color` and walks up the ancestors, so a background painted by a
|
||||
gradient is invisible to it; and where several ancestors are candidates it takes *the
|
||||
best* contrast of any of them. Put together, text on the felt was being scored against
|
||||
the dark page behind the felt rather than the felt. Two plain AA failures had been
|
||||
sitting in a clean report for as long as the check has existed: the chair number on an
|
||||
empty seat, on the table's brown rim, at 2.65:1, and the pot's own label, gold on green,
|
||||
at 2.66:1.
|
||||
|
||||
`runContrastAudit.js` is the answer to both. It scores with APCA, and it gets the
|
||||
background by screenshotting the page with every piece of text made transparent and
|
||||
reading the pixels underneath — which cannot be fooled by a gradient, a shadow or a
|
||||
border, because it is looking at what was painted. The first version of it *did* walk the
|
||||
DOM, and reported the gold dealer button as black-on-black at Lc 0.4; an element anyone
|
||||
can see is legible. That false positive is why the tool works the way it does.
|
||||
|
||||
What it found, on 92 distinct pieces of text: 65 below the level APCA sets for the size
|
||||
and weight they are drawn at, **54 of them passing WCAG AA at the same moment**. The
|
||||
worst was Lc 28. After this round nothing on the page is below Lc 60, nothing fails AA,
|
||||
and the worst reading is Lc 60.4.
|
||||
|
||||
Three of the fixes are worth naming, because none of them is "make the grey lighter":
|
||||
|
||||
- **A player's stack was styled as secondary text.** Muted grey at Lc 44, under a name
|
||||
set in `--text` at a heavier weight. Read back the other way that says a dealer needs
|
||||
the name more than the number, and during a hand the opposite is true — the name is
|
||||
looked up once to find the chair, the stack is read on every action because it decides
|
||||
whether a player can cover the bet. Hierarchy now comes from size and weight, which is
|
||||
free, instead of from fading, which on a dark ground is the most expensive way to say
|
||||
"secondary".
|
||||
- **An empty seat had no background of its own**, just a 2%-white wash at 0.7 opacity, so
|
||||
whatever the felt was doing behind it decided how readable its number was: Lc 28 on the
|
||||
rim, 30 on the dark green, 33 further in. Contrast that depends on where a seat happens
|
||||
to sit cannot be reasoned about. It has a ground now. The wash is 0.62 and not the 0.82
|
||||
it was first written at, because contrast turns out to be nearly flat across that range
|
||||
— two points of Lc — and the heavier one put four dark rectangles on the felt like
|
||||
holes.
|
||||
- **The pot's label sat on the one green in the design.** `--gold` is tuned for near-black
|
||||
and measures Lc 70-odd everywhere else it is used; on the pot disc it was Lc 38. The
|
||||
disc is deeper now rather than the label bleached, because gold on dark green is the
|
||||
pairing the whole page is built on. Deepening only the disc's outer stop moved it two
|
||||
points and no further — the highlight is at 32%/28% and that is exactly where the label
|
||||
sits, so the reading was being taken on the brightest part of the disc.
|
||||
|
||||
### What is deliberately not fixed
|
||||
|
||||
APCA asks Lc 90 of anything at 12px/400 or below, and this page is full of 10px and 11px
|
||||
labels that live inside a 58px seat puck. Fifty-five pieces of text are still below that
|
||||
bar. Reaching it means white micro-labels and a flatter, shoutier page; the honest way to
|
||||
clear it is bigger type, and bigger type is a layout change that would relitigate the
|
||||
seat-crowding work above. So the sweep reports two numbers — the strict per-size level,
|
||||
and the Lc 60 / Lc 75 floors this page actually holds itself to — and only the floors are
|
||||
treated as pass/fail.
|
||||
|
||||
## The failures that only happen at the table
|
||||
|
||||
Everything above this line is about a tool being easy to read and quick to press. This
|
||||
section is about it being *there* — the three ways it could let a dealer down on the
|
||||
night that no amount of layout work would have caught, because none of them show up on a
|
||||
developer's desk with a good connection and a plugged-in laptop.
|
||||
|
||||
### One spelling of the URL, because the offline promise depends on it
|
||||
|
||||
The manifest says "Works with no signal", and it did — at `/casino/texasholdem`.
|
||||
|
||||
Razor Pages routing is case-insensitive, so `/Casino/TexasHoldem` serves the identical
|
||||
page. A service worker's scope is not: the spec matches it as a plain string prefix, so a
|
||||
worker scoped to `/casino/` does not control a page at `/Casino/TexasHoldem`. Measured on
|
||||
the capital-C spelling, with the network cut:
|
||||
|
||||
| | registration | controlling | cached | offline |
|
||||
|---|---|---|---|---|
|
||||
| `/casino/texasholdem` | created | yes | 25 entries | loads |
|
||||
| `/Casino/TexasHoldem` | created, active | **no** | **nothing at all** | `ERR_INTERNET_DISCONNECTED` |
|
||||
|
||||
The empty cache is the part that matters, and it took a second look to understand. The
|
||||
registration *succeeded*. The worker *activated*. Nothing was logged. But `registerWorker`
|
||||
handed its precache list to `navigator.serviceWorker.ready`, and `ready` resolves with the
|
||||
registration **controlling this page** — so on a page outside the scope it never resolves,
|
||||
the message is never sent, and not one file is stored. A dealer who had opened that URL to
|
||||
"load the tool for tonight" had loaded nothing, and had no way to find out until the
|
||||
signal went.
|
||||
|
||||
So one spelling wins: a permanent redirect in `Program.cs`, scoped to `/casino` rather
|
||||
than applied site-wide, because every file under `wwwroot/casino` and
|
||||
`wwwroot/images/casino` is already lowercase and so lowercasing a path there cannot miss a
|
||||
real file on a case-sensitive filesystem. That guarantee does not hold for the rest of the
|
||||
site, and `/chessHub` in particular would break.
|
||||
|
||||
The precache handshake was fixed too, rather than left to be saved by the redirect. It
|
||||
now takes the worker off the registration itself — `installing || waiting || active` —
|
||||
and waits for `activated` on that. A tool whose offline support silently depends on being
|
||||
in scope is one URL away from having none.
|
||||
|
||||
### And what the document never mentions
|
||||
|
||||
The shell list is read off the DOM on purpose, so it can't drift from the markup. The
|
||||
icons are the one part of the shell the DOM doesn't mention: the browser reads them out of
|
||||
the manifest, so no tag in the document points at them and the sweep missed all four. An
|
||||
installed app launched with no signal had no icon to show for itself.
|
||||
|
||||
They're read out of the manifest now — not typed into a list here, for exactly the same
|
||||
reason. iOS is the exception: it reads none of the manifest's icons and only
|
||||
`<link rel="apple-touch-icon">`, which *is* in the document, so the sweep picks up the
|
||||
stylesheet, icon and apple-touch links together. Twenty-five entries, all four icons.
|
||||
|
||||
Reading the manifest offline is deliberately cache-first, which is backwards from how the
|
||||
rest of the worker thinks. A plain `fetch` for it failed on every offline launch and
|
||||
logged `ERR_INTERNET_DISCONNECTED` — noise on the one screen where a real error has to be
|
||||
visible, in exchange for re-reading four paths that were already cached.
|
||||
|
||||
### A lock is easy to take and easy to leave holding
|
||||
|
||||
A dealer's phone lies face-up on the felt all night, and dims and locks between the flop
|
||||
and the turn every hand. The Screen Wake Lock API fixes that in about four lines, and the
|
||||
risk runs entirely the other way: a lock that is taken and *not* dropped burns the battery
|
||||
through a break, a re-buy, and twenty minutes of arguing about pizza, with nothing on
|
||||
screen to say why.
|
||||
|
||||
So the rule is narrow — held only while betting is actually open — and the tests are
|
||||
mostly about letting go:
|
||||
|
||||
- **A whole street of renders takes exactly one.** `sync()` runs off `renderAll`, which
|
||||
fires many times a second while a street is being bet.
|
||||
- **Two syncs in the same tick take exactly one.** Without the `pending` guard both get
|
||||
past the "already holding one?" check and the second sentinel is never stored, so
|
||||
nothing can release it.
|
||||
- **A hand that ends inside the request leaves nothing held.** A fold can end the hand
|
||||
between asking for a lock and being handed one, and the sentinel that arrives afterwards
|
||||
belongs to a hand that no longer exists.
|
||||
- **The browser taking it back leaves us able to take it again.** This is not an edge
|
||||
case: the browser releases the lock itself every time the document stops being visible,
|
||||
and never gives it back. A tool that takes one at the start of the hand and assumes it
|
||||
still has it works until the dealer checks a rule on their phone.
|
||||
|
||||
#### A fake that wasn't installed, and four tests that agreed
|
||||
|
||||
The first run of these had eight failures and, worse, one pass: "a browser without the API
|
||||
is left alone entirely" was green from the start.
|
||||
|
||||
`globalThis.navigator = fake` does nothing. Node has had its own `navigator` since v21,
|
||||
defined as a getter, so the assignment silently fails — no error, no effect. Every test in
|
||||
the file was measuring Node's real navigator, which has no `wakeLock`, so the acquire
|
||||
tests failed honestly and the unsupported-browser test passed for precisely the wrong
|
||||
reason. `Object.defineProperty` installs it properly.
|
||||
|
||||
Then five faults were injected and all five were caught: dropping the `pending` guard,
|
||||
keeping a lock the hand no longer wants, ignoring the browser's own release, holding it
|
||||
between hands, and reading a missing `keepScreenAwake` as off instead of on.
|
||||
|
||||
### Asking the browser to keep the night
|
||||
|
||||
"The night lives in this browser and nowhere else" was always true and always missing its
|
||||
second half. `localStorage` is evictable: a browser is free to clear site data when the
|
||||
device runs short, and Safari deletes script-created storage after seven days without a
|
||||
visit. A table saved at last month's game can simply not be there at this one, with
|
||||
nothing having gone wrong and nobody having deleted anything.
|
||||
|
||||
`navigator.storage.persist()` is the way to say this data is not that, and every browser
|
||||
wants the request to come from something the dealer did — so it fires on **Post blinds &
|
||||
deal**, the moment this stops being a setup screen and starts being a night somebody could
|
||||
lose. Chromium and Safari answer silently on their own engagement heuristics; Firefox
|
||||
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.
|
||||
|
||||
## 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
|
||||
noticing. A pot wizard that asks "who won?" is never more wrong than the dealer ticking
|
||||
the box; one that answers the question itself is wrong on its own account, silently,
|
||||
and a table that has come to trust it will not check.
|
||||
|
||||
So besides the hand-written cases — every category against every other, the wheel, three
|
||||
kickers deep, the counterfeited two pair, the board that plays — it is checked against a
|
||||
second evaluator written a different way, on twenty thousand random seven-card hands and
|
||||
four thousand random pairs of them. The second one is in the test file and shares no
|
||||
helper with the module: it scores a hand by counting ranks into a sortable list rather
|
||||
than by asking about shapes. That pass is most of the suite's running time and it is
|
||||
worth it; three deliberately introduced bugs — an ace that keeps its value in the wheel,
|
||||
a dropped two-pair kicker, a flush read only by its top card — are each caught by it as
|
||||
well as by the cases written for them.
|
||||
|
||||
## Why the randomised suite exists
|
||||
|
||||
Every correctness bug found in this tool so far has been shape-dependent: blinds posted
|
||||
|
||||
@@ -4,9 +4,23 @@
|
||||
// bet at, so the test that matters most is the last one: the seats it marks are
|
||||
// exactly the seats the betting round refuses to ask for another chip.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
|
||||
|
||||
const CSS = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
||||
|
||||
// The declarations inside one rule, by its exact selector.
|
||||
const ruleBody = selector => {
|
||||
const at = CSS.indexOf(`\n${selector} {`);
|
||||
if (at < 0) return null;
|
||||
const open = CSS.indexOf("{", at);
|
||||
return CSS.slice(open + 1, CSS.indexOf("}", open));
|
||||
};
|
||||
|
||||
test("a player who moved all-in is marked", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 100 });
|
||||
state.seats[3].stack = 12;
|
||||
@@ -96,3 +110,64 @@ test("marking nobody doesn't move a chip", () => {
|
||||
state.seats.forEach(player => Betting.isAllIn(state, player));
|
||||
assert.equal(chipsOnTable(state), before);
|
||||
});
|
||||
|
||||
// ---- And the shape of the mark, which is the felt's problem ------------------
|
||||
//
|
||||
// "ALL IN" is the only thing that ever lands on the stack line with a space in it, and
|
||||
// a space is somewhere to break. Measured at 360x640 with twelve players all-in, it
|
||||
// wanted 36px and had 34: it broke onto a second line, and an 11px line became 22.
|
||||
//
|
||||
// That is not a typography complaint. The puck went from 55px tall to 66, and puck
|
||||
// height is what the seat ring is short of — at the ends of an ellipse the seats are
|
||||
// separated vertically, so 11px taller is 11px further into the seat next door. The
|
||||
// layout search then tried every width it has, found none that cleared, and fell back
|
||||
// to the narrowest, which is also the one that wraps hardest.
|
||||
//
|
||||
// So the rule every design system gives a badge applies here too: one line, always.
|
||||
describe("the mark is one line, because the ring cannot afford two", () => {
|
||||
test("a stack line can never wrap", () => {
|
||||
const body = ruleBody(".seatPuckStack");
|
||||
assert.ok(body, "the .seatPuckStack rule should exist");
|
||||
assert.match(body, /white-space:\s*nowrap/,
|
||||
"the stack line must not be allowed to wrap — a second line is 11px the ring hasn't got");
|
||||
});
|
||||
|
||||
test("and on the tightest pucks it gives up its letter-spacing to fit", () => {
|
||||
// Not wrapping alone only converts a wrap into a spill: a line that cannot wrap
|
||||
// and still doesn't fit paints over the seat beside it instead. The spacing is
|
||||
// 3.6px of the 36 it wanted, and it is the one of the mark's three signals that
|
||||
// can go — the warm colour and the bold weight stay.
|
||||
const base = ruleBody(".seatPuckStack.seatPuckAllIn");
|
||||
const tight = ruleBody(".seatsTight .seatPuckStack.seatPuckAllIn");
|
||||
assert.ok(base, "the base all-in rule should exist");
|
||||
assert.ok(tight, "the tight-tier all-in rule should exist");
|
||||
|
||||
const spacingOf = body => {
|
||||
const match = body.match(/letter-spacing:\s*([\d.]+)em/);
|
||||
return match ? Number(match[1]) : null;
|
||||
};
|
||||
const wide = spacingOf(base);
|
||||
const narrow = spacingOf(tight);
|
||||
assert.ok(wide !== null && narrow !== null, "both rules should set letter-spacing in em");
|
||||
assert.ok(narrow < wide,
|
||||
`the tight tier should space the mark more tightly than the ordinary one (${narrow} vs ${wide})`);
|
||||
|
||||
// The arithmetic that has to hold for it to fit at all: six characters at 10px
|
||||
// with 4px of padding either side and a 1px border, inside a 44px puck.
|
||||
const CONTENT_PX = 44 - 2 * 4 - 2 * 1;
|
||||
const inkAt = spacing => 32.4 + spacing * 10 * "ALL IN".length;
|
||||
assert.ok(inkAt(narrow) <= CONTENT_PX,
|
||||
`"ALL IN" must fit the narrowest puck: ${inkAt(narrow).toFixed(1)}px into ${CONTENT_PX}px`);
|
||||
assert.ok(inkAt(wide) > CONTENT_PX,
|
||||
"the ordinary spacing is what didn't fit — if it does now, this test is measuring the wrong thing");
|
||||
});
|
||||
|
||||
test("the two signals that survive are the ones doing the work", () => {
|
||||
// The spacing exists so "ALL IN" doesn't read as money at a glance. Dropping it
|
||||
// on the narrow pucks is only safe while the colour and the weight remain, so
|
||||
// they are pinned here rather than left to be tidied away later.
|
||||
const base = ruleBody(".seatPuckStack.seatPuckAllIn");
|
||||
assert.match(base, /color:\s*#ffcf6b/i, "the warm colour separates it from a grey stack");
|
||||
assert.match(base, /font-weight:\s*700/, "and the weight does the rest");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// Perceptual contrast (APCA), and how much of it each piece of text needs.
|
||||
//
|
||||
// The audit already had a contrast check and it passed, which is why this exists. That
|
||||
// check measures WCAG 2 contrast ratio, and WCAG 2's own authors say it is the wrong
|
||||
// instrument for this page: the ratio is a luminance gap, and a luminance gap
|
||||
// systematically *overstates* readability once one of the two colours is near black.
|
||||
// This table's page is #141414 and its panels are darker still, so every reading it
|
||||
// takes is on the side of the algorithm where it is least reliable. Text can clear
|
||||
// 4.5:1 and still read as a smudge.
|
||||
//
|
||||
// APCA asks the other question — can text of *this size and weight* be read on this
|
||||
// background — and answers it on a scale where a given number means the same
|
||||
// readability at either polarity. It replaces nothing: the WCAG check stays, because
|
||||
// that is the number an accessibility audit will ask for. This is the one that says
|
||||
// whether a dealer can read it in a dim room.
|
||||
//
|
||||
// Constants are 0.98G-4g, and the implementation is checked against the eight
|
||||
// published reference pairs in apcaMath.test.js. An unverified contrast checker is
|
||||
// worse than none, because it launders a guess into a number.
|
||||
|
||||
const MAIN_TRC = 2.4;
|
||||
const R_CO = 0.2126729, G_CO = 0.7151522, B_CO = 0.0721750;
|
||||
const NORM_BG = 0.56, NORM_TXT = 0.57, REV_TXT = 0.62, REV_BG = 0.65;
|
||||
const BLK_THRS = 0.022, BLK_CLMP = 1.414;
|
||||
const SCALE_BOW = 1.14, SCALE_WOB = 1.14;
|
||||
const LO_BOW_OFFSET = 0.027, LO_WOB_OFFSET = 0.027;
|
||||
const DELTA_Y_MIN = 0.0005, LO_CLIP = 0.001;
|
||||
|
||||
// Screen luminance. Note this is a straight exponent, not the sRGB piecewise curve —
|
||||
// APCA specifies the simple 2.4 power and the two are not interchangeable here.
|
||||
function sRGBtoY(rgb) {
|
||||
const s = c => Math.pow(c / 255.0, MAIN_TRC);
|
||||
return R_CO * s(rgb[0]) + G_CO * s(rgb[1]) + B_CO * s(rgb[2]);
|
||||
}
|
||||
|
||||
// Signed: positive is dark text on light, negative is light text on dark. The sign
|
||||
// carries the polarity, and polarity is not cosmetic — the same luminance pair reads
|
||||
// differently each way round, which is the whole reason this exists.
|
||||
function contrast(txtY, bgY) {
|
||||
txtY = txtY > BLK_THRS ? txtY : txtY + Math.pow(BLK_THRS - txtY, BLK_CLMP);
|
||||
bgY = bgY > BLK_THRS ? bgY : bgY + Math.pow(BLK_THRS - bgY, BLK_CLMP);
|
||||
|
||||
if (Math.abs(bgY - txtY) < DELTA_Y_MIN) return 0.0;
|
||||
|
||||
let sapc, out;
|
||||
if (bgY > txtY) {
|
||||
sapc = (Math.pow(bgY, NORM_BG) - Math.pow(txtY, NORM_TXT)) * SCALE_BOW;
|
||||
out = sapc < LO_CLIP ? 0.0 : sapc - LO_BOW_OFFSET;
|
||||
} else {
|
||||
sapc = (Math.pow(bgY, REV_BG) - Math.pow(txtY, REV_TXT)) * SCALE_WOB;
|
||||
out = sapc > -LO_CLIP ? 0.0 : sapc + LO_WOB_OFFSET;
|
||||
}
|
||||
return out * 100;
|
||||
}
|
||||
|
||||
function lc(textRgb, bgRgb) {
|
||||
return contrast(sRGBtoY(textRgb), sRGBtoY(bgRgb));
|
||||
}
|
||||
|
||||
// How much contrast this text needs, from the published APCA levels:
|
||||
//
|
||||
// Lc 90 fluent body text; and the floor for anything at 14px/400 or 12px/400
|
||||
// Lc 75 the minimum for a column of body text (18px/400, 16px/500, 14px/700)
|
||||
// Lc 60 the minimum for content text that isn't a column or block
|
||||
// Lc 45 larger, heavier text — headlines at 36px/400 or 24px/700
|
||||
// Lc 30 the absolute floor, and only for genuinely spot-read text
|
||||
//
|
||||
// Interpolating between those anchors rather than inventing a curve. Weight is folded
|
||||
// in as an effective size because that is how the published table behaves: bolder
|
||||
// strokes buy roughly the same readability as bigger ones, and 14px/700 sitting at the
|
||||
// same Lc 75 as 18px/400 is the anchor that fixes the exchange rate.
|
||||
function requiredLc(px, weight = 400) {
|
||||
const heavier = Math.max(0, (Number(weight) || 400) - 400) / 300; // 400..700 -> 0..1
|
||||
const effective = px * (1 + 0.28 * heavier); // 14px/700 ~= 18px/400
|
||||
|
||||
const anchors = [
|
||||
[12, 90],
|
||||
[14, 90],
|
||||
[15, 82],
|
||||
[18, 75],
|
||||
[24, 60],
|
||||
[36, 45],
|
||||
];
|
||||
if (effective <= anchors[0][0]) return anchors[0][1];
|
||||
const last = anchors[anchors.length - 1];
|
||||
if (effective >= last[0]) return last[1];
|
||||
for (let i = 1; i < anchors.length; i++) {
|
||||
const [x0, y0] = anchors[i - 1];
|
||||
const [x1, y1] = anchors[i];
|
||||
if (effective <= x1) {
|
||||
const t = (effective - x0) / (x1 - x0);
|
||||
return y0 + t * (y1 - y0);
|
||||
}
|
||||
}
|
||||
return last[1];
|
||||
}
|
||||
|
||||
module.exports = { sRGBtoY, contrast, lc, requiredLc };
|
||||
@@ -0,0 +1,141 @@
|
||||
// The contrast arithmetic, checked against the numbers its authors publish.
|
||||
//
|
||||
// A contrast checker that is wrong is worse than not having one, because it launders a
|
||||
// guess into a number and then a stylesheet gets tuned to it. The eight pairs below are
|
||||
// the reference values published with APCA 0.98G-4g; if the implementation drifts from
|
||||
// them by so much as a rounding change, these fail and nothing downstream is believed.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { lc, contrast, sRGBtoY, requiredLc } = require("./apca");
|
||||
|
||||
const hex = h => {
|
||||
h = h.replace("#", "");
|
||||
if (h.length === 3) h = h.split("").map(c => c + c).join("");
|
||||
return [0, 2, 4].map(i => parseInt(h.slice(i, i + 2), 16));
|
||||
};
|
||||
|
||||
describe("the published reference pairs", () => {
|
||||
// Text first, background second, exactly as the reference table lists them.
|
||||
const CASES = [
|
||||
["#888", "#fff", 63.056469930209424],
|
||||
["#fff", "#888", -68.54146436644962],
|
||||
["#000", "#aaa", 58.146262578561334],
|
||||
["#aaa", "#000", -56.24113336839742],
|
||||
["#123", "#def", 91.66830811481631],
|
||||
["#def", "#123", -93.06770049484275],
|
||||
["#123", "#444", 8.32326136957393],
|
||||
["#444", "#123", -7.526878460278154],
|
||||
];
|
||||
|
||||
CASES.forEach(([text, bg, expected]) => {
|
||||
test(`${text} on ${bg} is Lc ${expected.toFixed(2)}`, () => {
|
||||
assert.ok(Math.abs(lc(hex(text), hex(bg)) - expected) < 1e-9,
|
||||
`got ${lc(hex(text), hex(bg))}, want ${expected}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("polarity", () => {
|
||||
test("is signed, and the sign is not decoration", () => {
|
||||
// Dark text on light is positive, light text on dark negative. The two are not
|
||||
// mirror images — swapping them changes the magnitude as well as the sign,
|
||||
// which is the entire reason this is used instead of a ratio.
|
||||
const dark = lc(hex("#888"), hex("#fff"));
|
||||
const light = lc(hex("#fff"), hex("#888"));
|
||||
assert.ok(dark > 0 && light < 0);
|
||||
assert.ok(Math.abs(Math.abs(dark) - Math.abs(light)) > 5,
|
||||
"the same pair reads differently each way round");
|
||||
});
|
||||
|
||||
test("a colour on itself is nothing at all", () => {
|
||||
assert.equal(lc(hex("#2c2c2c"), hex("#2c2c2c")), 0);
|
||||
});
|
||||
|
||||
test("and so is a difference too small to see", () => {
|
||||
// Below deltaYmin the answer is zero rather than a tiny number, so a rounding
|
||||
// artefact can never be reported as contrast.
|
||||
assert.equal(lc([44, 44, 44], [44, 44, 45]), 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("luminance", () => {
|
||||
test("is the straight 2.4 power, not the sRGB piecewise curve", () => {
|
||||
// Worth pinning: the two are close enough to look right and different enough to
|
||||
// move a borderline reading. Mid grey under the piecewise curve is ~0.2159.
|
||||
assert.ok(Math.abs(sRGBtoY([128, 128, 128]) - Math.pow(128 / 255, 2.4)) < 1e-6);
|
||||
});
|
||||
|
||||
test("white is as close to 1 as the published coefficients get", () => {
|
||||
// 1.0000001, not 1. The three coefficients are given to seven places and sum to
|
||||
// a hair over one, and that is the specification rather than a mistake to
|
||||
// correct — the reference pairs above only come out exact with these values.
|
||||
// Asserted loosely on purpose: tightening this to 1e-12 fails on correct code.
|
||||
assert.ok(Math.abs(sRGBtoY([255, 255, 255]) - 1) < 1e-6);
|
||||
assert.equal(sRGBtoY([0, 0, 0]), 0);
|
||||
});
|
||||
|
||||
test("green carries most of it", () => {
|
||||
// Not arbitrary: it's why gold on green was the worst pairing on the felt.
|
||||
assert.ok(sRGBtoY([0, 255, 0]) > sRGBtoY([255, 0, 0]) + sRGBtoY([0, 0, 255]));
|
||||
});
|
||||
|
||||
test("contrast() takes luminances, lc() takes colours", () => {
|
||||
assert.equal(contrast(sRGBtoY(hex("#888")), sRGBtoY(hex("#fff"))), lc(hex("#888"), hex("#fff")));
|
||||
});
|
||||
});
|
||||
|
||||
describe("how much contrast a given piece of text needs", () => {
|
||||
test("the published anchors land where they should", () => {
|
||||
assert.equal(Math.round(requiredLc(12, 400)), 90);
|
||||
assert.equal(Math.round(requiredLc(14, 400)), 90);
|
||||
assert.equal(Math.round(requiredLc(18, 400)), 75);
|
||||
assert.equal(Math.round(requiredLc(24, 400)), 60);
|
||||
assert.equal(Math.round(requiredLc(36, 400)), 45);
|
||||
});
|
||||
|
||||
test("smaller text asks for more, never less", () => {
|
||||
for (let px = 9; px < 40; px++) {
|
||||
assert.ok(requiredLc(px, 400) >= requiredLc(px + 1, 400),
|
||||
`${px}px should ask for at least as much as ${px + 1}px`);
|
||||
}
|
||||
});
|
||||
|
||||
test("weight buys size, at the rate the published table sets", () => {
|
||||
// 14px/700 and 18px/400 sit at the same Lc 75 in the table, and that pair is
|
||||
// what fixes the exchange rate between weight and size here.
|
||||
assert.ok(Math.abs(requiredLc(14, 700) - requiredLc(18, 400)) < 3);
|
||||
assert.ok(requiredLc(14, 700) < requiredLc(14, 400), "bold asks for less than regular");
|
||||
});
|
||||
|
||||
test("it never runs off either end", () => {
|
||||
assert.equal(requiredLc(4, 400), 90);
|
||||
assert.equal(requiredLc(200, 900), 45);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the page's own colours measure", () => {
|
||||
// These are the readings the round was built on. Pinned so that a future change to
|
||||
// the tokens has to look at them rather than move them by accident.
|
||||
const PANEL = hex("#1a1a1a");
|
||||
|
||||
test("the greys these tokens replaced were below the floor", () => {
|
||||
assert.ok(Math.abs(lc(hex("#9a9a9a"), PANEL)) < 50, "the old muted grey");
|
||||
assert.ok(Math.abs(lc(hex("#8a8a8a"), PANEL)) < 40, "the old dim grey");
|
||||
});
|
||||
|
||||
test("and the ones that replaced them clear it", () => {
|
||||
assert.ok(Math.abs(lc(hex("#c8c8c8"), PANEL)) >= 60, "muted");
|
||||
assert.ok(Math.abs(lc(hex("#b4b4b4"), PANEL)) >= 58, "dim");
|
||||
});
|
||||
|
||||
test("the three tiers are still three tiers", () => {
|
||||
// The worry with lifting muted and dim is that they collapse into --text and the
|
||||
// page flattens. They don't: the spread is wider than it was.
|
||||
const text = Math.abs(lc(hex("#eeeeee"), PANEL));
|
||||
const muted = Math.abs(lc(hex("#c8c8c8"), PANEL));
|
||||
const dim = Math.abs(lc(hex("#b4b4b4"), PANEL));
|
||||
assert.ok(text > muted && muted > dim, "still ordered");
|
||||
assert.ok(text - dim > 30, "and still a real spread");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,473 @@
|
||||
// Where a player's bet sits on the felt.
|
||||
//
|
||||
// The chips were the last thing on the table nobody had measured. They were laid out on
|
||||
// a ring of their own at a fixed 27/44 of the seat's reach — a fraction picked when the
|
||||
// seat ring was itself fixed — and the choice between that ring and clinging to the
|
||||
// puck was made by asking how wide the *window* was. Neither of those is a question
|
||||
// about the felt. A phone held sideways is 844px across and gives the felt 117px of
|
||||
// height: it passed the width test, took the ring, and every chip on it was painted
|
||||
// behind a seat. The seats carry `z-index: 1` and the bets layer carries none, so a
|
||||
// chip under a puck is not dimmed or half-hidden — it is gone, and with it the only
|
||||
// number saying what that player has in.
|
||||
//
|
||||
// Everything below is geometry, so it runs without a browser. The felts are the ones
|
||||
// measured on real screens: 692x117 is a phone on its side, 300x216 a phone upright.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Render } = require("./load");
|
||||
|
||||
const RADII = { rx: 44, ry: 42 };
|
||||
const PUCK = { width: 96, height: 64 };
|
||||
const CHIP = { width: 44, height: 22 };
|
||||
|
||||
const felt = (width, height) => ({ width, height });
|
||||
const spacingOn = (box, puck = PUCK, chip = CHIP) =>
|
||||
({ box, puck, chip, clearance: Render.BET_CLEARANCE_PX });
|
||||
|
||||
// A chip's centre in pixels on a given felt.
|
||||
const chipPixels = (index, count, box, spacing, radii = RADII) => {
|
||||
const pos = Render.betPosition(index, count, box.width / box.height, radii, spacing);
|
||||
return { x: (pos.left / 100) * box.width, y: (pos.top / 100) * box.height };
|
||||
};
|
||||
|
||||
const seatPixels = (index, count, box, radii = RADII) => {
|
||||
const pos = Render.seatPosition(index, count, box.width / box.height, radii);
|
||||
return { x: (pos.left / 100) * box.width, y: (pos.top / 100) * box.height };
|
||||
};
|
||||
|
||||
// Two axis-aligned boxes, by their centres. The same test the seats settle among
|
||||
// themselves — see seatFootprint.test.js — because it is the same question.
|
||||
const boxesOverlap = (a, b, sizeA, sizeB) =>
|
||||
Math.abs(a.x - b.x) < (sizeA.width + sizeB.width) / 2 - 0.5 &&
|
||||
Math.abs(a.y - b.y) < (sizeA.height + sizeB.height) / 2 - 0.5;
|
||||
|
||||
describe("reaching the edge of a box", () => {
|
||||
test("straight out the side is half the width, straight up half the height", () => {
|
||||
assert.equal(Render.edgeReach(96, 64, 1, 0), 48);
|
||||
assert.equal(Render.edgeReach(96, 64, 0, -1), 32);
|
||||
assert.equal(Render.edgeReach(96, 64, -1, 0), 48);
|
||||
});
|
||||
|
||||
test("a corner is whichever edge the ray meets first", () => {
|
||||
// 45° out of a 96x64 box: the top and bottom are nearer than the sides, so the
|
||||
// ray leaves through one of those. A single radius — half the diagonal, 57.7 —
|
||||
// would put a chip 25px further out than it needs to be, which on a 117px felt
|
||||
// is the difference between clear of the puck and off the table.
|
||||
const diagonal = Render.edgeReach(96, 64, Math.SQRT1_2, Math.SQRT1_2);
|
||||
assert.ok(Math.abs(diagonal - 32 / Math.SQRT1_2) < 1e-9, `got ${diagonal}`);
|
||||
assert.ok(diagonal < 48, "and never further than the wider half-edge");
|
||||
});
|
||||
|
||||
test("a square is the same in every direction it is asked about", () => {
|
||||
const straight = Render.edgeReach(50, 50, 0, 1);
|
||||
assert.equal(Render.edgeReach(50, 50, 1, 0), straight);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getting one box clear of another", () => {
|
||||
const PUCK96 = { width: 96, height: 88 };
|
||||
const CHIP50 = { width: 50, height: 21 };
|
||||
|
||||
// Travel `t` along `u` from the puck's centre, then ask whether the two boxes are
|
||||
// apart. This is the question clearanceReach is supposed to answer, asked the long
|
||||
// way round so the answer can be checked rather than restated.
|
||||
const apart = (t, ux, uy) =>
|
||||
Math.abs(t * ux) >= (PUCK96.width + CHIP50.width) / 2 - 1e-9 ||
|
||||
Math.abs(t * uy) >= (PUCK96.height + CHIP50.height) / 2 - 1e-9;
|
||||
|
||||
test("straight up and down it is what adding the two half-heights gives", () => {
|
||||
assert.equal(Render.clearanceReach(PUCK96, CHIP50, 0, -1), (88 + 21) / 2);
|
||||
assert.equal(Render.clearanceReach(PUCK96, CHIP50, 1, 0), (96 + 50) / 2);
|
||||
});
|
||||
|
||||
test("on a diagonal it is more than the two reaches added together", () => {
|
||||
// The bug this replaced. Two axis-aligned boxes are clear when they are apart on
|
||||
// one axis, and the travel that achieves that is the reach of the box you get by
|
||||
// adding their widths and their heights — which is never less, and on a diagonal
|
||||
// is more, than adding the two boxes' own reaches. At 768x1024 the difference was
|
||||
// 11px, and 11px is how far inside its own player's puck the chip came to rest.
|
||||
const dirs = [[0.83, 0.56], [0.6, 0.8], [0.9, 0.44], [Math.SQRT1_2, Math.SQRT1_2]];
|
||||
let shortfalls = 0;
|
||||
dirs.forEach(([ux, uy]) => {
|
||||
const summed = Render.edgeReach(PUCK96.width, PUCK96.height, ux, uy) +
|
||||
Render.edgeReach(CHIP50.width, CHIP50.height, ux, uy);
|
||||
const proper = Render.clearanceReach(PUCK96, CHIP50, ux, uy);
|
||||
assert.ok(proper >= summed - 1e-9, `${ux},${uy}: ${proper} < ${summed}`);
|
||||
if (proper <= summed + 1e-9) return;
|
||||
// Where they differ, the old answer was not merely smaller — it was wrong:
|
||||
// travelling that far leaves the two boxes still on top of each other.
|
||||
shortfalls++;
|
||||
assert.ok(!apart(summed, ux, uy),
|
||||
`${ux},${uy}: adding the reaches gave ${summed.toFixed(1)}, which is enough after all`);
|
||||
});
|
||||
assert.ok(shortfalls >= 2, "expected the two to actually differ on some of these");
|
||||
});
|
||||
|
||||
test("and it is exactly enough — a hair less is not", () => {
|
||||
const dirs = [[0, -1], [1, 0], [0.83, 0.56], [0.6, -0.8], [-0.9, 0.44]];
|
||||
dirs.forEach(([ux, uy]) => {
|
||||
const t = Render.clearanceReach(PUCK96, CHIP50, ux, uy);
|
||||
assert.ok(apart(t, ux, uy), `${ux},${uy}: ${t}px still overlaps`);
|
||||
assert.ok(!apart(t - 0.5, ux, uy), `${ux},${uy}: ${t}px is further than needed`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("a chip on the ring", () => {
|
||||
test("with nothing measured it keeps the proportions the ring always used", () => {
|
||||
// The unit suite and the very first render both call it this way. 27/44 and
|
||||
// 25/42 of the seat's own reach, so the chips move with the ring rather than
|
||||
// with the felt.
|
||||
const seat = Render.seatPosition(3, 9, 1.4, RADII);
|
||||
const chip = Render.betPosition(3, 9, 1.4, RADII, null);
|
||||
assert.ok(Math.abs((chip.left - 50) - (seat.left - 50) * (27 / 44)) < 1e-9);
|
||||
assert.ok(Math.abs((chip.top - 50) - (seat.top - 50) * (25 / 42)) < 1e-9);
|
||||
});
|
||||
|
||||
test("measured, it sits just clear of its own puck rather than at a fixed fraction", () => {
|
||||
const box = felt(560, 400);
|
||||
const spacing = spacingOn(box);
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const seat = seatPixels(i, 9, box);
|
||||
const chip = chipPixels(i, 9, box, spacing);
|
||||
assert.ok(!boxesOverlap(seat, chip, PUCK, CHIP),
|
||||
`seat ${i + 1}'s own chip is under its own puck`);
|
||||
}
|
||||
});
|
||||
|
||||
test("and never wanders into the pot badge in the middle", () => {
|
||||
// The floor: 38% of the way out. Without it a chip walked in past a puck that
|
||||
// is 96px wide on a felt whose reach is 90px would land on the pot total, which
|
||||
// is the same fault at the other end of the spoke.
|
||||
const box = felt(300, 216);
|
||||
const spacing = spacingOn(box, { width: 200, height: 150 });
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const seat = seatPixels(i, 6, box);
|
||||
const chip = chipPixels(i, 6, box, spacing);
|
||||
const seatReach = Math.hypot(seat.x - box.width / 2, seat.y - box.height / 2);
|
||||
const chipReach = Math.hypot(chip.x - box.width / 2, chip.y - box.height / 2);
|
||||
assert.ok(chipReach >= seatReach * 0.38 - 0.5,
|
||||
`chip ${i + 1} is ${chipReach.toFixed(1)}px out of a possible ${seatReach.toFixed(1)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("a seat sitting dead centre gets its chip put somewhere, not NaN", () => {
|
||||
const box = felt(400, 300);
|
||||
const pos = Render.betPosition(0, 1, box.width / box.height, { rx: 0, ry: 0 }, spacingOn(box));
|
||||
assert.ok(Number.isFinite(pos.left) && Number.isFinite(pos.top), JSON.stringify(pos));
|
||||
});
|
||||
});
|
||||
|
||||
describe("deciding whether a ring of chips fits at all", () => {
|
||||
// The shape the layout works in: one entry per chip on the felt, carrying its own
|
||||
// measured size, because a chip is as wide as the number printed on it.
|
||||
const tableOf = (count, chip = CHIP) => {
|
||||
Render.state = { seatCount: count, seats: Array.from({ length: count }, () => ({})) };
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
({ index: i, width: chip.width, height: chip.height }));
|
||||
};
|
||||
|
||||
test("a desktop felt has room for one", () => {
|
||||
const box = felt(700, 500);
|
||||
const all = tableOf(9);
|
||||
assert.equal(Render.chipsClearTheSeats(all, box.width / box.height, RADII, spacingOn(box)), true);
|
||||
});
|
||||
|
||||
test("a phone on its side has not, whatever the window's width says about it", () => {
|
||||
// 692x117, measured on an 844px-wide phone in landscape — a viewport that sails
|
||||
// past a `max-width: 700px` test. The ring is a slot: the 38% floor puts a chip
|
||||
// on the seat across the table from it.
|
||||
const box = felt(692, 117);
|
||||
const all = tableOf(9);
|
||||
assert.equal(Render.chipsClearTheSeats(all, box.width / box.height, RADII, spacingOn(box)), false,
|
||||
"this is the felt where every chip was painted behind a puck");
|
||||
});
|
||||
|
||||
test("its answer is the one the geometry gives", () => {
|
||||
// Not a restatement of the two cases above: whatever it returns, it has to agree
|
||||
// with laying the chips out and measuring them against every puck on the felt.
|
||||
const shapes = [felt(700, 500), felt(692, 117), felt(300, 216), felt(1000, 620), felt(480, 300)];
|
||||
shapes.forEach(box => {
|
||||
const all = tableOf(9);
|
||||
const spacing = spacingOn(box);
|
||||
const aspect = box.width / box.height;
|
||||
const clear = all.every(({ index: i }) => {
|
||||
const chip = chipPixels(i, 9, box, spacing);
|
||||
return all.every(({ index: j }) => !boxesOverlap(chip, seatPixels(j, 9, box), CHIP, PUCK));
|
||||
});
|
||||
assert.equal(Render.chipsClearTheSeats(all, aspect, RADII, spacing), clear,
|
||||
`${box.width}x${box.height}`);
|
||||
});
|
||||
});
|
||||
|
||||
test("each chip is asked about at its own size", () => {
|
||||
// A chip is as wide as the number on it: at 768x1024, four players' chips were
|
||||
// 40px and one holding a four-figure bet was 47. Answering the question once, at
|
||||
// the widest, told the whole felt it had no room for a ring — and dropped all
|
||||
// five chips onto their own players' stacks to solve a problem four of them
|
||||
// did not have.
|
||||
// The felt and the pucks are the iPad's own, measured: 672x672, pucks 96x88.
|
||||
const box = felt(672, 672);
|
||||
const narrow = { width: 40, height: 20 };
|
||||
const huge = { width: 420, height: 380 };
|
||||
const spacing = { box, puck: { width: 96, height: 88 }, clearance: Render.BET_CLEARANCE_PX };
|
||||
const aspect = box.width / box.height;
|
||||
|
||||
assert.equal(Render.chipsClearTheSeats(tableOf(9, narrow), aspect, RADII, spacing), true,
|
||||
"a felt this size has room for a ring of ordinary chips");
|
||||
assert.equal(Render.chipsClearTheSeats(tableOf(9, huge), aspect, RADII, spacing), false,
|
||||
"and none at all for a ring of chips that size");
|
||||
|
||||
// Now one of each. The answer has to come from the sizes in the list, not from
|
||||
// one size standing in for all of them: with one huge chip the ring is off, and
|
||||
// taking that chip out of the question puts it back.
|
||||
const mixed = tableOf(9, narrow);
|
||||
mixed[0] = { index: 0, ...huge };
|
||||
assert.equal(Render.chipsClearTheSeats(mixed, aspect, RADII, spacing), false);
|
||||
assert.equal(Render.chipsClearTheSeats(mixed.slice(1), aspect, RADII, spacing), true,
|
||||
"the eight ordinary ones were never the problem");
|
||||
});
|
||||
|
||||
test("and at the chips against each other", () => {
|
||||
// The ring is a smaller circle than the seats' one and holds the same number of
|
||||
// things, so it is the tighter fit of the two. Measured on a Pixel 7 with twelve
|
||||
// players, two $10s on the ring sat on top of each other while every puck on the
|
||||
// felt was clear — so a check that only looks at the pucks says yes to a layout
|
||||
// that hides one player's bet behind another's.
|
||||
// The Pixel 7's own numbers, measured: a 300x336 felt on a ring of 44 by 40.84,
|
||||
// pucks 44x55, a pot badge 64x53, and the seven players who had put chips in.
|
||||
const box = felt(300, 336);
|
||||
const radii = { rx: 44, ry: 40.84 };
|
||||
const puck = { width: 44, height: 55 };
|
||||
const pot = { x: 150, y: 168, width: 64, height: 53 };
|
||||
const spacing = { box, puck, pot, clearance: Render.BET_CLEARANCE_PX };
|
||||
const aspect = box.width / box.height;
|
||||
|
||||
Render.state = { seatCount: 12, seats: Array.from({ length: 12 }, () => ({})) };
|
||||
const bettors = [2, 3, 4, 5, 6, 7, 8];
|
||||
const all = bettors.map(index => ({ index, width: index === 2 ? 44.1 : 49.7, height: 21 }));
|
||||
|
||||
const chips = all.map(entry => ({
|
||||
...entry,
|
||||
...chipPixels(entry.index, 12, box,
|
||||
{ ...spacing, chip: { width: entry.width, height: entry.height } }, radii),
|
||||
}));
|
||||
const seats = [...Array(12).keys()].map(i => seatPixels(i, 12, box, radii));
|
||||
|
||||
assert.equal(chips.some(chip => seats.some(seat => boxesOverlap(chip, seat, chip, puck))), false,
|
||||
"this table is chosen so the pucks are not the problem");
|
||||
assert.equal(chips.some(chip => boxesOverlap(chip, pot, chip, pot)), false,
|
||||
"nor the pot");
|
||||
assert.ok(chips.some((chip, i) => chips.some((other, j) => i !== j &&
|
||||
boxesOverlap(chip, other, chip, other))), "and so the chips are");
|
||||
|
||||
assert.equal(Render.chipsClearTheSeats(all, aspect, radii, spacing), false);
|
||||
});
|
||||
|
||||
test("it looks at every puck, not just the chip's own", () => {
|
||||
// The rule this replaced walked each chip in until it was clear of its *own*
|
||||
// seat and stopped there, which says nothing about the eight other pucks on the
|
||||
// felt. 560x190 is a felt where that rule is satisfied and the ring is still no
|
||||
// good: every chip clears the puck it belongs to, and one of them is sitting on
|
||||
// a stranger's.
|
||||
const box = felt(560, 190);
|
||||
const all = tableOf(9);
|
||||
const spacing = spacingOn(box);
|
||||
const chips = all.map(({ index: i }) => chipPixels(i, 9, box, spacing));
|
||||
|
||||
all.forEach(({ index: i }) => assert.ok(!boxesOverlap(chips[i], seatPixels(i, 9, box), CHIP, PUCK),
|
||||
`chip ${i + 1} is under its own puck, so this felt does not make the point`));
|
||||
assert.ok(all.some(({ index: i }) => all.some(({ index: j }) => i !== j &&
|
||||
boxesOverlap(chips[i], seatPixels(j, 9, box), CHIP, PUCK))),
|
||||
"expected a chip on somebody else's puck — what the old rule could not see");
|
||||
assert.equal(Render.chipsClearTheSeats(all, box.width / box.height, RADII, spacing), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a chip clinging to its puck", () => {
|
||||
// The fallback when there is no room for a ring: the chip hangs off the puck on the
|
||||
// side facing the empty middle of the felt.
|
||||
//
|
||||
// The felts the app actually draws, with the puck size each one settles on — read
|
||||
// off the running site at each viewport rather than guessed here, because the tight
|
||||
// felts shrink the puck too, and a test that holds the desktop's 96x64 puck against
|
||||
// a 528x83 felt is describing a table the app never draws.
|
||||
const REAL = [
|
||||
["Galaxy S8 on its side", felt(528, 83), { width: 44, height: 55 }],
|
||||
["iPhone on its side", felt(692, 113), { width: 72, height: 55 }],
|
||||
["Galaxy S8 upright, six-handed", felt(248, 280), { width: 78, height: 88 }],
|
||||
["Galaxy S8 upright, packed", felt(248, 280), { width: 44, height: 55 }],
|
||||
];
|
||||
// Chips are 21x17 everywhere they are anchored — the amount is the only thing on
|
||||
// one at that size.
|
||||
const TIGHT = { puck: { width: 44, height: 55 }, chip: { width: 21, height: 17 } };
|
||||
|
||||
// What each chip is told to do, worked out for the whole table at once.
|
||||
const placeAll = (count, box, puck, chip = TIGHT.chip, radii = RADII) => {
|
||||
Render.state = { seatCount: count, seats: Array.from({ length: count }, () => ({})) };
|
||||
const indices = [...Array(count).keys()];
|
||||
const entries = indices.map(i => ({ index: i, width: chip.width, height: chip.height }));
|
||||
const spacing = { box, puck, clearance: Render.BET_CLEARANCE_PX };
|
||||
const offs = Render.anchoredChipTravel(entries, count, box.width / box.height, RADII, spacing);
|
||||
return indices.map(i => {
|
||||
const seat = Render.seatPosition(i, count, box.width / box.height, radii);
|
||||
const at = { x: (seat.left / 100) * box.width, y: (seat.top / 100) * box.height };
|
||||
const off = offs[i];
|
||||
return { seat: at, chip: { x: at.x + off.x, y: at.y + off.y }, off, puck };
|
||||
});
|
||||
};
|
||||
|
||||
test("on its own, a chip asks to sit just clear of its puck", () => {
|
||||
const box = felt(692, 113);
|
||||
const puck = { width: 72, height: 55 };
|
||||
const seat = Render.seatPosition(0, 9, box.width / box.height, RADII);
|
||||
const off = Render.anchoredChipOffset(seat, puck, TIGHT.chip, box, RADII);
|
||||
const travel = Math.hypot(off.x, off.y);
|
||||
const ux = off.x / travel, uy = off.y / travel;
|
||||
assert.ok(Math.abs(travel -
|
||||
(Render.clearanceReach(puck, TIGHT.chip, ux, uy) + Render.BET_CLEARANCE_PX)) < 1e-6, `travelled ${travel}`);
|
||||
});
|
||||
|
||||
test("and gets it, where nothing else is in the way", () => {
|
||||
// 692x113 is the roomier of the two landscape felts. Every chip clears its own
|
||||
// puck and none of them has to give ground.
|
||||
placeAll(9, felt(692, 113), { width: 72, height: 55 }).forEach((row, i) => {
|
||||
assert.ok(!boxesOverlap(row.seat, row.chip, row.puck, TIGHT.chip),
|
||||
`seat ${i + 1}'s chip is inside its own puck, and nothing made it go there`);
|
||||
});
|
||||
});
|
||||
|
||||
test("it goes off the rim, not down the spoke", () => {
|
||||
// On a felt 692x113 the two are nowhere near the same direction. A seat on the
|
||||
// top rail has a spoke pointing diagonally inward — which along a slot this flat
|
||||
// runs sideways into the next seat — while off the rim is very nearly straight
|
||||
// down, into the empty middle. This is the seam that put two chips on top of
|
||||
// each other on a Galaxy S8.
|
||||
const box = felt(692, 113);
|
||||
const rows = placeAll(9, box, { width: 72, height: 55 });
|
||||
const top = rows.find(r => r.seat.y < box.height * 0.3 && r.seat.x > box.width * 0.7);
|
||||
assert.ok(top, "expected a seat up on the right of the top rail");
|
||||
assert.ok(Math.abs(top.off.y) > Math.abs(top.off.x) * 2,
|
||||
`chip went ${top.off.x.toFixed(1)},${top.off.y.toFixed(1)} — that is along the rail, not off it`);
|
||||
assert.ok(top.off.y > 0, "and downward, toward the middle of the felt");
|
||||
});
|
||||
|
||||
test("no two chips on a full table land on each other", () => {
|
||||
// Four sides gave three seams. There are no sides now, and this is what that is
|
||||
// worth — on every felt the app actually draws, at every table size.
|
||||
REAL.forEach(([name, box, puck]) => {
|
||||
[6, 9, 12].forEach(count => {
|
||||
const rows = placeAll(count, box, puck);
|
||||
for (let a = 0; a < rows.length; a++) {
|
||||
for (let b = a + 1; b < rows.length; b++) {
|
||||
assert.ok(!boxesOverlap(rows[a].chip, rows[b].chip, TIGHT.chip, TIGHT.chip),
|
||||
`${name}, ${count} seats: chips ${a + 1} and ${b + 1} overlap`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("a chip only gives ground when it is standing on something", () => {
|
||||
// The point of pulling chips back one at a time rather than by a rule that
|
||||
// applies to all of them. Stopping every chip at the felt's midline, or scaling
|
||||
// them all by one factor, takes travel from the seats at the ends of a long
|
||||
// rail — measured at 692x113, that parked two chips on their own player's stack
|
||||
// to solve a collision that did not exist.
|
||||
//
|
||||
// So: every chip either got everything it asked for, or putting the last step
|
||||
// back reintroduces a collision it is part of. The step goes back for all of
|
||||
// them at once, because a facing pair gives ground together and the pass that
|
||||
// finally separates such a pair moves both ends of it.
|
||||
const step = Render.CHIP_PULLBACK_STEP_PX;
|
||||
REAL.forEach(([name, box, puck]) => {
|
||||
[6, 9, 12].forEach(count => {
|
||||
const rows = placeAll(count, box, puck).map(row => {
|
||||
const travel = Math.hypot(row.off.x, row.off.y);
|
||||
const dir = travel < 1e-6 ? { x: 0, y: 0 } : { x: row.off.x / travel, y: row.off.y / travel };
|
||||
const asked = travel < 1e-6 ? 0 :
|
||||
Render.clearanceReach(puck, TIGHT.chip, dir.x, dir.y) +
|
||||
Render.BET_CLEARANCE_PX;
|
||||
const back = Math.min(asked, travel + step);
|
||||
return { ...row, travel, asked,
|
||||
undone: { x: row.seat.x + dir.x * back, y: row.seat.y + dir.y * back } };
|
||||
});
|
||||
|
||||
rows.forEach((row, i) => {
|
||||
if (row.travel < 1e-6 || row.travel >= row.asked - 1e-6) return;
|
||||
const onSomething = rows.some((other, j) => j !== i && (
|
||||
boxesOverlap(row.undone, other.undone, TIGHT.chip, TIGHT.chip) ||
|
||||
boxesOverlap(row.undone, other.seat, TIGHT.chip, puck)));
|
||||
assert.ok(onSomething,
|
||||
`${name}, ${count} seats: chip ${i + 1} stopped ${(row.asked - row.travel).toFixed(1)}px short of nothing`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("nobody is pushed further out than they asked to go", () => {
|
||||
// The pull-back only ever takes travel away. A chip pushed past its clearance
|
||||
// would be floating out on the felt with nothing to say whose it is.
|
||||
REAL.forEach(([name, box, puck]) => {
|
||||
placeAll(12, box, puck).forEach((row, i) => {
|
||||
const travel = Math.hypot(row.off.x, row.off.y);
|
||||
if (travel < 1e-6) return;
|
||||
const ux = row.off.x / travel, uy = row.off.y / travel;
|
||||
const asked = Render.clearanceReach(puck, TIGHT.chip, ux, uy) +
|
||||
Render.BET_CLEARANCE_PX;
|
||||
assert.ok(travel <= asked + 0.5,
|
||||
`${name}: chip ${i + 1} went ${travel.toFixed(1)}px when ${asked.toFixed(1)} was asked for`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("the direction turns smoothly all the way round the ring", () => {
|
||||
// The property a set of discrete sides cannot have, and the reason there are
|
||||
// none: neighbouring seats get neighbouring directions, with no step anywhere.
|
||||
const box = felt(560, 400);
|
||||
const angles = Array.from({ length: 24 }, (_, i) => {
|
||||
const seat = Render.seatPosition(i, 24, box.width / box.height, RADII);
|
||||
const off = Render.anchoredChipOffset(seat, PUCK, CHIP, box, RADII);
|
||||
return Math.atan2(off.y, off.x);
|
||||
});
|
||||
angles.forEach((angle, i) => {
|
||||
const next = angles[(i + 1) % angles.length];
|
||||
let turn = Math.abs(next - angle);
|
||||
if (turn > Math.PI) turn = 2 * Math.PI - turn;
|
||||
assert.ok(turn < Math.PI / 4,
|
||||
`between seats ${i + 1} and ${i + 2} the chip jumps ${(turn * 180 / Math.PI).toFixed(0)}°`);
|
||||
});
|
||||
});
|
||||
|
||||
test("it stops short of the pot total as well", () => {
|
||||
// The other thing in the middle of the felt, and the one number on the table
|
||||
// that belongs to everybody. A seat on the middle of a rail sends its chip
|
||||
// straight at it: on a Galaxy S8 on its side, seat 1's $10 was sitting on
|
||||
// "Pot $65". The badge is measured rather than assumed to be at the centre —
|
||||
// it lives on the table, not on either overlay, and the two are not concentric.
|
||||
const box = felt(528, 83);
|
||||
const pot = { x: 264, y: 40, width: 96, height: 34 };
|
||||
Render.state = { seatCount: 12, seats: Array.from({ length: 12 }, () => ({})) };
|
||||
const indices = [...Array(12).keys()];
|
||||
const entries = indices.map(i =>
|
||||
({ index: i, width: TIGHT.chip.width, height: TIGHT.chip.height }));
|
||||
const spacing = { box, puck: TIGHT.puck, clearance: Render.BET_CLEARANCE_PX, pot };
|
||||
const offs = Render.anchoredChipTravel(entries, 12, box.width / box.height, RADII, spacing);
|
||||
|
||||
indices.forEach(i => {
|
||||
const seat = Render.seatPosition(i, 12, box.width / box.height, RADII);
|
||||
const chip = { x: (seat.left / 100) * box.width + offs[i].x,
|
||||
y: (seat.top / 100) * box.height + offs[i].y };
|
||||
assert.ok(!boxesOverlap(chip, pot, TIGHT.chip, pot),
|
||||
`chip ${i + 1} is on the pot total`);
|
||||
});
|
||||
});
|
||||
|
||||
test("a seat with no rim to be off still gets an answer", () => {
|
||||
const box = felt(400, 300);
|
||||
const off = Render.anchoredChipOffset({ left: 50, top: 50 }, PUCK, CHIP, box, RADII);
|
||||
assert.ok(Number.isFinite(off.x) && Number.isFinite(off.y), JSON.stringify(off));
|
||||
assert.ok(off.y < 0, "upward, which is where a puck's own writing is not");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
// The community cards, as a thing the table remembers.
|
||||
//
|
||||
// The board is the one part of a hand this tool never had a place for: hole cards go on
|
||||
// a seat, chips go in the middle, and the five cards everybody plays lived only on the
|
||||
// felt in front of the dealer. It is opt-in, because most nights nobody wants to enter
|
||||
// cards at all — and everything that reads it has to behave exactly as it always did
|
||||
// when it is off.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Betting, HoldemState, makeTable } = require("./load");
|
||||
|
||||
const dealt = (over = {}) => {
|
||||
const state = { ...makeTable({ seatCount: 6, stack: 200, smallBlind: 1, bigBlind: 2 }), ...over };
|
||||
Betting.beginHand(state);
|
||||
return state;
|
||||
};
|
||||
|
||||
describe("a new table", () => {
|
||||
test("has five empty slots and is not tracking them", () => {
|
||||
const state = HoldemState.createDefault();
|
||||
assert.deepEqual(state.board, ["", "", "", "", ""]);
|
||||
assert.equal(state.trackBoard, false,
|
||||
"off by default: a dealer who never asked for this should not be asked for cards");
|
||||
});
|
||||
});
|
||||
|
||||
describe("how many cards are out", () => {
|
||||
test("none, three, four, five", () => {
|
||||
assert.equal(Betting.boardCardsFor("preflop"), 0);
|
||||
assert.equal(Betting.boardCardsFor("flop"), 3);
|
||||
assert.equal(Betting.boardCardsFor("turn"), 4);
|
||||
assert.equal(Betting.boardCardsFor("river"), 5);
|
||||
});
|
||||
|
||||
test("and nothing at all for a street that isn't one", () => {
|
||||
assert.equal(Betting.boardCardsFor("showdown"), 0);
|
||||
assert.equal(Betting.boardCardsFor(undefined), 0);
|
||||
});
|
||||
|
||||
test("it matches the streets the betting engine actually deals", () => {
|
||||
// If a street is ever added, this is the table that has to hear about it —
|
||||
// a street with no entry silently draws no cards.
|
||||
Betting.STREETS.forEach(street => {
|
||||
assert.ok(Object.prototype.hasOwnProperty.call(Betting.BOARD_CARDS, street),
|
||||
`${street} has no card count`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearing it", () => {
|
||||
test("the next hand takes the board with it", () => {
|
||||
const state = dealt();
|
||||
state.board = ["AH", "KD", "9C", "4S", "2H"];
|
||||
HoldemState.startNextHand(state);
|
||||
assert.deepEqual(state.board, ["", "", "", "", ""]);
|
||||
});
|
||||
|
||||
test("so does a misdeal", () => {
|
||||
const state = dealt();
|
||||
state.board = ["AH", "KD", "9C", "", ""];
|
||||
Betting.takeHandBack(state);
|
||||
assert.deepEqual(state.board, ["", "", "", "", ""]);
|
||||
});
|
||||
|
||||
test("and so does dealing a hand, however the table got here", () => {
|
||||
// Dealing twice off the same button — after a misdeal, or a table set up and
|
||||
// then dealt again — would otherwise rank the last hand's board against this
|
||||
// hand's cards, and it would look exactly like a real answer.
|
||||
const state = dealt();
|
||||
state.board = ["AH", "KD", "9C", "4S", "2H"];
|
||||
Betting.beginHand(state);
|
||||
assert.deepEqual(state.board, ["", "", "", "", ""]);
|
||||
});
|
||||
|
||||
test("but turning the tracking off does not", () => {
|
||||
// A dealer who taps the toggle mid-hand can tap it back; wiping the flop on
|
||||
// the way past would cost them cards they had already entered.
|
||||
const state = dealt({ trackBoard: true });
|
||||
state.board = ["AH", "KD", "9C", "", ""];
|
||||
state.trackBoard = false;
|
||||
assert.deepEqual(state.board, ["AH", "KD", "9C", "", ""]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a board off a file", () => {
|
||||
const migrated = board => HoldemState.migrate({
|
||||
seats: [HoldemState.makePlayer()], board,
|
||||
}).board;
|
||||
|
||||
test("a table saved before boards existed gets an empty one, not tracked", () => {
|
||||
const parsed = HoldemState.migrate({ seats: [HoldemState.makePlayer()] });
|
||||
assert.deepEqual(parsed.board, ["", "", "", "", ""]);
|
||||
assert.equal(parsed.trackBoard, false);
|
||||
});
|
||||
|
||||
test("it is always five slots, however many arrived", () => {
|
||||
assert.equal(migrated(["AH"]).length, 5);
|
||||
assert.equal(migrated(["AH", "KD", "9C", "4S", "2H", "3D", "5S"]).length, 5);
|
||||
assert.equal(migrated("not an array").length, 5);
|
||||
assert.equal(migrated(null).length, 5);
|
||||
});
|
||||
|
||||
test("anything that isn't a card becomes an empty slot", () => {
|
||||
assert.deepEqual(migrated(["AH", "ZZ", 7, null, "10H"]), ["AH", "", "", "", ""]);
|
||||
});
|
||||
|
||||
test("and a real one keeps its place, upper-cased", () => {
|
||||
assert.deepEqual(migrated(["ah", " kd ", "9c", "", ""]), ["AH", "KD", "9C", "", ""]);
|
||||
});
|
||||
|
||||
test("tracking is only on if the file said so, in so many words", () => {
|
||||
const on = HoldemState.migrate({ seats: [HoldemState.makePlayer()], trackBoard: true });
|
||||
const truthy = HoldemState.migrate({ seats: [HoldemState.makePlayer()], trackBoard: "yes" });
|
||||
assert.equal(on.trackBoard, true);
|
||||
assert.equal(truthy.trackBoard, false, "a string is a file that has been edited, not a yes");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
// Taking a chip colour out of the legend.
|
||||
//
|
||||
// This was one click of an unlabelled 24px button, and it was not a change to a legend.
|
||||
// computeContribution() prices a seat by running the legend over that seat's chip
|
||||
// counts, so a colour that leaves takes every chip counted in it off the table.
|
||||
// Measured in a browser on a three-handed table: deleting the $500 row took $22,500 of
|
||||
// $26,874 with it, with no confirmation, nothing said afterwards, and no way back —
|
||||
// a colour added again is a new colour with a new id, while the counts stay keyed to
|
||||
// the one that left. The counts were left behind too, so the saved file carried money
|
||||
// that nothing could price and nothing could show.
|
||||
//
|
||||
// What is tested here is mostly the number in the warning, because a warning that
|
||||
// overstates is worse than none: it is the one thing a dealer would have to trust at
|
||||
// the exact moment they are about to lose track of the box.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { HoldemState, Money } = require("./load");
|
||||
|
||||
const tableWith = ({ counts = [], overrides = [], basePot = null } = {}) => {
|
||||
const state = HoldemState.createDefault();
|
||||
HoldemState.resizeSeats(state, 9);
|
||||
state.basePotAmount = basePot;
|
||||
["Christopher", "Mike from work", "Alexandra"].forEach((name, i) => {
|
||||
state.seats[i].name = name;
|
||||
state.seats[i].inHand = true;
|
||||
state.seats[i].buyIns = 500;
|
||||
state.seats[i].chipCounts = {};
|
||||
});
|
||||
counts.forEach(([seat, chipIndex, n]) => {
|
||||
state.seats[seat].chipCounts[state.chipLegend[chipIndex].id] = n;
|
||||
});
|
||||
overrides.forEach(([seat, amount]) => { state.seats[seat].amountOverride = amount; });
|
||||
return state;
|
||||
};
|
||||
|
||||
const onTable = state => state.seats.reduce(
|
||||
(sum, p) => sum + HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount), 0);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("what a chip colour is holding up", () => {
|
||||
test("is the money, named seat by seat", () => {
|
||||
const state = tableWith({ counts: [[0, 2, 4], [1, 2, 10]] }); // the $25 chip
|
||||
const usage = HoldemState.chipColorUsage(state, state.chipLegend[2].id);
|
||||
|
||||
assert.equal(usage.total, 350);
|
||||
assert.equal(usage.chips, 14);
|
||||
assert.deepEqual(usage.seats.map(s => [s.name, s.count, s.amount]), [
|
||||
["Christopher", 4, 100],
|
||||
["Mike from work", 10, 250],
|
||||
]);
|
||||
});
|
||||
|
||||
test("is nothing at all for a colour nobody counted", () => {
|
||||
const state = tableWith({ counts: [[0, 2, 4]] });
|
||||
const usage = HoldemState.chipColorUsage(state, state.chipLegend[4].id);
|
||||
assert.equal(usage.total, 0);
|
||||
assert.deepEqual(usage.seats, []);
|
||||
assert.ok(usage.chip, "the colour itself is still found, so it can be named");
|
||||
});
|
||||
|
||||
test("and nothing for a colour that isn't there", () => {
|
||||
const state = tableWith();
|
||||
assert.deepEqual(HoldemState.chipColorUsage(state, "no-such-chip"),
|
||||
{ chip: null, total: 0, chips: 0, seats: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("the number in the warning is the number the table loses", () => {
|
||||
// The whole point. Multiplying the chips by their value would have been the obvious
|
||||
// way to work this out and it is wrong in both directions — the two tests below are
|
||||
// the two ways it is wrong.
|
||||
const dropFromRemoving = (state, chipId) => {
|
||||
const before = onTable(state);
|
||||
HoldemState.removeChipColor(state, chipId);
|
||||
return Money.round(before - onTable(state));
|
||||
};
|
||||
|
||||
test("a seat with a typed amount ignores its chips, so its colour is worth nothing", () => {
|
||||
// computeContribution() reads amountOverride first and never reaches the counts.
|
||||
// Christopher is holding ten $25 chips on paper and priced at a flat $90.
|
||||
const state = tableWith({ counts: [[0, 2, 10]], overrides: [[0, 90]] });
|
||||
const chipId = state.chipLegend[2].id;
|
||||
|
||||
const usage = HoldemState.chipColorUsage(state, chipId);
|
||||
assert.equal(usage.total, 0, "$250 of chips, but the seat is not priced from them");
|
||||
assert.equal(dropFromRemoving(state, chipId), 0);
|
||||
});
|
||||
|
||||
test("a seat whose chips all leave falls back to the base pot, not to zero", () => {
|
||||
// The $25s are everything Christopher has counted, so taking them away drops him
|
||||
// to the table's standing ante — a loss of 50, not of 250.
|
||||
const state = tableWith({ counts: [[0, 2, 10]], basePot: 200 });
|
||||
const chipId = state.chipLegend[2].id;
|
||||
|
||||
const usage = HoldemState.chipColorUsage(state, chipId);
|
||||
assert.equal(usage.total, 50, "250 of chips, but only 50 of it actually leaves");
|
||||
assert.equal(dropFromRemoving(state, chipId), 50);
|
||||
});
|
||||
|
||||
test("and across a spread of tables the two always agree", () => {
|
||||
function rng(seed) {
|
||||
let s = seed >>> 0;
|
||||
return () => {
|
||||
s = (s * 1664525 + 1013904223) >>> 0;
|
||||
return s / 4294967296;
|
||||
};
|
||||
}
|
||||
const random = rng(20260906);
|
||||
let withMoney = 0;
|
||||
|
||||
for (let table = 0; table < 2000; table++) {
|
||||
const counts = [];
|
||||
for (let seat = 0; seat < 3; seat++) {
|
||||
for (let chip = 0; chip < 5; chip++) {
|
||||
if (random() < 0.5) counts.push([seat, chip, 1 + Math.floor(random() * 20)]);
|
||||
}
|
||||
}
|
||||
const overrides = [];
|
||||
for (let seat = 0; seat < 3; seat++) {
|
||||
if (random() < 0.25) overrides.push([seat, Math.floor(random() * 400)]);
|
||||
}
|
||||
const basePot = random() < 0.5 ? 200 : null;
|
||||
|
||||
const chipIndex = Math.floor(random() * 5);
|
||||
const stated = tableWith({ counts, overrides, basePot });
|
||||
const chipId = stated.chipLegend[chipIndex].id;
|
||||
const claimed = HoldemState.chipColorUsage(stated, chipId).total;
|
||||
|
||||
const actual = dropFromRemoving(tableWith({ counts, overrides, basePot }), chipId);
|
||||
if (claimed !== 0) withMoney++;
|
||||
assert.equal(claimed, actual,
|
||||
`table ${table}: warned ${claimed}, table moved ${actual}\n` +
|
||||
JSON.stringify({ counts, overrides, basePot, chipIndex }));
|
||||
}
|
||||
|
||||
// Guards the generator: shapes that never put money on a colour would make every
|
||||
// assertion above compare zero with zero.
|
||||
assert.ok(withMoney > 500, `only ${withMoney} of 2000 tables had money on the colour`);
|
||||
});
|
||||
|
||||
test("and it can be a gain, which is said as one", () => {
|
||||
// Fourteen $1 chips against a $200 standing ante: once the colour goes, the seat
|
||||
// stops being priced from its chips and falls through to the ante, so the table
|
||||
// goes *up* by 186. Found by the randomised check above, not by hand.
|
||||
const state = tableWith({ counts: [[1, 0, 14]], basePot: 200 });
|
||||
const usage = HoldemState.chipColorUsage(state, state.chipLegend[0].id);
|
||||
assert.equal(usage.total, -186);
|
||||
assert.equal(dropFromRemoving(state, state.chipLegend[0].id), -186);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removing it", () => {
|
||||
test("takes the counts with it, so nothing is left priced by a colour that is gone", () => {
|
||||
const state = tableWith({ counts: [[0, 2, 4], [1, 2, 10], [1, 0, 3]] });
|
||||
const chipId = state.chipLegend[2].id;
|
||||
|
||||
HoldemState.removeChipColor(state, chipId);
|
||||
|
||||
assert.equal(state.chipLegend.filter(c => c.id === chipId).length, 0);
|
||||
state.seats.forEach(player => assert.ok(!(chipId in (player.chipCounts || {})),
|
||||
"a count against a colour no legend entry answers to is money nothing can show"));
|
||||
// The other colour on that seat is untouched.
|
||||
assert.equal(state.seats[1].chipCounts[state.chipLegend[0].id], 3);
|
||||
});
|
||||
|
||||
test("reports what it cost, measured before anything moved", () => {
|
||||
const state = tableWith({ counts: [[0, 2, 4]] });
|
||||
const usage = HoldemState.removeChipColor(state, state.chipLegend[2].id);
|
||||
assert.equal(usage.total, 100);
|
||||
assert.equal(usage.seats.length, 1);
|
||||
});
|
||||
|
||||
test("and a colour that isn't there changes nothing", () => {
|
||||
const state = tableWith({ counts: [[0, 2, 4]] });
|
||||
const before = onTable(state);
|
||||
const usage = HoldemState.removeChipColor(state, "no-such-chip");
|
||||
assert.equal(usage.chip, null);
|
||||
assert.equal(state.chipLegend.length, 5);
|
||||
assert.equal(onTable(state), before);
|
||||
});
|
||||
});
|
||||
@@ -103,8 +103,18 @@ describe("the corner scale", () => {
|
||||
describe("the tokens themselves", () => {
|
||||
test("every var() the stylesheet reaches for exists", () => {
|
||||
const used = new Set((css.match(/var\((--[a-zA-Z0-9-]+)/g) || []).map(s => s.slice(4)));
|
||||
// Published from JS at runtime rather than declared here — see actionBar.js.
|
||||
const fromScript = new Set(["--actionBarHeight", "--feltRoomPx"]);
|
||||
// Published from JS at runtime rather than declared here — see actionBar.js for
|
||||
// the first two, render.js for the seat width and the chip's denomination colour,
|
||||
// which is picked per chip from the amount on it, and feedback.js for the
|
||||
// toast's height, which is one line or two depending on what is being said.
|
||||
// The seat sheet's height is the same idea as the action bar's, and for the same
|
||||
// reason: two things fixed to the bottom of a phone, and the toast has to clear
|
||||
// whichever is taller. It changes when a section of the sheet is unfolded, so it
|
||||
// is measured rather than written down — see Render.watchSeatSheetHeight.
|
||||
const fromScript = new Set([
|
||||
"--actionBarHeight", "--feltRoomPx", "--seatPuckW", "--betChipColour",
|
||||
"--toastHeight", "--seatSheetHeight",
|
||||
]);
|
||||
// Defined by the site's own stylesheet, not this one.
|
||||
const fromSite = new Set(["--font-mono"]);
|
||||
const missing = [...used].filter(t =>
|
||||
|
||||
@@ -71,3 +71,74 @@ describe("a window with room to spare", () => {
|
||||
assert.equal(room(844.4, 127.6, 223.2) % 1, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when the felt changes size, so does everything on it", () => {
|
||||
// The felt is published as a custom property and the seats follow it on their own,
|
||||
// because they are placed in percentages of it. A bet chip anchored inside a seat
|
||||
// is not: it is placed in pixels off that seat's centre, worked out against the felt
|
||||
// as it was, and nothing re-ran that when the felt moved.
|
||||
//
|
||||
// Measured at 640x360 with five players, on a felt that lost 23px of height: the
|
||||
// chips were positioned for the old one and painted on the new, and seat 3's $5 came
|
||||
// to rest on seat 4's puck. The seat pucks had the same problem — they are searched
|
||||
// for a width the felt no longer has — and two pairs of them were overlapping.
|
||||
//
|
||||
// A side pot appearing does this too: it is 55px off the felt, mid-hand.
|
||||
const fakeDom = (windowHeight = 900) => {
|
||||
const root = { style: { values: {},
|
||||
setProperty(k, v) { this.values[k] = v; },
|
||||
getPropertyValue(k) { return this.values[k] || ""; } } };
|
||||
global.document = {
|
||||
documentElement: root,
|
||||
getElementById: id => (id === "pokerTableWrap"
|
||||
? { getBoundingClientRect: () => ({ top: 146 }) }
|
||||
: null),
|
||||
};
|
||||
global.window = { innerHeight: windowHeight, scrollY: 0 };
|
||||
return root;
|
||||
};
|
||||
|
||||
const spyRender = () => {
|
||||
const calls = { density: 0, seats: 0 };
|
||||
global.Render = {
|
||||
state: {},
|
||||
updateSeatDensity() { calls.density++; },
|
||||
renderSeats() { calls.seats++; },
|
||||
};
|
||||
return calls;
|
||||
};
|
||||
|
||||
test.afterEach(() => {
|
||||
delete global.document;
|
||||
delete global.window;
|
||||
delete global.Render;
|
||||
});
|
||||
|
||||
test("a felt that changed height lays the seats out again", () => {
|
||||
fakeDom();
|
||||
const calls = spyRender();
|
||||
ActionBar.publishFeltRoom(117);
|
||||
assert.equal(calls.seats, 1, "first publish sets a height it did not have");
|
||||
|
||||
ActionBar.publishFeltRoom(172);
|
||||
assert.equal(calls.seats, 2, "and a side pot grew the bar by 55px");
|
||||
});
|
||||
|
||||
test("a felt that did not change is left alone", () => {
|
||||
fakeDom();
|
||||
const calls = spyRender();
|
||||
ActionBar.publishFeltRoom(117);
|
||||
ActionBar.publishFeltRoom(117);
|
||||
ActionBar.publishFeltRoom(117);
|
||||
assert.equal(calls.seats, 1,
|
||||
"re-laying the table on every resize tick would be work for nothing");
|
||||
assert.equal(calls.density, 3, "the cheap re-measure still runs each time");
|
||||
});
|
||||
|
||||
test("it publishes the height whether or not anything is listening", () => {
|
||||
const root = fakeDom();
|
||||
delete global.Render;
|
||||
assert.doesNotThrow(() => ActionBar.publishFeltRoom(117));
|
||||
assert.match(root.style.getPropertyValue("--feltRoomPx"), /^\d+px$/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
// What shape the felt ends up, which is a question about which rule wins.
|
||||
//
|
||||
// feltRoom.test.js covers how much room there is. This covers what the stylesheet does
|
||||
// with it, and it exists because of a bug that no amount of reading caught: the upright
|
||||
// oval for phones was written into the `max-width: 700px` block, and the `max-width:
|
||||
// 900px` block below it set the same custom property back to 1. Both queries match a
|
||||
// phone, the two declarations have identical specificity, and the later one wins. The
|
||||
// rule was correct, the arithmetic was correct, and the property was 1 on every phone
|
||||
// on the site. It measured as a no-op and would have shipped as one.
|
||||
//
|
||||
// So this walks the media queries the way the cascade does — in source order, keeping
|
||||
// the last one that matches — instead of trusting that a rule written for a viewport is
|
||||
// the rule that viewport gets.
|
||||
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");
|
||||
|
||||
// Comments carry example numbers and at least one worked-through `--feltAspect: 1`, so
|
||||
// they come out before anything here counts declarations.
|
||||
const SOURCE = CSS.replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
|
||||
// --- just enough of a media query engine to answer "does this viewport match?" -------
|
||||
//
|
||||
// Only the forms this stylesheet actually uses: width and height bounds, orientation,
|
||||
// and `and` between them. Anything else is refused loudly rather than guessed at, so a
|
||||
// new kind of query can't quietly start matching everything.
|
||||
const matches = (condition, view) => {
|
||||
if (!condition) return true;
|
||||
return condition.split(/\s+and\s+/).every(part => {
|
||||
const m = part.trim().match(/^\(\s*([a-z-]+)\s*:\s*([^)]+?)\s*\)$/);
|
||||
assert.ok(m, `feltShape can't read the query "${part}" — teach it or simplify the CSS`);
|
||||
const [, feature, raw] = m;
|
||||
const px = parseFloat(raw);
|
||||
switch (feature) {
|
||||
case "min-width": return view.width >= px;
|
||||
case "max-width": return view.width <= px;
|
||||
case "min-height": return view.height >= px;
|
||||
case "max-height": return view.height <= px;
|
||||
case "orientation":
|
||||
return raw === (view.width > view.height ? "landscape" : "portrait");
|
||||
default:
|
||||
assert.fail(`feltShape doesn't know the media feature "${feature}"`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Every style rule in the file, in source order, with the media condition it sits
|
||||
// under. Written as a brace walk rather than a regex because a regex for `max-height`
|
||||
// also finds it in `@media (orientation: landscape) and (max-height: 560px)`, and a
|
||||
// media feature is not a declaration.
|
||||
const rules = () => {
|
||||
const out = [];
|
||||
const open = [];
|
||||
let prelude = "", i = 0;
|
||||
while (i < SOURCE.length) {
|
||||
const c = SOURCE[i];
|
||||
if (c === "{") {
|
||||
const head = prelude.trim();
|
||||
prelude = "";
|
||||
if (head.startsWith("@")) { open.push(head); i++; continue; }
|
||||
let depth = 1, j = i + 1;
|
||||
while (j < SOURCE.length && depth > 0) {
|
||||
if (SOURCE[j] === "{") depth++;
|
||||
else if (SOURCE[j] === "}") depth--;
|
||||
j++;
|
||||
}
|
||||
const media = open.filter(h => h.startsWith("@media"))
|
||||
.map(h => h.replace(/^@media\s*/, "")).join(" and ");
|
||||
out.push({ selector: head, condition: media || null, body: SOURCE.slice(i + 1, j - 1) });
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
if (c === "}") { open.pop(); prelude = ""; i++; continue; }
|
||||
prelude += c;
|
||||
i++;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const ALL_RULES = rules();
|
||||
|
||||
// The cascade, for equal-specificity declarations of one property on one selector: the
|
||||
// last one whose query matches is the one the viewport gets.
|
||||
const winnerAt = (selector, prop, view) => {
|
||||
const winning = ALL_RULES
|
||||
.filter(r => r.selector === selector && matches(r.condition, view))
|
||||
.map(r => r.body.match(new RegExp(`(?:^|;)\\s*${prop}\\s*:\\s*([^;]+)`)))
|
||||
.filter(Boolean)
|
||||
.pop();
|
||||
assert.ok(winning, `nothing sets ${prop} on ${selector} at ${view.width}x${view.height}`);
|
||||
return winning[1].trim();
|
||||
};
|
||||
|
||||
const PHONES = [
|
||||
{ width: 360, height: 640, name: "Galaxy S8" },
|
||||
{ width: 375, height: 667, name: "iPhone SE" },
|
||||
{ width: 390, height: 844, name: "iPhone 14" },
|
||||
{ width: 412, height: 915, name: "Pixel 7" },
|
||||
{ width: 430, height: 932, name: "Pro Max" },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("the shape a phone gets", () => {
|
||||
test("is an oval standing up, on every one of them", () => {
|
||||
PHONES.forEach(phone => {
|
||||
const aspect = parseFloat(winnerAt("#pokerTableWrap", "--feltAspect", phone));
|
||||
assert.ok(aspect > 1.2,
|
||||
`${phone.name} lands on --feltAspect: ${aspect}. A portrait phone is ` +
|
||||
`tall and this is what uses it; anything at or under 1 is the square ` +
|
||||
`felt that threw the room away.`);
|
||||
});
|
||||
});
|
||||
|
||||
test("and a wide screen still gets one lying down", () => {
|
||||
// The desktop felt is the shape it always was. Whatever the phones do, this is
|
||||
// the one a poker table looks like when there is room for it.
|
||||
assert.equal(parseFloat(winnerAt("#pokerTableWrap", "--feltAspect", { width: 1280, height: 900 })), 0.72);
|
||||
});
|
||||
|
||||
test("with the middle widths in between left round", () => {
|
||||
// 701-900px is one column and wide enough that neither oval is called for. It
|
||||
// has to be written as a range: a bare max-width would match the phones too and,
|
||||
// sitting lower in the file, would win there.
|
||||
[{ width: 768, height: 1024 }, { width: 900, height: 700 }].forEach(view => {
|
||||
assert.equal(parseFloat(winnerAt("#pokerTableWrap", "--feltAspect", view)), 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("what stops the oval", () => {
|
||||
const phoneRule = winnerAt("#pokerTableWrap", "max-height", { width: 390, height: 844 });
|
||||
|
||||
test("is the room and nothing but the room", () => {
|
||||
// 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.equal(phoneRule, "var(--feltRoomPx, 100vh)");
|
||||
});
|
||||
|
||||
test("and it is not floored, however tempting that is", () => {
|
||||
// A floor at the desktop oval — max(var(--feltRoomPx), 72cqw) — was written here
|
||||
// and measured out again. Twelve players all in with nine side pots is the
|
||||
// tallest the bar gets, 341px, and at 360x640 that leaves 142px of room. Floored
|
||||
// to 242 the felt keeps its shape and hides five of the twelve seats behind the
|
||||
// bar. Unfloored at 142 it is flat and all twelve are visible.
|
||||
//
|
||||
// The slit it was defending against wasn't reachable: that came from forcing
|
||||
// --feltRoomPx to 10px by hand, and the real floor is FELT_FLOOR_PX at 96.
|
||||
assert.doesNotMatch(phoneRule, /max\(/,
|
||||
"a floor here trades a flat table for a covered seat, which is the worse of " +
|
||||
"the two — see the comment above the rule");
|
||||
});
|
||||
|
||||
test("and the width is let go of, so the height can be the one that binds", () => {
|
||||
// The wide layout sizes the felt from its width and caps that. On a phone the
|
||||
// width is the column and the height is what's scarce, so the cap moves.
|
||||
assert.equal(winnerAt("#pokerTableWrap", "max-width", { width: 390, height: 844 }), "none");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,415 @@
|
||||
// What beats what.
|
||||
//
|
||||
// This is the one module in the tool that can pay the wrong person without anybody
|
||||
// noticing. A pot wizard that asks "who won?" is only ever as wrong as the dealer
|
||||
// ticking the box; one that answers the question itself is wrong on its own account,
|
||||
// silently, and a table that has come to trust it will not check. So the ordering is
|
||||
// tested from both ends: every category against every other, the traps that a
|
||||
// hand-rolled evaluator gets wrong (the wheel, the counterfeited kicker, the board
|
||||
// that plays), and then a lot of random seven-card hands against a second
|
||||
// implementation written a different way.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { HandRank } = require("./load");
|
||||
|
||||
const best = (...cards) => HandRank.best(cards.flat());
|
||||
const beats = (a, b) => HandRank.compare(a, b) > 0;
|
||||
const chops = (a, b) => HandRank.compare(a, b) === 0;
|
||||
|
||||
// A hand written as one string, for the tests that list a lot of them.
|
||||
const hand = str => str.trim().split(/\s+/);
|
||||
|
||||
describe("the five-card hand", () => {
|
||||
const of = str => HandRank.evaluate5(hand(str));
|
||||
|
||||
test("each category is recognised", () => {
|
||||
const cases = [
|
||||
["AS KS QS JS TS", HandRank.STRAIGHT_FLUSH, "Royal flush"],
|
||||
["9H 8H 7H 6H 5H", HandRank.STRAIGHT_FLUSH, "Straight flush, nine high"],
|
||||
["7C 7D 7H 7S 2C", HandRank.FOUR_OF_A_KIND, "Four of a kind, sevens"],
|
||||
["KC KD KH 3S 3C", HandRank.FULL_HOUSE, "Full house, kings full of threes"],
|
||||
["AD JD 8D 5D 2D", HandRank.FLUSH, "Flush, ace high"],
|
||||
["9C 8D 7H 6S 5C", HandRank.STRAIGHT, "Straight, nine high"],
|
||||
["QC QD QH 7S 2C", HandRank.THREE_OF_A_KIND, "Three of a kind, queens"],
|
||||
["AC AD 8H 8S 4C", HandRank.TWO_PAIR, "Two pair, aces and eights"],
|
||||
["QC QD 9H 5S 2C", HandRank.ONE_PAIR, "Pair of queens"],
|
||||
["AC JD 8H 5S 2C", HandRank.HIGH_CARD, "Ace high"],
|
||||
];
|
||||
cases.forEach(([cards, category, described]) => {
|
||||
const ranked = of(cards);
|
||||
assert.equal(ranked.category, category, cards);
|
||||
assert.equal(HandRank.describe(ranked), described, cards);
|
||||
});
|
||||
});
|
||||
|
||||
test("the categories are in the right order", () => {
|
||||
// Listed weakest first, then every pair checked both ways round. The order
|
||||
// itself is the thing being asserted, so it is written out rather than derived
|
||||
// from the constants it is meant to be checking.
|
||||
const ladder = [
|
||||
"AC JD 8H 5S 2C", // ace high
|
||||
"2C 2D 8H 5S 3C", // a pair of deuces
|
||||
"AC AD 8H 8S 4C", // two pair
|
||||
"3C 3D 3H 7S 2C", // trips
|
||||
"9C 8D 7H 6S 5C", // straight
|
||||
"AD JD 8D 5D 2D", // flush
|
||||
"2C 2D 2H 3S 3C", // full house
|
||||
"7C 7D 7H 7S 2C", // quads
|
||||
"9H 8H 7H 6H 5H", // straight flush
|
||||
];
|
||||
for (let low = 0; low < ladder.length; low++) {
|
||||
for (let high = low + 1; high < ladder.length; high++) {
|
||||
assert.ok(beats(of(ladder[high]), of(ladder[low])),
|
||||
`${ladder[high]} should beat ${ladder[low]}`);
|
||||
assert.ok(!beats(of(ladder[low]), of(ladder[high])),
|
||||
`${ladder[low]} should not beat ${ladder[high]}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("the same hand in different suits is a chop", () => {
|
||||
assert.ok(chops(of("AC KD 8H 5S 2C"), of("AS KH 8D 5C 2H")));
|
||||
assert.ok(chops(of("QC QD 9H 5S 2C"), of("QH QS 9D 5C 2H")));
|
||||
});
|
||||
|
||||
test("nothing but five real cards is a hand", () => {
|
||||
assert.equal(HandRank.evaluate5(hand("AC KD 8H 5S")), null, "four");
|
||||
assert.equal(HandRank.evaluate5(hand("AC KD 8H 5S 2C 3D")), null, "six");
|
||||
assert.equal(HandRank.evaluate5(["AC", "KD", "8H", "5S", "ZZ"]), null, "nonsense");
|
||||
assert.equal(HandRank.evaluate5(["AC", "KD", "8H", "5S", ""]), null, "an empty slot");
|
||||
assert.equal(HandRank.evaluate5(null), null);
|
||||
});
|
||||
|
||||
test("case does not matter, because the picker is not the only way cards get in", () => {
|
||||
assert.ok(chops(of("ac kd 8h 5s 2c"), of("AC KD 8H 5S 2C")));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the wheel", () => {
|
||||
const of = str => HandRank.evaluate5(hand(str));
|
||||
|
||||
// The straight that runs through the ace at the bottom. An evaluator that scores a
|
||||
// straight by its top card and lets the ace keep its usual value makes A-2-3-4-5
|
||||
// the best straight there is instead of the worst — which pays the wrong player
|
||||
// every time it comes up against a broadway.
|
||||
test("A-2-3-4-5 is a straight", () => {
|
||||
assert.equal(of("AC 2D 3H 4S 5C").category, HandRank.STRAIGHT);
|
||||
assert.equal(HandRank.describe(of("AC 2D 3H 4S 5C")), "Straight, five high");
|
||||
});
|
||||
|
||||
test("and it is the lowest one", () => {
|
||||
assert.ok(beats(of("6C 5D 4H 3S 2C"), of("AC 2D 3H 4S 5C")),
|
||||
"six high beats the wheel");
|
||||
assert.ok(beats(of("AC KD QH JS TC"), of("AC 2D 3H 4S 5C")),
|
||||
"broadway beats the wheel");
|
||||
});
|
||||
|
||||
test("the same goes for a steel wheel", () => {
|
||||
const steel = of("AH 2H 3H 4H 5H");
|
||||
assert.equal(steel.category, HandRank.STRAIGHT_FLUSH);
|
||||
assert.equal(HandRank.describe(steel), "Straight flush, five high");
|
||||
assert.ok(beats(of("6H 5H 4H 3H 2H"), steel));
|
||||
assert.ok(beats(of("AS KS QS JS TS"), steel));
|
||||
});
|
||||
|
||||
test("an ace with a four-card run either side of it is not a straight", () => {
|
||||
// K-A-2-3-4 does not wrap.
|
||||
assert.equal(of("KC AD 2H 3S 4C").category, HandRank.HIGH_CARD);
|
||||
assert.equal(of("QC KD AH 2S 3C").category, HandRank.HIGH_CARD);
|
||||
});
|
||||
});
|
||||
|
||||
describe("kickers", () => {
|
||||
const of = str => HandRank.evaluate5(hand(str));
|
||||
|
||||
test("the fifth card settles two pair", () => {
|
||||
assert.ok(beats(of("AC AD 8H 8S KC"), of("AH AS 8D 8C QC")));
|
||||
assert.ok(chops(of("AC AD 8H 8S KC"), of("AH AS 8D 8C KD")));
|
||||
});
|
||||
|
||||
test("and a pair, three kickers deep", () => {
|
||||
assert.ok(beats(of("9C 9D AH 7S 2C"), of("9H 9S KD 7C 2D")), "first kicker");
|
||||
assert.ok(beats(of("9C 9D AH 7S 2C"), of("9H 9S AD 6C 2D")), "second");
|
||||
assert.ok(beats(of("9C 9D AH 7S 3C"), of("9H 9S AD 7C 2D")), "third");
|
||||
});
|
||||
|
||||
test("the higher pair beats the better kicker", () => {
|
||||
assert.ok(beats(of("TC TD 3H 2S 4C"), of("9C 9D AH KS QC")));
|
||||
});
|
||||
|
||||
test("two pair is read by its higher pair first", () => {
|
||||
assert.ok(beats(of("KC KD 2H 2S 3C"), of("QC QD JH JS AC")));
|
||||
});
|
||||
|
||||
test("a full house is read by its trips first", () => {
|
||||
assert.ok(beats(of("3C 3D 3H 2S 2C"), of("2D 2H 2C AS AD")));
|
||||
});
|
||||
|
||||
test("a flush is read all the way down", () => {
|
||||
assert.ok(beats(of("AD KD 8D 5D 3D"), of("AH KH 8H 5H 2H")));
|
||||
assert.ok(chops(of("AD KD 8D 5D 3D"), of("AH KH 8H 5H 3H")));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the best five out of seven", () => {
|
||||
test("it finds the flush the two hole cards do not both belong to", () => {
|
||||
const ranked = best(["AH", "2C"], ["KH", "QH", "7H", "3H", "9S"]);
|
||||
assert.equal(ranked.category, HandRank.FLUSH);
|
||||
assert.equal(HandRank.describe(ranked), "Flush, ace high");
|
||||
});
|
||||
|
||||
test("it plays the board when the board is better than anything you hold", () => {
|
||||
// Nobody can beat a royal on the board; both hands are the board itself.
|
||||
const board = ["AS", "KS", "QS", "JS", "TS"];
|
||||
const one = best(["2C", "3D"], board);
|
||||
const two = best(["7H", "8H"], board);
|
||||
assert.equal(HandRank.describe(one), "Royal flush");
|
||||
assert.ok(chops(one, two), "the board plays, so it is a chop");
|
||||
});
|
||||
|
||||
test("it does not use three hole cards, because there are only two", () => {
|
||||
const ranked = best(["AC", "AD"], ["AH", "KS", "KD", "2C", "3H"]);
|
||||
assert.equal(HandRank.describe(ranked), "Full house, aces full of kings");
|
||||
});
|
||||
|
||||
test("a counterfeited two pair falls back on the board", () => {
|
||||
// Holding 6-5 on a board of 8-8-K-K-A: the player's two pair is counterfeited —
|
||||
// the best five are the board's two pair with the ace, and the sixes never play.
|
||||
const ranked = best(["6C", "5D"], ["8H", "8S", "KC", "KD", "AC"]);
|
||||
assert.equal(HandRank.describe(ranked), "Two pair, kings and eights");
|
||||
assert.deepEqual([...ranked.cards].sort(), ["8H", "8S", "AC", "KC", "KD"].sort());
|
||||
});
|
||||
|
||||
test("five, six and seven cards are all rankable; four is not", () => {
|
||||
assert.ok(best(["AC", "KD"], ["QH", "JS", "TC"]), "five");
|
||||
assert.ok(best(["AC", "KD"], ["QH", "JS", "TC", "2D"]), "six");
|
||||
assert.ok(best(["AC", "KD"], ["QH", "JS", "TC", "2D", "3H"]), "seven");
|
||||
assert.equal(best(["AC", "KD"], ["QH", "JS"]), null, "four");
|
||||
});
|
||||
|
||||
test("the same card twice is not a hand", () => {
|
||||
// A mis-entry, not a monster. Ranking it would mean ranking a deck that does
|
||||
// not exist, and the answer would look exactly as confident as a real one.
|
||||
assert.equal(best(["AC", "AC"], ["KH", "QS", "JD", "2C", "3H"]), null);
|
||||
assert.equal(best(["AC", "KD"], ["KD", "QS", "JD", "2C", "3H"]), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the showdown", () => {
|
||||
const board = ["AH", "KD", "9C", "4S", "2H"];
|
||||
|
||||
test("it names one winner and says how it read every hand", () => {
|
||||
const result = HandRank.showdown(board, [
|
||||
{ id: "a", cards: ["AS", "KC"] }, // two pair, aces and kings
|
||||
{ id: "b", cards: ["9H", "9S"] }, // trips
|
||||
{ id: "c", cards: ["AC", "3D"] }, // a pair of aces
|
||||
]);
|
||||
assert.equal(result.complete, true);
|
||||
assert.deepEqual(result.winners, ["b"]);
|
||||
assert.deepEqual(result.players.map(p => p.place), [2, 1, 3]);
|
||||
assert.deepEqual(result.players.map(p => HandRank.describe(p.hand)), [
|
||||
"Two pair, aces and kings",
|
||||
"Three of a kind, nines",
|
||||
"Pair of aces",
|
||||
]);
|
||||
});
|
||||
|
||||
test("a chop comes back as two winners on the same place", () => {
|
||||
const result = HandRank.showdown(board, [
|
||||
{ id: "a", cards: ["AS", "QC"] },
|
||||
{ id: "b", cards: ["AD", "QH"] },
|
||||
{ id: "c", cards: ["7H", "5S"] },
|
||||
]);
|
||||
assert.deepEqual(result.winners.sort(), ["a", "b"]);
|
||||
assert.deepEqual(result.players.map(p => p.place), [1, 1, 3],
|
||||
"and the player behind them is third, not second");
|
||||
});
|
||||
|
||||
test("an incomplete board is not a showdown", () => {
|
||||
const result = HandRank.showdown(["AH", "KD", "9C"], [
|
||||
{ id: "a", cards: ["AS", "KC"] },
|
||||
{ id: "b", cards: ["9H", "9S"] },
|
||||
]);
|
||||
assert.equal(result.boardComplete, false);
|
||||
assert.equal(result.complete, false);
|
||||
assert.deepEqual(result.winners, [], "it must not name one");
|
||||
});
|
||||
|
||||
test("one unknown hand and it will not say who won", () => {
|
||||
// Who is *ahead* and who *won* are different claims, and the second one is the
|
||||
// only one that can pay somebody. A player who never turned their cards over
|
||||
// could have anything.
|
||||
const result = HandRank.showdown(board, [
|
||||
{ id: "a", cards: ["AS", "KC"] },
|
||||
{ id: "b", cards: ["", ""] },
|
||||
]);
|
||||
assert.equal(result.complete, false);
|
||||
assert.deepEqual(result.winners, []);
|
||||
assert.deepEqual(result.unknown, ["b"]);
|
||||
assert.ok(result.players[0].hand, "but it still reads the hands it can");
|
||||
assert.equal(result.players[1].hand, null);
|
||||
});
|
||||
|
||||
test("a card in two places stops it dead", () => {
|
||||
const result = HandRank.showdown(board, [
|
||||
{ id: "a", cards: ["AH", "KC"] }, // the ace of hearts is on the board
|
||||
{ id: "b", cards: ["9H", "9S"] },
|
||||
]);
|
||||
assert.equal(result.duplicated, true);
|
||||
assert.equal(result.complete, false);
|
||||
assert.deepEqual(result.winners, []);
|
||||
});
|
||||
|
||||
test("no players at all is not a showdown either", () => {
|
||||
const result = HandRank.showdown(board, []);
|
||||
assert.equal(result.complete, false);
|
||||
assert.deepEqual(result.winners, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("against a second opinion", () => {
|
||||
// The strongest check available without a poker library: a completely separate
|
||||
// ranking, written the other way round — every hand turned into a sortable string
|
||||
// by counting ranks, with no shared helper between it and the module. Where the two
|
||||
// disagree, one of them is wrong and the test says which hand it was.
|
||||
const VALUE = { T: 10, J: 11, Q: 12, K: 13, A: 14 };
|
||||
const val = card => VALUE[card[0]] || Number(card[0]);
|
||||
|
||||
function reference5(cards) {
|
||||
const ranks = cards.map(val).sort((x, y) => y - x);
|
||||
const suited = new Set(cards.map(c => c[1])).size === 1;
|
||||
|
||||
const tally = {};
|
||||
ranks.forEach(r => { tally[r] = (tally[r] || 0) + 1; });
|
||||
const bySize = Object.keys(tally)
|
||||
.map(Number)
|
||||
.sort((x, y) => tally[y] - tally[x] || y - x);
|
||||
const sizes = bySize.map(r => tally[r]).join("");
|
||||
|
||||
const unique = [...new Set(ranks)];
|
||||
let run = null;
|
||||
if (unique.length === 5) {
|
||||
if (unique[0] - unique[4] === 4) run = unique[0];
|
||||
else if (unique.join(",") === "14,5,4,3,2") run = 5;
|
||||
}
|
||||
|
||||
// [category, ...tiebreak], the same shape, arrived at from the group sizes
|
||||
// rather than from a chain of ifs about shape[0] and shape[1].
|
||||
if (suited && run !== null) return [8, run];
|
||||
if (sizes === "41") return [7, ...bySize];
|
||||
if (sizes === "32") return [6, ...bySize];
|
||||
if (suited) return [5, ...ranks];
|
||||
if (run !== null) return [4, run];
|
||||
if (sizes === "311") return [3, ...bySize];
|
||||
if (sizes === "221") return [2, ...bySize];
|
||||
if (sizes === "2111") return [1, ...bySize];
|
||||
return [0, ...ranks];
|
||||
}
|
||||
|
||||
const worse = (a, b) => {
|
||||
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
||||
if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) < (b[i] || 0);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
function referenceBest(cards) {
|
||||
let top = null;
|
||||
for (let a = 0; a < 3; a++) for (let b = a + 1; b < 4; b++)
|
||||
for (let c = b + 1; c < 5; c++) for (let d = c + 1; d < 6; d++)
|
||||
for (let e = d + 1; e < 7; e++) {
|
||||
const score = reference5([cards[a], cards[b], cards[c], cards[d], cards[e]]);
|
||||
if (!top || worse(top, score)) top = score;
|
||||
}
|
||||
return top;
|
||||
}
|
||||
|
||||
// A deck, and a deterministic shuffle — a failure has to be reproducible or it is
|
||||
// just an anecdote about one CI run.
|
||||
const DECK = [];
|
||||
"23456789TJQKA".split("").forEach(r => "SHDC".split("").forEach(s => DECK.push(r + s)));
|
||||
|
||||
let seed = 20260904;
|
||||
const random = () => {
|
||||
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
||||
return seed / 0x7fffffff;
|
||||
};
|
||||
|
||||
test("twenty thousand random seven-card hands rank the same way", () => {
|
||||
const deck = [...DECK];
|
||||
for (let round = 0; round < 20000; round++) {
|
||||
// Seven off the top of a shuffle of only what it needs.
|
||||
const drawn = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const pick = i + Math.floor(random() * (deck.length - i));
|
||||
[deck[i], deck[pick]] = [deck[pick], deck[i]];
|
||||
drawn.push(deck[i]);
|
||||
}
|
||||
const mine = HandRank.best(drawn);
|
||||
const theirs = referenceBest(drawn);
|
||||
assert.deepEqual([mine.category, ...mine.tiebreak], theirs,
|
||||
`disagreed on ${drawn.join(" ")}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("and four thousand random pairs of hands are ordered the same way", () => {
|
||||
// The categories agreeing is not the same as the comparison agreeing: this is
|
||||
// the half that pays somebody.
|
||||
const deck = [...DECK];
|
||||
for (let round = 0; round < 4000; round++) {
|
||||
const drawn = [];
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const pick = i + Math.floor(random() * (deck.length - i));
|
||||
[deck[i], deck[pick]] = [deck[pick], deck[i]];
|
||||
drawn.push(deck[i]);
|
||||
}
|
||||
const board = drawn.slice(0, 5);
|
||||
const one = [...board, drawn[5], drawn[6]];
|
||||
const two = [...board, drawn[7], drawn[8]];
|
||||
|
||||
const mine = Math.sign(HandRank.compare(HandRank.best(one), HandRank.best(two)));
|
||||
const a = referenceBest(one), b = referenceBest(two);
|
||||
const theirs = worse(a, b) ? -1 : worse(b, a) ? 1 : 0;
|
||||
assert.equal(mine, theirs,
|
||||
`board ${board.join(" ")}: ${drawn[5]}${drawn[6]} vs ${drawn[7]}${drawn[8]}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("saying it in a sentence", () => {
|
||||
// The verdict line reads "Alexandra wins $25 with ___", so the phrase has to carry
|
||||
// its own article. Which article is not something the first letter can answer: it
|
||||
// is "a full house" and "a flush", but "four of a kind" and "two pair" take none,
|
||||
// and nobody has ever said "an ace high".
|
||||
test("every category takes the article a table would give it", () => {
|
||||
const said = str => HandRank.phrase(HandRank.evaluate5(hand(str)));
|
||||
assert.deepEqual([
|
||||
said("AS KS QS JS TS"),
|
||||
said("9H 8H 7H 6H 5H"),
|
||||
said("7C 7D 7H 7S 2C"),
|
||||
said("KC KD KH 3S 3C"),
|
||||
said("AD JD 8D 5D 2D"),
|
||||
said("9C 8D 7H 6S 5C"),
|
||||
said("QC QD QH 7S 2C"),
|
||||
said("AC AD 8H 8S 4C"),
|
||||
said("QC QD 9H 5S 2C"),
|
||||
said("AC JD 8H 5S 2C"),
|
||||
], [
|
||||
"a royal flush",
|
||||
"a straight flush, nine high",
|
||||
"four of a kind, sevens",
|
||||
"a full house, kings full of threes",
|
||||
"a flush, ace high",
|
||||
"a straight, nine high",
|
||||
"three of a kind, queens",
|
||||
"two pair, aces and eights",
|
||||
"a pair of queens",
|
||||
"ace high",
|
||||
]);
|
||||
});
|
||||
|
||||
test("with nothing to say it says something", () => {
|
||||
assert.equal(HandRank.phrase(null), "the best hand");
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ function load(name) {
|
||||
}
|
||||
|
||||
const Money = load("money");
|
||||
const HandRank = load("handRank");
|
||||
const PotCalculator = load("potCalculator");
|
||||
const SplitCalculator = load("splitCalculator");
|
||||
const Betting = load("betting");
|
||||
@@ -33,6 +34,15 @@ globalThis.HoldemState = HoldemState;
|
||||
// its methods reach for both. Loaded last so nothing it touches is still undefined.
|
||||
const ActionBar = load("actionBar");
|
||||
|
||||
// The picker's slot bookkeeping — which slots it is filling, what is already dealt —
|
||||
// is plain state work and testable here. Only render() and open() touch the DOM.
|
||||
const CardPicker = load("cardPicker");
|
||||
|
||||
// The wake lock's rule about when a table needs the screen is plain state work too.
|
||||
// Its acquire/release bookkeeping is testable here as well, against a stand-in for
|
||||
// navigator.wakeLock — see wakeLock.test.js.
|
||||
const WakeLock = load("wakeLock");
|
||||
|
||||
// ---- Table builders ----------------------------------------------------------
|
||||
|
||||
// A table with `seatCount` chairs where `seated` (a list of seat indexes) are dealt
|
||||
@@ -78,6 +88,7 @@ function contributions(state) {
|
||||
|
||||
module.exports = {
|
||||
Money,
|
||||
HandRank,
|
||||
Render,
|
||||
HandHistoryExport,
|
||||
PotCalculator,
|
||||
@@ -86,6 +97,8 @@ module.exports = {
|
||||
Betting,
|
||||
HoldemState,
|
||||
ActionBar,
|
||||
CardPicker,
|
||||
WakeLock,
|
||||
makeTable,
|
||||
chipsOnTable,
|
||||
contributions,
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
// What a shut sidebar panel says about itself.
|
||||
//
|
||||
// The sidebar is eight <details>, every one exactly 60px tall with nothing in it but
|
||||
// its own name — 480px of a phone's screen and 480px of a 660px desktop column, saying
|
||||
// "Standings", "Hands", "Waiting list" and nothing else. Whether the box balances,
|
||||
// whether anyone is waiting for a seat, how many hands have been played: the page had
|
||||
// all three worked out and charged a tap for each of them, on rows two thirds empty.
|
||||
//
|
||||
// So each one now carries the fact you would have opened it for. Two rules hold this
|
||||
// together, and both are checked here.
|
||||
//
|
||||
// A glance appears only when it has something a dealer would act on. Eight rows that
|
||||
// always say something are eight rows that say nothing again, and a row reading
|
||||
// "Nobody waiting" all night is the clutter this went in to remove.
|
||||
//
|
||||
// And a glance never says something the panel under it would contradict. It is the
|
||||
// panel's own sentence with the explanation taken off, so it has to be read off the
|
||||
// same state — never off the panel's DOM, which for a shut <details> isn't rendered
|
||||
// at all.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Render, HoldemState, makeTable } = require("./load");
|
||||
|
||||
const glances = state => Render.panelGlances(state);
|
||||
const textOf = (state, id) => (glances(state)[id] || {}).text;
|
||||
|
||||
// A table part-way through a night: money on it, so the bank check has an opinion.
|
||||
const night = () => {
|
||||
const state = makeTable({ seatCount: 9, seated: [0, 1, 2, 3, 4], stack: 200,
|
||||
smallBlind: 5, bigBlind: 10 });
|
||||
state.seats.slice(0, 5).forEach(p => { p.buyIns = 200; });
|
||||
return state;
|
||||
};
|
||||
|
||||
describe("the rows that always have something to say", () => {
|
||||
test("the table's own shape and stakes", () => {
|
||||
assert.equal(textOf(night(), "glanceTableSetup"), "9 seats · $5/$10");
|
||||
});
|
||||
|
||||
test("and the stakes drop out rather than reading as free", () => {
|
||||
// A table set up with no blinds yet is a real state — it is what the panel
|
||||
// opens on — and "9 seats · $0/$0" would be a claim about the game rather
|
||||
// than an admission that nobody has said yet.
|
||||
const state = night();
|
||||
state.smallBlind = 0;
|
||||
state.bigBlind = 0;
|
||||
assert.equal(textOf(state, "glanceTableSetup"), "9 seats");
|
||||
});
|
||||
|
||||
test("the chip values, as a range rather than a count of rows", () => {
|
||||
// "Are the values right for tonight's set" is the question this answers.
|
||||
// Five colours is the answer to a different one.
|
||||
assert.equal(textOf(night(), "glanceChips"), "5 colours · $1–$500");
|
||||
});
|
||||
|
||||
test("with a chip nobody has priced yet left out of the range", () => {
|
||||
// A blank row in the legend is a chip the dealer hasn't got to, not a
|
||||
// zero-dollar chip — and letting it in would read the range as starting at $0.
|
||||
const state = night();
|
||||
state.chipLegend.push({ id: "pink", color: "#ff88aa", value: 0 });
|
||||
assert.equal(textOf(state, "glanceChips"), "5 colours · $1–$500");
|
||||
});
|
||||
|
||||
test("and one chip is a value, not a range of one", () => {
|
||||
const state = night();
|
||||
state.chipLegend = [{ id: "red", color: "#c0392b", value: 5 }];
|
||||
assert.equal(textOf(state, "glanceChips"), "1 colour · $5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the bank check, which is the one worth a colour", () => {
|
||||
test("says so when the cash covers the chips", () => {
|
||||
const state = night();
|
||||
const { text, tone } = glances(state).glanceStandings;
|
||||
assert.equal(text, "Balanced");
|
||||
assert.equal(tone, "ok");
|
||||
});
|
||||
|
||||
test("and names the gap when it doesn't", () => {
|
||||
// Chips on the table that nobody paid for. The glance is the alarm and the
|
||||
// amount; which seat is holding them is a paragraph, and that is the half
|
||||
// that stays behind the tap.
|
||||
const state = night();
|
||||
state.seats[0].buyIns = 100;
|
||||
const { text, tone } = glances(state).glanceStandings;
|
||||
assert.equal(text, "Off by $100");
|
||||
assert.equal(tone, "warn");
|
||||
});
|
||||
|
||||
test("the same way round when the cash is the side that's over", () => {
|
||||
// One word covers both signs on purpose: the number and the alarm are what a
|
||||
// dealer acts on, and "short" against "over" is a direction they would have to
|
||||
// hold in their head to read. The panel says which way in a sentence.
|
||||
const state = night();
|
||||
state.seats[0].buyIns = 400;
|
||||
const { text, tone } = glances(state).glanceStandings;
|
||||
assert.equal(text, "Off by $200");
|
||||
assert.equal(tone, "warn");
|
||||
});
|
||||
|
||||
test("and stays quiet over a table nobody has bought into", () => {
|
||||
// The panel hides its own check here for the same reason. A glance reading
|
||||
// "Balanced" over an empty table is answering for a count never taken.
|
||||
const state = makeTable({ seatCount: 9, seated: [], smallBlind: 5, bigBlind: 10 });
|
||||
state.seats.forEach(p => { p.stack = null; p.buyIns = 0; });
|
||||
assert.equal(textOf(state, "glanceStandings"), "");
|
||||
assert.equal(glances(state).glanceStandings.tone, "");
|
||||
});
|
||||
|
||||
test("and agrees with the sentence the panel would print", () => {
|
||||
// The two are read off one call apiece and must never disagree — a glance
|
||||
// saying "Balanced" over a panel saying "$40 more in chips than cash taken"
|
||||
// is worse than no glance at all.
|
||||
[0, 100, 400].forEach(buyIn => {
|
||||
const state = night();
|
||||
state.seats[0].buyIns = buyIn;
|
||||
const bank = HoldemState.bankReconciliation(state);
|
||||
const { text, tone } = glances(state).glanceStandings;
|
||||
assert.equal(tone === "ok", bank.balanced, `buy-in ${buyIn}`);
|
||||
if (!bank.balanced) {
|
||||
assert.ok(text.includes(String(Math.abs(bank.difference))),
|
||||
`${text} doesn't carry the panel's own ${bank.difference}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("the rows that stay quiet until they have news", () => {
|
||||
test("nobody waiting, no note, no hands played yet", () => {
|
||||
const state = night();
|
||||
assert.equal(textOf(state, "glanceWaiting"), "");
|
||||
assert.equal(textOf(state, "glanceHands"), "");
|
||||
assert.equal(textOf(state, "glanceNotes"), "");
|
||||
});
|
||||
|
||||
test("and speak up the moment they do", () => {
|
||||
const state = night();
|
||||
state.waitingList = [{ id: "w1", name: "Nadia" }, { id: "w2", name: "Owen" }];
|
||||
state.handHistory = [{ handNumber: 1, pot: 120, payouts: [], ledger: [] }];
|
||||
assert.equal(textOf(state, "glanceWaiting"), "2 waiting");
|
||||
assert.equal(textOf(state, "glanceHands"), "1 hand");
|
||||
});
|
||||
|
||||
test("counting one of something as one of it", () => {
|
||||
const state = night();
|
||||
state.waitingList = [{ id: "w1", name: "Nadia" }];
|
||||
state.handHistory = [{ handNumber: 1, pot: 120, payouts: [], ledger: [] },
|
||||
{ handNumber: 2, pot: 80, payouts: [], ledger: [] }];
|
||||
assert.equal(textOf(state, "glanceWaiting"), "1 waiting");
|
||||
assert.equal(textOf(state, "glanceHands"), "2 hands");
|
||||
});
|
||||
|
||||
test("and a note shows which note it is, not that there is one", () => {
|
||||
// "Has notes" costs a tap to learn anything, which is the whole failure this
|
||||
// is fixing. The text is what identifies it; the row's own width ellipsises.
|
||||
const state = night();
|
||||
state.notes = "Sam owes Dee $20 from last night";
|
||||
assert.equal(textOf(state, "glanceNotes"), "Sam owes Dee $20 from last night");
|
||||
});
|
||||
|
||||
test("reading past the blank lines a note gets typed under", () => {
|
||||
// Notes get written as a list — a heading, a blank line, then the thing. The
|
||||
// first line of the string is regularly not the first line of the note.
|
||||
const state = night();
|
||||
state.notes = "\n \n Sam owes Dee $20\nand Ted owes Al $5";
|
||||
assert.equal(textOf(state, "glanceNotes"), "Sam owes Dee $20");
|
||||
});
|
||||
|
||||
test("and a note of nothing but whitespace is no note", () => {
|
||||
const state = night();
|
||||
state.notes = "\n\n \t \n";
|
||||
assert.equal(textOf(state, "glanceNotes"), "");
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the row is allowed to be", () => {
|
||||
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
|
||||
// row that silently keeps its old text for the rest of the night.
|
||||
const state = night();
|
||||
assert.deepEqual(Object.keys(glances(state)).sort(), [...ROWS].sort());
|
||||
assert.deepEqual([...Render.PANEL_GLANCES].sort(), [...ROWS].sort());
|
||||
});
|
||||
|
||||
test("and none of them is a paragraph", () => {
|
||||
// The row is 44px of a 320px column with a label already on it. Anything
|
||||
// long enough to need the panel's room belongs in the panel.
|
||||
const state = night();
|
||||
state.waitingList = [{ id: "w1", name: "Nadia" }];
|
||||
state.handHistory = [{ handNumber: 1, pot: 120, payouts: [], ledger: [] }];
|
||||
ROWS.filter(id => id !== "glanceNotes").forEach(id => {
|
||||
const text = textOf(state, id);
|
||||
assert.ok(text.length <= 24, `${id} is ${text.length} chars: "${text}"`);
|
||||
assert.ok(!text.includes("\n"), `${id} has a line break in it`);
|
||||
});
|
||||
});
|
||||
|
||||
test("including a note, which is trimmed to one line and left to the ellipsis", () => {
|
||||
// The one glance whose length isn't ours to choose. It must still be a single
|
||||
// line — a second one would make the row taller, and the row is what holds the
|
||||
// sidebar to eight equal slabs.
|
||||
const state = night();
|
||||
state.notes = "x".repeat(400) + "\nsecond line";
|
||||
const text = textOf(state, "glanceNotes");
|
||||
assert.ok(!text.includes("\n"));
|
||||
assert.equal(text, "x".repeat(400));
|
||||
});
|
||||
|
||||
test("and a state that isn't there yet asks for nothing", () => {
|
||||
// renderAll runs before the first load has finished on a cold open.
|
||||
assert.deepEqual(Render.panelGlances(null), {});
|
||||
assert.deepEqual(Render.panelGlances(undefined), {});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
// Just enough PNG to read pixels back out of a screenshot.
|
||||
//
|
||||
// The contrast audit needs to know what colour is actually behind a piece of text, and
|
||||
// the only honest answer is the one the browser painted. Walking the DOM and
|
||||
// compositing `background-color` up the ancestors gets it wrong wherever a gradient,
|
||||
// a shadow or a border is doing the painting: tried that first, and it reported the
|
||||
// gold dealer button as black-on-black at Lc 0.4 — a fault that does not exist, on an
|
||||
// element anyone can see is legible. Sampling the screenshot cannot make that mistake.
|
||||
//
|
||||
// zlib is in Node, so this needs no dependency. Screenshots are 8-bit and
|
||||
// non-interlaced; anything else throws rather than returning quietly wrong pixels.
|
||||
const zlib = require("node:zlib");
|
||||
|
||||
const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
function paeth(a, b, c) {
|
||||
const p = a + b - c;
|
||||
const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
|
||||
if (pa <= pb && pa <= pc) return a;
|
||||
return pb <= pc ? b : c;
|
||||
}
|
||||
|
||||
function decode(buffer) {
|
||||
if (!buffer.subarray(0, 8).equals(SIGNATURE)) throw new Error("not a PNG");
|
||||
|
||||
let width = 0, height = 0, depth = 0, colorType = 0, interlace = 0;
|
||||
const idat = [];
|
||||
|
||||
let at = 8;
|
||||
while (at < buffer.length) {
|
||||
const length = buffer.readUInt32BE(at);
|
||||
const type = buffer.toString("ascii", at + 4, at + 8);
|
||||
const data = buffer.subarray(at + 8, at + 8 + length);
|
||||
at += 12 + length;
|
||||
|
||||
if (type === "IHDR") {
|
||||
width = data.readUInt32BE(0);
|
||||
height = data.readUInt32BE(4);
|
||||
depth = data[8];
|
||||
colorType = data[9];
|
||||
interlace = data[12];
|
||||
} else if (type === "IDAT") {
|
||||
idat.push(data);
|
||||
} else if (type === "IEND") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (depth !== 8) throw new Error(`unsupported bit depth ${depth}`);
|
||||
if (interlace !== 0) throw new Error("interlaced PNG not supported");
|
||||
if (colorType !== 2 && colorType !== 6) throw new Error(`unsupported colour type ${colorType}`);
|
||||
|
||||
const channels = colorType === 6 ? 4 : 3;
|
||||
const raw = zlib.inflateSync(Buffer.concat(idat));
|
||||
const stride = width * channels;
|
||||
const out = Buffer.alloc(width * height * 4);
|
||||
|
||||
let prior = Buffer.alloc(stride);
|
||||
for (let y = 0; y < height; y++) {
|
||||
const filter = raw[y * (stride + 1)];
|
||||
const line = Buffer.from(raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1)));
|
||||
|
||||
for (let i = 0; i < stride; i++) {
|
||||
const left = i >= channels ? line[i - channels] : 0;
|
||||
const up = prior[i];
|
||||
const upLeft = i >= channels ? prior[i - channels] : 0;
|
||||
switch (filter) {
|
||||
case 0: break;
|
||||
case 1: line[i] = (line[i] + left) & 0xff; break;
|
||||
case 2: line[i] = (line[i] + up) & 0xff; break;
|
||||
case 3: line[i] = (line[i] + ((left + up) >> 1)) & 0xff; break;
|
||||
case 4: line[i] = (line[i] + paeth(left, up, upLeft)) & 0xff; break;
|
||||
default: throw new Error(`unknown filter ${filter} on row ${y}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (let x = 0; x < width; x++) {
|
||||
const from = x * channels;
|
||||
const to = (y * width + x) * 4;
|
||||
out[to] = line[from];
|
||||
out[to + 1] = line[from + 1];
|
||||
out[to + 2] = line[from + 2];
|
||||
out[to + 3] = channels === 4 ? line[from + 3] : 255;
|
||||
}
|
||||
prior = line;
|
||||
}
|
||||
|
||||
return { width, height, data: out };
|
||||
}
|
||||
|
||||
// Nearest pixel, clamped. A rect can sit a hair outside the shot after rounding, and
|
||||
// clamping is the right answer there rather than an exception or a black pixel.
|
||||
function pixelAt(image, x, y) {
|
||||
const px = Math.min(image.width - 1, Math.max(0, Math.round(x)));
|
||||
const py = Math.min(image.height - 1, Math.max(0, Math.round(y)));
|
||||
const at = (py * image.width + px) * 4;
|
||||
return [image.data[at], image.data[at + 1], image.data[at + 2]];
|
||||
}
|
||||
|
||||
module.exports = { decode, pixelAt };
|
||||
@@ -0,0 +1,178 @@
|
||||
// The screenshot reader, tested by building PNGs whose pixels are already known.
|
||||
//
|
||||
// This matters more than it looks. If a filter is decoded wrong the pixels are still
|
||||
// plausible colours — slightly off greys and greens, not obvious garbage — and the
|
||||
// contrast audit would report confident, wrong numbers about a page nobody would think
|
||||
// to re-check. So every filter type gets encoded here and read back.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const zlib = require("node:zlib");
|
||||
const { decode, pixelAt } = require("./png");
|
||||
|
||||
const CRC_TABLE = (() => {
|
||||
const table = new Int32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
table[n] = c;
|
||||
}
|
||||
return table;
|
||||
})();
|
||||
|
||||
function crc32(buf) {
|
||||
let c = 0xffffffff;
|
||||
for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function chunk(type, data) {
|
||||
const head = Buffer.alloc(8);
|
||||
head.writeUInt32BE(data.length, 0);
|
||||
head.write(type, 4, "ascii");
|
||||
const crc = Buffer.alloc(4);
|
||||
crc.writeUInt32BE(crc32(Buffer.concat([Buffer.from(type, "ascii"), data])), 0);
|
||||
return Buffer.concat([head, data, crc]);
|
||||
}
|
||||
|
||||
function paeth(a, b, c) {
|
||||
const p = a + b - c;
|
||||
const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
|
||||
if (pa <= pb && pa <= pc) return a;
|
||||
return pb <= pc ? b : c;
|
||||
}
|
||||
|
||||
// pixels: [y][x] = [r,g,b(,a)]. filter is applied to every row, which is legal PNG and
|
||||
// is exactly how each decoder branch gets exercised.
|
||||
function encode(pixels, filter = 0, channels = 4) {
|
||||
const height = pixels.length, width = pixels[0].length;
|
||||
const stride = width * channels;
|
||||
const rows = [];
|
||||
let prior = Buffer.alloc(stride);
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
const raw = Buffer.alloc(stride);
|
||||
for (let x = 0; x < width; x++) {
|
||||
for (let c = 0; c < channels; c++) {
|
||||
raw[x * channels + c] = pixels[y][x][c] !== undefined ? pixels[y][x][c] : 255;
|
||||
}
|
||||
}
|
||||
const filtered = Buffer.alloc(stride);
|
||||
for (let i = 0; i < stride; i++) {
|
||||
const left = i >= channels ? raw[i - channels] : 0;
|
||||
const up = prior[i];
|
||||
const upLeft = i >= channels ? prior[i - channels] : 0;
|
||||
switch (filter) {
|
||||
case 0: filtered[i] = raw[i]; break;
|
||||
case 1: filtered[i] = (raw[i] - left) & 0xff; break;
|
||||
case 2: filtered[i] = (raw[i] - up) & 0xff; break;
|
||||
case 3: filtered[i] = (raw[i] - ((left + up) >> 1)) & 0xff; break;
|
||||
case 4: filtered[i] = (raw[i] - paeth(left, up, upLeft)) & 0xff; break;
|
||||
default: throw new Error("bad filter");
|
||||
}
|
||||
}
|
||||
rows.push(Buffer.concat([Buffer.from([filter]), filtered]));
|
||||
prior = raw;
|
||||
}
|
||||
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(width, 0);
|
||||
ihdr.writeUInt32BE(height, 4);
|
||||
ihdr[8] = 8;
|
||||
ihdr[9] = channels === 4 ? 6 : 2;
|
||||
|
||||
return Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
chunk("IHDR", ihdr),
|
||||
chunk("IDAT", zlib.deflateSync(Buffer.concat(rows))),
|
||||
chunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
// Deliberately awkward: neighbouring values that differ, so a filter decoded with the
|
||||
// wrong neighbour produces a different answer rather than the same one by luck.
|
||||
const PIXELS = [
|
||||
[[20, 30, 40], [200, 10, 90], [18, 18, 18], [255, 255, 255]],
|
||||
[[0, 0, 0], [28, 108, 60], [108, 68, 39], [1, 2, 3]],
|
||||
[[240, 217, 168], [47, 122, 77], [200, 200, 200], [17, 17, 17]],
|
||||
];
|
||||
|
||||
describe("every filter type reads back exactly", () => {
|
||||
[0, 1, 2, 3, 4].forEach(filter => {
|
||||
test(`filter ${filter}`, () => {
|
||||
const image = decode(encode(PIXELS, filter));
|
||||
assert.equal(image.width, 4);
|
||||
assert.equal(image.height, 3);
|
||||
for (let y = 0; y < PIXELS.length; y++) {
|
||||
for (let x = 0; x < PIXELS[y].length; x++) {
|
||||
assert.deepEqual(pixelAt(image, x, y), PIXELS[y][x],
|
||||
`pixel ${x},${y} under filter ${filter}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("and a three-channel image reads back too", () => {
|
||||
const image = decode(encode(PIXELS, 4, 3));
|
||||
assert.deepEqual(pixelAt(image, 1, 2), [47, 122, 77]);
|
||||
});
|
||||
|
||||
test("including where Paeth has to choose between close neighbours", () => {
|
||||
// Written first to catch paeth(left, up, ...) decoded as paeth(up, left, ...),
|
||||
// on the assumption that the two differ on a tie. They don't, anywhere: the
|
||||
// predictor is symmetric in its first two arguments, checked over all 16,777,216
|
||||
// byte triples. The reason is that a tie between pa and pb requires
|
||||
// left + up == 2 * upLeft, and that is exactly the condition making pc zero — so
|
||||
// every tie is resolved by returning upLeft, whichever order the other two
|
||||
// arrived in. Swapping them is a no-op and no test can catch it, which is worth
|
||||
// knowing rather than papering over with a fixture that appears to.
|
||||
//
|
||||
// What this does catch is the mistake that is actually available: reading the
|
||||
// wrong neighbour. The values sit close together so a predictor that reaches one
|
||||
// pixel off produces a different answer rather than the same one by luck.
|
||||
const CLOSE = [
|
||||
[[100, 10, 20], [110, 40, 25]],
|
||||
[[90, 70, 30], [55, 95, 35]],
|
||||
];
|
||||
const image = decode(encode(CLOSE, 4));
|
||||
assert.deepEqual(pixelAt(image, 1, 1), [55, 95, 35]);
|
||||
assert.deepEqual(pixelAt(image, 0, 1), [90, 70, 30]);
|
||||
assert.deepEqual(pixelAt(image, 1, 0), [110, 40, 25]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sampling", () => {
|
||||
const image = decode(encode(PIXELS, 0));
|
||||
|
||||
test("rounds to the nearest pixel", () => {
|
||||
assert.deepEqual(pixelAt(image, 0.4, 0.4), [20, 30, 40]);
|
||||
assert.deepEqual(pixelAt(image, 0.6, 0.4), [200, 10, 90]);
|
||||
});
|
||||
|
||||
test("clamps rather than throwing or reading black", () => {
|
||||
// A rect can sit a hair outside the shot once it's been rounded, and a silent
|
||||
// black pixel there would read as a contrast failure that isn't one.
|
||||
assert.deepEqual(pixelAt(image, -5, -5), [20, 30, 40]);
|
||||
assert.deepEqual(pixelAt(image, 999, 999), [17, 17, 17]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what it refuses", () => {
|
||||
test("something that isn't a PNG at all", () => {
|
||||
assert.throws(() => decode(Buffer.from("not a png at all, honestly")), /not a PNG/);
|
||||
});
|
||||
|
||||
test("an interlaced one, rather than returning scrambled pixels", () => {
|
||||
const png = encode(PIXELS, 0);
|
||||
// Byte 12 of the IHDR data: 8 signature + 8 chunk header + 12 = interlace flag.
|
||||
const broken = Buffer.from(png);
|
||||
broken[8 + 8 + 12] = 1;
|
||||
assert.throws(() => decode(broken), /interlaced/);
|
||||
});
|
||||
|
||||
test("a colour type it cannot read", () => {
|
||||
const broken = Buffer.from(encode(PIXELS, 0));
|
||||
broken[8 + 8 + 9] = 3; // palette
|
||||
assert.throws(() => decode(broken), /colour type/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
// Who can win each pot, said in the fewest names that answer it.
|
||||
//
|
||||
// The breakdown used to list every eligible player for every pot. Pot eligibility is
|
||||
// nested — potCalculator.test.js checks that across 20,000 tables — so the main pot's
|
||||
// line was every name at the table and each side pot repeated it minus one. The lines
|
||||
// that were too long to fit were exactly the ones nobody needed spelled out, and the
|
||||
// short ones at the bottom were the side pots a dealer actually announces by name.
|
||||
//
|
||||
// Measured on an iPhone 14 before this: one all-in five-handed already clipped the main
|
||||
// pot, and twelve-handed the first row wanted 521px of a 254px slot, with no count and
|
||||
// no title — a dealer reading "Alexandra…" could not tell whether three people or
|
||||
// eleven were eligible, which is the number the showdown turns on.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Render } = require("./load");
|
||||
|
||||
// Names are "P<id>" so a test reads as ids and the joining is still exercised.
|
||||
const nameFor = id => `P${id}`;
|
||||
const say = (eligible, everyone) => Render.describeEligible(eligible, everyone, nameFor);
|
||||
|
||||
describe("the eligible line", () => {
|
||||
test("says so plainly when the whole table can win it", () => {
|
||||
// The main pot, every hand. Twelve names to say "nobody is excluded yet" was
|
||||
// the single longest line in the component and the least informative.
|
||||
assert.equal(say(["a", "b", "c"], ["a", "b", "c"]), "all 3");
|
||||
assert.equal(say(["a"], ["a"]), "all 1");
|
||||
});
|
||||
|
||||
test("names who is out when that is the shorter list", () => {
|
||||
const table = ["a", "b", "c", "d", "e", "f", "g", "h"];
|
||||
assert.equal(say(["b", "c", "d", "e", "f", "g", "h"], table), "7 in, not Pa");
|
||||
assert.equal(say(["c", "d", "e", "f", "g", "h"], table), "6 in, not Pa and Pb");
|
||||
assert.equal(say(["d", "e", "f", "g", "h"], table), "5 in, not Pa, Pb and Pc");
|
||||
});
|
||||
|
||||
test("names who is in when that is the shorter list", () => {
|
||||
// The far side pots, which are the ones a dealer announces by name: "side pot
|
||||
// is 80 between Ben and Carlos".
|
||||
const table = ["a", "b", "c", "d", "e", "f", "g", "h"];
|
||||
assert.equal(say(["g", "h"], table), "2 in, Pg and Ph");
|
||||
assert.equal(say(["h"], table), "1 in, Ph");
|
||||
assert.equal(say(["f", "g", "h"], table), "3 in, Pf, Pg and Ph");
|
||||
});
|
||||
|
||||
test("and when the two are the same length, says who is in", () => {
|
||||
// A tie is not a reason to make the dealer do the subtraction: "who can win
|
||||
// this" is the question, so answer it directly when it costs nothing.
|
||||
const table = ["a", "b", "c", "d"];
|
||||
assert.equal(say(["c", "d"], table), "2 in, Pc and Pd");
|
||||
});
|
||||
|
||||
test("leads with the count, because it is the tail that gets cut off", () => {
|
||||
// .potLayerWho is right-aligned with an ellipsis, so a line too long for a phone
|
||||
// loses its end. Every form has to begin with the number of hands to ask for.
|
||||
const table = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l"];
|
||||
[table, ["c", "d", "e", "f", "g", "h", "i", "j", "k", "l"], ["k", "l"]]
|
||||
.forEach(eligible => assert.match(say(eligible, table), /^(all \d+|\d+ in,)/,
|
||||
`"${say(eligible, table)}" buries the count where a narrow screen can eat it`));
|
||||
});
|
||||
|
||||
test("never spells out more than half the table", () => {
|
||||
// The point of picking the shorter side. Nested eligibility makes the two lists
|
||||
// complements, so this is a guarantee rather than a hope.
|
||||
const table = Array.from({ length: 12 }, (_, i) => `s${i}`);
|
||||
for (let inCount = 1; inCount <= 12; inCount++) {
|
||||
const line = say(table.slice(0, inCount), table);
|
||||
const named = (line.match(/P/g) || []).length;
|
||||
assert.ok(named <= 6, `${inCount} of 12 named ${named} people: "${line}"`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// A stub rather than a DOM: the file has no jsdom and does not want one. Enough of an
|
||||
// element to answer what the row is made of.
|
||||
const fakeDom = () => {
|
||||
const make = () => ({
|
||||
className: "", textContent: "", title: "", children: [],
|
||||
classList: { add() {} },
|
||||
appendChild(child) { this.children.push(child); return child; },
|
||||
});
|
||||
global.document = { createElement: make };
|
||||
return () => { delete global.document; };
|
||||
};
|
||||
|
||||
describe("the row it builds", () => {
|
||||
const pots = [
|
||||
{ label: "Main pot", amount: 300, eligible: ["a", "b", "c", "d"] },
|
||||
{ label: "Side pot 1", amount: 150, eligible: ["c", "d"] },
|
||||
];
|
||||
const textOf = row => row.children.map(c => c.textContent);
|
||||
|
||||
test("carries the full list in a title, for anyone with a pointer", () => {
|
||||
const undo = fakeDom();
|
||||
try {
|
||||
const wrap = Render.buildPotBreakdown(pots, [], nameFor);
|
||||
const who = wrap.children.map(row => row.children[2]);
|
||||
assert.equal(who[0].title, "Pa, Pb, Pc, Pd");
|
||||
assert.equal(who[1].title, "Pc, Pd");
|
||||
// The visible text is the short form; the title is the long one. A tooltip
|
||||
// is not a mechanism a keyboard reaches, which is why it is the extra and
|
||||
// not the answer.
|
||||
assert.equal(who[0].textContent, "all 4");
|
||||
assert.equal(who[1].textContent, "2 in, Pc and Pd");
|
||||
} finally { undo(); }
|
||||
});
|
||||
|
||||
test("puts the label, the money and the people in that order", () => {
|
||||
const undo = fakeDom();
|
||||
try {
|
||||
const wrap = Render.buildPotBreakdown(pots, [], nameFor);
|
||||
assert.deepEqual(textOf(wrap.children[0]), ["Main pot", "$300", "all 4"]);
|
||||
} finally { undo(); }
|
||||
});
|
||||
|
||||
test("and an uncalled bet still reads as money going back", () => {
|
||||
// Not an eligibility question — nobody is competing for it — so it keeps its
|
||||
// own wording and gets no title to expand.
|
||||
const undo = fakeDom();
|
||||
try {
|
||||
const wrap = Render.buildPotBreakdown(pots, [{ id: "a", amount: 25 }], nameFor);
|
||||
const row = wrap.children[2];
|
||||
assert.deepEqual(textOf(row), ["Uncalled", "$25", "back to Pa"]);
|
||||
assert.equal(row.children[2].title, "");
|
||||
} finally { undo(); }
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
// The bar's pot list is not allowed to grow with the table.
|
||||
//
|
||||
// #actionBar is fixed over the felt, so every pixel it takes is a pixel the table
|
||||
// loses — and this list took one row per all-in with no ceiling. Measured on a 360x640
|
||||
// phone, five players idle: bar 151px. Six all-in: 303px. Twelve all-in: 411px, which
|
||||
// is 64% of the screen, leaving 96px of felt to draw twelve seats in. A fixed overlay
|
||||
// whose height depends on the state is a fault whatever it is showing, so the cap is
|
||||
// checked here rather than left to whoever next adds an all-in to the audit sweep.
|
||||
//
|
||||
// The two ends survive because they are the two that are live. Side pots are paid from
|
||||
// the newest one back down to the main pot, so the top layer is the next thing that
|
||||
// happens and the main pot is the last, and those are the two a dealer says out loud
|
||||
// while the chips are stacked — "main pot 7,200, side pot 4,500". The layers between
|
||||
// are the middle of that count-down, wanted at showdown, which is the pot wizard, which
|
||||
// passes no cap and still lists all of them.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Render, Money, PotCalculator } = require("./load");
|
||||
|
||||
const nameFor = id => `P${id}`;
|
||||
|
||||
// The same stub potBreakdown.test.js uses — enough of an element to say what the row
|
||||
// is made of, and no jsdom in a suite that runs in a second.
|
||||
const fakeDom = () => {
|
||||
const make = () => ({
|
||||
className: "", textContent: "", title: "", children: [],
|
||||
classList: { add() {} },
|
||||
appendChild(child) { this.children.push(child); return child; },
|
||||
});
|
||||
global.document = { createElement: make };
|
||||
return () => { delete global.document; };
|
||||
};
|
||||
|
||||
// A table where everyone is all-in for a different amount, which is the shape that
|
||||
// makes the most pot layers — the worst case and the one that was measured. `count`
|
||||
// players ladder into `count - 1` layers: two of them share a single undivided pot and
|
||||
// the breakdown declines to draw at all, which is why nothing below three appears here.
|
||||
const laddered = count => PotCalculator.layers(
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
id: `s${i}`, contribution: 100 + i * 130, folded: false,
|
||||
})));
|
||||
|
||||
const build = (pots, options) => {
|
||||
const undo = fakeDom();
|
||||
try {
|
||||
const wrap = Render.buildPotBreakdown(pots, [], nameFor, null, options);
|
||||
return wrap.children.map(row => ({
|
||||
className: row.className,
|
||||
label: row.children[0].textContent,
|
||||
amount: row.children[1].textContent,
|
||||
who: row.children[2].textContent,
|
||||
title: row.children[2].title,
|
||||
}));
|
||||
} finally { undo(); }
|
||||
};
|
||||
|
||||
describe("the bar's copy of the pot list", () => {
|
||||
test("stops growing once there are more pots than it has room for", () => {
|
||||
// The property that matters: rows are a function of the cap, not of the table.
|
||||
// Twelve-handed and six-handed have to render the same number of lines.
|
||||
for (let players = 3; players <= 12; players++) {
|
||||
const rows = build(laddered(players), { maxLayers: 4 });
|
||||
assert.ok(rows.length <= 4,
|
||||
`${players} all-in drew ${rows.length} rows; the bar is sized for 4`);
|
||||
}
|
||||
});
|
||||
|
||||
test("and the number of rows never depends on how many people are all-in", () => {
|
||||
const wide = [8, 9, 10, 11, 12].map(n => build(laddered(n), { maxLayers: 4 }).length);
|
||||
assert.deepEqual(wide, [3, 3, 3, 3, 3],
|
||||
"past the cap every table should draw main, the elision and the top pot");
|
||||
});
|
||||
|
||||
test("keeps the two pots a dealer actually announces", () => {
|
||||
const pots = laddered(12);
|
||||
const rows = build(pots, { maxLayers: 4 });
|
||||
assert.equal(rows[0].label, pots[0].label, "the main pot is the last one paid");
|
||||
assert.equal(rows[rows.length - 1].label, pots[pots.length - 1].label,
|
||||
"the newest side pot is the first one paid");
|
||||
});
|
||||
|
||||
test("and leaves them in the order the pots were made", () => {
|
||||
const rows = build(laddered(12), { maxLayers: 4 });
|
||||
assert.match(rows[1].className, /potLayerMore/,
|
||||
"the elision belongs where the gap is, not appended after the list");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the money in the rows that were elided", () => {
|
||||
test("is still on the screen, added up", () => {
|
||||
// The one thing a summary row must not do is lose chips. Whatever is drawn has
|
||||
// to account for every pot, or a dealer counting the announcement against the
|
||||
// chips in the middle finds a shortfall the tool caused.
|
||||
for (let players = 3; players <= 12; players++) {
|
||||
const pots = laddered(players);
|
||||
const total = pots.reduce((sum, pot) => sum + pot.amount, 0);
|
||||
const drawn = build(pots, { maxLayers: 4 })
|
||||
.reduce((sum, row) => sum + Number(row.amount.replace(/[^0-9.]/g, "")), 0);
|
||||
assert.equal(drawn, total,
|
||||
`${players} all-in: rows add to ${drawn}, the pots add to ${total}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("and says how many it stands for", () => {
|
||||
const rows = build(laddered(12), { maxLayers: 4 });
|
||||
const pots = laddered(12);
|
||||
assert.equal(rows[1].label, `${pots.length - 2} more side pots`);
|
||||
});
|
||||
|
||||
test("with the list itself in a title, for anyone with a pointer", () => {
|
||||
const pots = laddered(12);
|
||||
const rows = build(pots, { maxLayers: 4 });
|
||||
pots.slice(1, -1).forEach(pot => {
|
||||
assert.ok(rows[1].title.includes(pot.label),
|
||||
`${pot.label} vanished without trace`);
|
||||
assert.ok(rows[1].title.includes(Money.format(pot.amount)),
|
||||
`${pot.label}'s amount vanished without trace`);
|
||||
});
|
||||
});
|
||||
|
||||
test("and points at where the whole list is", () => {
|
||||
// The title is a pointer affordance, so it cannot be the answer on a phone. The
|
||||
// visible text has to say where the rest lives.
|
||||
assert.match(build(laddered(12), { maxLayers: 4 })[1].who, /resolve/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when eliding would not buy anything", () => {
|
||||
test("a list that fits is left alone", () => {
|
||||
for (let players = 3; players <= 5; players++) {
|
||||
const pots = laddered(players);
|
||||
assert.ok(pots.length <= 4, "this loop is meant to cover the fitting sizes");
|
||||
const rows = build(pots, { maxLayers: 4 });
|
||||
assert.equal(rows.length, pots.length);
|
||||
assert.deepEqual(rows.map(r => r.label), pots.map(p => p.label));
|
||||
}
|
||||
});
|
||||
|
||||
test("and a single middle layer is not replaced by a line saying there is one", () => {
|
||||
// At exactly the cap the middle is one row, and swapping one row for one row is
|
||||
// pure indirection: the dealer loses an amount and gains a sentence.
|
||||
const pots = laddered(5);
|
||||
assert.equal(pots.length, 4, "five all-in ladder into exactly the cap");
|
||||
const rows = build(pots, { maxLayers: 4 });
|
||||
assert.equal(rows.length, 4);
|
||||
assert.ok(!rows.some(r => /potLayerMore/.test(r.className)));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the wizard, which has room", () => {
|
||||
test("still gets every layer", () => {
|
||||
// Same builder, no cap — this is the announcement read out before anybody shows
|
||||
// a hand, and it is in a dialog that scrolls. Capping it there would hide the
|
||||
// pots at the moment they are being paid.
|
||||
const pots = laddered(12);
|
||||
const rows = build(pots, undefined);
|
||||
assert.equal(rows.length, pots.length);
|
||||
assert.ok(!rows.some(r => /potLayerMore/.test(r.className)));
|
||||
});
|
||||
});
|
||||
|
||||
// ---- And smaller still once nobody can act -----------------------------------
|
||||
//
|
||||
// The list is decision support. A player working out whether to call is owed the side
|
||||
// pot they would be calling into, and that is worth three rows of a fixed bar.
|
||||
//
|
||||
// Once everyone is all-in there is no decision left to support: the only thing that
|
||||
// happens for the rest of the hand is the dealer turning cards. The list went on
|
||||
// standing there anyway, and it is not free — measured at 360x640 with twelve players
|
||||
// all-in, the bar was 267px of a 640px window. That is 42%, against the sweep's own
|
||||
// "persistent chrome under 30%", and the felt pays it on the exact table shape that has
|
||||
// the most seats to fit into what is left.
|
||||
//
|
||||
// So during a run-out it collapses to the main pot and one line for everything else.
|
||||
// The main pot stays because it is the number a dealer says out loud; the layering is
|
||||
// what the pot wizard is for, and the summary row has always said so in as many words.
|
||||
describe("the list during a run-out, when nobody has a decision left", () => {
|
||||
test("is two rows however many people are all-in", () => {
|
||||
const rows = [3, 6, 9, 12].map(n => build(laddered(n), { maxLayers: 4, summarise: true }).length);
|
||||
assert.deepEqual(rows, [2, 2, 2, 2]);
|
||||
});
|
||||
|
||||
test("and that is fewer than the same table gets while the betting is live", () => {
|
||||
// The point of the change, stated as a comparison rather than a constant, so it
|
||||
// still means something if the ordinary cap is ever retuned.
|
||||
const live = build(laddered(12), { maxLayers: 4 }).length;
|
||||
const runout = build(laddered(12), { maxLayers: 4, summarise: true }).length;
|
||||
assert.ok(runout < live, `a run-out should be shorter than live betting (${runout} vs ${live})`);
|
||||
});
|
||||
|
||||
test("still names the main pot, which is the one said out loud", () => {
|
||||
const pots = laddered(12);
|
||||
const rows = build(pots, { maxLayers: 4, summarise: true });
|
||||
assert.equal(rows[0].label, pots[0].label);
|
||||
assert.equal(rows[0].amount, Money.format(pots[0].amount));
|
||||
});
|
||||
|
||||
test("and still accounts for every chip", () => {
|
||||
// The same rule as the elision above, and the one a summary row must not break:
|
||||
// a dealer counting the announcement against the chips in the middle must not
|
||||
// find a shortfall the tool invented.
|
||||
for (let players = 3; players <= 12; players++) {
|
||||
const pots = laddered(players);
|
||||
const total = pots.reduce((sum, pot) => sum + pot.amount, 0);
|
||||
const drawn = build(pots, { maxLayers: 4, summarise: true })
|
||||
.reduce((sum, row) => sum + Number(row.amount.replace(/[^0-9.]/g, "")), 0);
|
||||
assert.equal(drawn, total,
|
||||
`${players} all-in: rows add to ${drawn}, the pots add to ${total}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("says how many side pots it stands for, and counts them properly at one", () => {
|
||||
const many = build(laddered(12), { maxLayers: 4, summarise: true });
|
||||
assert.equal(many[1].label, `${laddered(12).length - 1} side pots`);
|
||||
|
||||
// Three players ladder into two pots: a main and exactly one side.
|
||||
const one = build(laddered(3), { maxLayers: 4, summarise: true });
|
||||
assert.equal(one[1].label, "1 side pot", "a single side pot is not '1 side pots'");
|
||||
});
|
||||
|
||||
test("and points at where the full list actually is", () => {
|
||||
const rows = build(laddered(12), { maxLayers: 4, summarise: true });
|
||||
assert.match(rows[1].who, /resolve/,
|
||||
"the row that hides the layers has to say where they went");
|
||||
assert.match(rows[1].title, /Side pot/,
|
||||
"and a pointer still gets the full list without resolving anything");
|
||||
});
|
||||
});
|
||||
@@ -122,3 +122,63 @@ describe("PotCalculator.resolveRound", () => {
|
||||
assert.throws(() => PotCalculator.resolveRound(entries, ["ghost"]), /No valid winners/);
|
||||
});
|
||||
});
|
||||
|
||||
// The pots come out of layers() in one order and with one relationship between them,
|
||||
// and the action bar's wording now depends on both. If eligibility were not nested,
|
||||
// "8 in, not Mike and Bob" would be a lie — there would be someone eligible for a side
|
||||
// pot who was never eligible for the main one, and naming the complement would hide
|
||||
// them. It is a property of how the layers are built rather than an accident of the
|
||||
// examples above, so it is checked as one.
|
||||
describe("pot eligibility is nested", () => {
|
||||
function rng(seed) {
|
||||
let s = seed >>> 0;
|
||||
return () => {
|
||||
s = (s * 1664525 + 1013904223) >>> 0;
|
||||
return s / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
test("every side pot's players are a strict subset of the pot below it", () => {
|
||||
const random = rng(20260905);
|
||||
let multiPotTables = 0;
|
||||
let pairs = 0;
|
||||
|
||||
for (let table = 0; table < 20000; table++) {
|
||||
const n = 2 + Math.floor(random() * 11);
|
||||
const players = Array.from({ length: n }, (_, i) => p(
|
||||
`p${i}`,
|
||||
// A sixth of the table never puts anything in, which is a table mid-deal.
|
||||
random() < 1 / 6 ? 0 : (1 + Math.floor(random() * 8)) * 25,
|
||||
random() < 0.2));
|
||||
|
||||
const layers = PotCalculator.layers(players);
|
||||
if (layers.length > 1) multiPotTables++;
|
||||
|
||||
for (let i = 1; i < layers.length; i++) {
|
||||
const below = layers[i - 1].eligible;
|
||||
const belowSet = new Set(below);
|
||||
const here = layers[i].eligible;
|
||||
pairs++;
|
||||
assert.ok(here.every(id => belowSet.has(id)),
|
||||
`table ${table}: ${layers[i].label} has a player ${layers[i - 1].label} does not\n` +
|
||||
JSON.stringify(players));
|
||||
assert.ok(here.length < below.length,
|
||||
`table ${table}: ${layers[i].label} is no smaller than ${layers[i - 1].label} — ` +
|
||||
`layers with the same eligibility are supposed to be merged into one pot\n` +
|
||||
JSON.stringify(players));
|
||||
}
|
||||
}
|
||||
|
||||
// Guards the generator rather than the code: a shape that stopped producing side
|
||||
// pots would make every assertion above vacuous and the test would still pass.
|
||||
assert.ok(multiPotTables > 10000, `only ${multiPotTables} tables had a side pot`);
|
||||
assert.ok(pairs > 20000, `only ${pairs} adjacent pairs to check`);
|
||||
});
|
||||
|
||||
test("so the main pot is everyone still live, and it is the widest", () => {
|
||||
const layers = PotCalculator.layers([p("a", 100), p("b", 40), p("c", 100), p("d", 25)]);
|
||||
assert.deepEqual(layers[0].eligible.sort(), ["a", "b", "c", "d"]);
|
||||
layers.slice(1).forEach(layer =>
|
||||
assert.ok(layer.eligible.length < layers[0].eligible.length));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
// Reading the showdown: the picker that takes the cards in, and the pot maths that
|
||||
// pays somebody on the strength of them.
|
||||
//
|
||||
// The evaluator is tested to death next door in handRank.test.js. What is tested here
|
||||
// is the join — the two places where a correct ranking can still pay the wrong player:
|
||||
// a side pot, where the question is asked again of a smaller field, and a card entered
|
||||
// in two places, where the ranking is of a deck that does not exist.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { CardPicker, HandRank, HoldemState, PotCalculator, Money, makeTable } = require("./load");
|
||||
|
||||
describe("the picker, filling the board", () => {
|
||||
const table = (board = ["", "", "", "", ""]) => {
|
||||
const state = makeTable({ seatCount: 4, stack: 100 });
|
||||
state.board = board;
|
||||
return state;
|
||||
};
|
||||
|
||||
// The sheet's own drawing is the DOM's business; the slot bookkeeping under it is
|
||||
// not, and that is what these ask about.
|
||||
const headless = () => { CardPicker.render = () => {}; CardPicker.close = () => {}; };
|
||||
|
||||
const onBoard = (state, cardIndex = 0, limit = 5) => {
|
||||
headless();
|
||||
CardPicker.state = state;
|
||||
CardPicker.hooks = { onPick: () => {} };
|
||||
CardPicker.target = "board";
|
||||
CardPicker.boardLimit = limit;
|
||||
CardPicker.cardIndex = cardIndex;
|
||||
return CardPicker;
|
||||
};
|
||||
|
||||
const onSeat = (state, seatIndex, cardIndex = 0) => {
|
||||
headless();
|
||||
CardPicker.state = state;
|
||||
CardPicker.hooks = { onPick: () => {} };
|
||||
CardPicker.target = "seat";
|
||||
CardPicker.seatIndex = seatIndex;
|
||||
CardPicker.cardIndex = cardIndex;
|
||||
return CardPicker;
|
||||
};
|
||||
|
||||
test("it only offers the slots that have actually been turned", () => {
|
||||
const state = table();
|
||||
assert.equal(onBoard(state, 0, 3).slotValues().length, 3, "the flop is three");
|
||||
assert.equal(onBoard(state, 0, 4).slotValues().length, 4, "the turn makes four");
|
||||
assert.equal(onBoard(state, 0, 5).slotValues().length, 5);
|
||||
});
|
||||
|
||||
test("a card goes into the board, not into a seat", () => {
|
||||
const state = table();
|
||||
onBoard(state, 1, 3).assign("KD");
|
||||
assert.deepEqual(state.board, ["", "KD", "", "", ""]);
|
||||
assert.deepEqual(state.seats[0].cards, ["", ""]);
|
||||
});
|
||||
|
||||
test("it walks on to the next empty slot, so a flop is three taps", () => {
|
||||
const state = table();
|
||||
const picker = onBoard(state, 0, 3);
|
||||
picker.assign("KD");
|
||||
assert.equal(picker.cardIndex, 1);
|
||||
picker.assign("9C");
|
||||
assert.equal(picker.cardIndex, 2);
|
||||
picker.assign("4S");
|
||||
assert.deepEqual(state.board.slice(0, 3), ["KD", "9C", "4S"]);
|
||||
});
|
||||
|
||||
test("and it does not walk past the slots it was given", () => {
|
||||
// Filling the turn must not run on to the river: that card is still in the deck.
|
||||
const state = table(["KD", "9C", "4S", "", ""]);
|
||||
const picker = onBoard(state, 3, 4);
|
||||
picker.assign("AH");
|
||||
assert.deepEqual(state.board, ["KD", "9C", "4S", "AH", ""]);
|
||||
});
|
||||
|
||||
test("clearing a slot empties that one and nothing else", () => {
|
||||
const state = table(["KD", "9C", "4S", "", ""]);
|
||||
onBoard(state, 1, 3).clearSlot();
|
||||
assert.deepEqual(state.board, ["KD", "", "4S", "", ""]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a card cannot be in two places", () => {
|
||||
const table = () => {
|
||||
const state = makeTable({ seatCount: 4, stack: 100 });
|
||||
state.board = ["KD", "9C", "4S", "", ""];
|
||||
state.seats[0].cards = ["AH", "AS"];
|
||||
state.seats[1].cards = ["", ""];
|
||||
return state;
|
||||
};
|
||||
|
||||
const picker = (state, setup) => {
|
||||
CardPicker.state = state;
|
||||
CardPicker.hooks = { onPick: () => {} };
|
||||
setup();
|
||||
return CardPicker;
|
||||
};
|
||||
|
||||
test("the board's cards are unavailable to a hand", () => {
|
||||
// The half of this that did not exist before: the picker knew about every
|
||||
// player's cards and nothing about the middle of the table, so the king of
|
||||
// diamonds on the flop could be dealt to somebody as well.
|
||||
const state = table();
|
||||
const used = picker(state, () => {
|
||||
CardPicker.target = "seat";
|
||||
CardPicker.seatIndex = 1;
|
||||
CardPicker.cardIndex = 0;
|
||||
}).cardsInUse();
|
||||
assert.equal(used.get("KD"), "the board");
|
||||
assert.equal(used.get("9C"), "the board");
|
||||
assert.ok(used.has("AH"), "and the other players' are still in there");
|
||||
});
|
||||
|
||||
test("and a hand's cards are unavailable to the board", () => {
|
||||
const state = table();
|
||||
const used = picker(state, () => {
|
||||
CardPicker.target = "board";
|
||||
CardPicker.boardLimit = 5;
|
||||
CardPicker.cardIndex = 3;
|
||||
}).cardsInUse();
|
||||
assert.ok(used.has("AH"), "the ace in seat 1's hand");
|
||||
assert.ok(used.has("KD"), "and the rest of the board");
|
||||
});
|
||||
|
||||
test("but the slot being edited is not competing with itself", () => {
|
||||
const state = table();
|
||||
const used = picker(state, () => {
|
||||
CardPicker.target = "board";
|
||||
CardPicker.boardLimit = 3;
|
||||
CardPicker.cardIndex = 0;
|
||||
}).cardsInUse();
|
||||
assert.equal(used.has("KD"), false, "re-picking what is already there is not a duplicate");
|
||||
assert.equal(used.has("9C"), true);
|
||||
});
|
||||
|
||||
test("the same goes for a seat's own slot", () => {
|
||||
const state = table();
|
||||
const used = picker(state, () => {
|
||||
CardPicker.target = "seat";
|
||||
CardPicker.seatIndex = 0;
|
||||
CardPicker.cardIndex = 0;
|
||||
}).cardsInUse();
|
||||
assert.equal(used.has("AH"), false);
|
||||
assert.equal(used.has("AS"), true, "the player's other card still is");
|
||||
});
|
||||
});
|
||||
|
||||
describe("who wins a side pot", () => {
|
||||
// The case a single ranking of the whole table gets wrong. The best hand takes the
|
||||
// main pot and is then out of the question: the side pot goes to the best of who is
|
||||
// left, which is a different answer, and it is the answer the wizard has to ask for
|
||||
// again each round rather than reading off one list.
|
||||
//
|
||||
// Three players, and the short stack has the best hand of the three.
|
||||
const board = ["KD", "9C", "4S", "AH", "2H"];
|
||||
const hands = {
|
||||
short: ["9H", "9S"], // trips — the best hand at the table
|
||||
big: ["AS", "KC"], // two pair, aces and kings
|
||||
other: ["AC", "3D"], // a pair of aces
|
||||
};
|
||||
|
||||
const entries = () => PotCalculator.buildEntries([
|
||||
{ id: "short", contribution: 20, folded: false },
|
||||
{ id: "big", contribution: 100, folded: false },
|
||||
{ id: "other", contribution: 100, folded: false },
|
||||
]);
|
||||
|
||||
// What the wizard does: read the hands still in for *this* round.
|
||||
const readRound = state => HandRank.showdown(board,
|
||||
PotCalculator.getContenders(state).map(c => ({ id: c.id, cards: hands[c.id] })));
|
||||
|
||||
test("the short stack takes the main pot with the best hand", () => {
|
||||
const round = readRound(entries());
|
||||
assert.equal(round.complete, true);
|
||||
assert.deepEqual(round.winners, ["short"]);
|
||||
});
|
||||
|
||||
test("and the side pot goes to the best of the two who are left", () => {
|
||||
const first = PotCalculator.resolveRound(entries(), ["short"]);
|
||||
// 20 from each of three: the short stack is paid 60 and is out of it.
|
||||
assert.equal(Money.round(first.perWinner[0].amount), 60);
|
||||
|
||||
const round = readRound(first.updatedEntries);
|
||||
assert.deepEqual(round.winners, ["big"],
|
||||
"not the trips, which are no longer eligible for this money");
|
||||
assert.equal(round.players.length, 2, "and the short stack is not even offered");
|
||||
|
||||
const second = PotCalculator.resolveRound(first.updatedEntries, ["big"]);
|
||||
assert.equal(Money.round(second.perWinner[0].amount), 160);
|
||||
});
|
||||
|
||||
test("every chip is accounted for across the two rounds", () => {
|
||||
const first = PotCalculator.resolveRound(entries(), ["short"]);
|
||||
const second = PotCalculator.resolveRound(first.updatedEntries, ["big"]);
|
||||
assert.equal(Money.round(first.payoutPool + second.payoutPool), 220);
|
||||
assert.equal(PotCalculator.getContenders(second.updatedEntries).length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what it refuses to answer", () => {
|
||||
const board = ["KD", "9C", "4S", "AH", "2H"];
|
||||
|
||||
test("a folded player's cards are not in the running", () => {
|
||||
// They are not a contender, so they are never handed in — which means a hand
|
||||
// that would have won cannot take a pot its owner threw away.
|
||||
const table = PotCalculator.buildEntries([
|
||||
{ id: "live", contribution: 50, folded: false },
|
||||
{ id: "mucked", contribution: 50, folded: true },
|
||||
]);
|
||||
const contenders = PotCalculator.getContenders(table);
|
||||
assert.deepEqual(contenders.map(c => c.id), ["live"]);
|
||||
|
||||
const round = HandRank.showdown(board, contenders.map(c => ({ id: c.id, cards: ["7C", "5D"] })));
|
||||
assert.deepEqual(round.winners, ["live"], "even holding nothing");
|
||||
});
|
||||
|
||||
test("one hand not turned over and it hands the question back", () => {
|
||||
const round = HandRank.showdown(board, [
|
||||
{ id: "a", cards: ["9H", "9S"] },
|
||||
{ id: "b", cards: ["", ""] },
|
||||
]);
|
||||
assert.equal(round.complete, false);
|
||||
assert.deepEqual(round.winners, []);
|
||||
assert.deepEqual(round.unknown, ["b"]);
|
||||
});
|
||||
|
||||
test("a board still being dealt is not a showdown", () => {
|
||||
const round = HandRank.showdown(["KD", "9C", "4S", "", ""], [
|
||||
{ id: "a", cards: ["9H", "9S"] },
|
||||
{ id: "b", cards: ["AS", "KC"] },
|
||||
]);
|
||||
assert.equal(round.complete, false);
|
||||
assert.deepEqual(round.winners, []);
|
||||
});
|
||||
|
||||
test("a card entered twice stops it rather than guessing", () => {
|
||||
// The failure mode that matters: a mis-typed card produces a hand that ranks
|
||||
// perfectly well and is not the one on the table. Refusing is the only honest
|
||||
// answer, and the dealer picks the winner the way they always could.
|
||||
const round = HandRank.showdown(board, [
|
||||
{ id: "a", cards: ["9H", "9S"] },
|
||||
{ id: "b", cards: ["KD", "QC"] }, // the king of diamonds is on the board
|
||||
]);
|
||||
assert.equal(round.duplicated, true);
|
||||
assert.equal(round.complete, false);
|
||||
assert.deepEqual(round.winners, []);
|
||||
});
|
||||
|
||||
test("and two players holding the same card is caught as well", () => {
|
||||
const round = HandRank.showdown(board, [
|
||||
{ id: "a", cards: ["QH", "QS"] },
|
||||
{ id: "b", cards: ["QH", "JC"] },
|
||||
]);
|
||||
assert.equal(round.duplicated, true);
|
||||
assert.equal(round.complete, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a table that never asked for any of this", () => {
|
||||
test("has no board, tracks nothing, and reads no showdown", () => {
|
||||
const state = HoldemState.createDefault();
|
||||
assert.equal(state.trackBoard, false);
|
||||
// Which is what the wizard checks before it reads anything at all — see
|
||||
// readShowdown() in potWizard.js. With it off, an empty board would decline
|
||||
// anyway, so the tool cannot start answering a question nobody asked.
|
||||
const round = HandRank.showdown(state.board, [
|
||||
{ id: "a", cards: ["9H", "9S"] },
|
||||
{ id: "b", cards: ["AS", "KC"] },
|
||||
]);
|
||||
assert.equal(round.complete, false);
|
||||
assert.deepEqual(round.winners, []);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
// Motion, for the people who asked for less of it.
|
||||
//
|
||||
// The stylesheet had honoured `prefers-reduced-motion` for a while, and all three places
|
||||
// it did were the wrong size of thing: a 2px press-down on a button, a checkbox tick,
|
||||
// and the acting seat's pulse. What none of them covered was motion that moves something
|
||||
// across the screen — and for anyone with a vestibular disorder that is the part that
|
||||
// matters. A button dipping under a thumb is not what makes people ill.
|
||||
//
|
||||
// Four things were doing it. A seat editor that slides the full height of itself up from
|
||||
// off-screen, every time a dealer taps a chair. A toast that slides in from below. And
|
||||
// three `scrollIntoView({ behavior: "smooth" })` calls, which are the most provocative of
|
||||
// the lot, because what moves is the whole viewport rather than one control.
|
||||
//
|
||||
// The scripts are checked here against a stand-in for matchMedia; the stylesheet is
|
||||
// checked against its own text, the way the seat footprint rules already are.
|
||||
const test = require("node:test");
|
||||
const { describe, afterEach } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { Render } = require("./load");
|
||||
|
||||
const CSS = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
||||
|
||||
// ---- The scripts ------------------------------------------------------------
|
||||
|
||||
// Node has no matchMedia at all, so this installs one rather than replacing one. It is
|
||||
// defined as a plain value here for the same reason `navigator` had to be defined with
|
||||
// defineProperty in wakeLock.test.js: assigning to a global the runtime owns can be a
|
||||
// silent no-op, and a test that thinks it installed a fake and didn't passes for the
|
||||
// wrong reason.
|
||||
const setReducedMotion = wanted => {
|
||||
Object.defineProperty(globalThis, "matchMedia", {
|
||||
value: query => ({
|
||||
matches: wanted && /prefers-reduced-motion:\s*reduce/.test(query),
|
||||
media: query,
|
||||
}),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
};
|
||||
|
||||
const unsetMatchMedia = () => { delete globalThis.matchMedia; };
|
||||
|
||||
// An element that records what it was asked to do, and nothing else.
|
||||
const fakeElement = () => {
|
||||
const calls = [];
|
||||
return { calls, scrollIntoView(options) { calls.push(options); } };
|
||||
};
|
||||
|
||||
afterEach(unsetMatchMedia);
|
||||
|
||||
describe("what the tool reads off the setting", () => {
|
||||
test("a browser asking for less motion is heard", () => {
|
||||
setReducedMotion(true);
|
||||
assert.equal(Render.prefersReducedMotion(), true);
|
||||
});
|
||||
|
||||
test("and one that isn't, isn't", () => {
|
||||
setReducedMotion(false);
|
||||
assert.equal(Render.prefersReducedMotion(), false);
|
||||
});
|
||||
|
||||
test("a browser too old to have the question is not assumed to be answering it", () => {
|
||||
// No matchMedia at all. The safe reading is "no preference stated", which leaves
|
||||
// the tool behaving exactly as it did — not "reduce", which would quietly take
|
||||
// the animation away from everybody on an old browser.
|
||||
unsetMatchMedia();
|
||||
assert.equal(Render.prefersReducedMotion(), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bringing something into view", () => {
|
||||
test("scrolls smoothly when nobody has asked otherwise", () => {
|
||||
setReducedMotion(false);
|
||||
const el = fakeElement();
|
||||
Render.bringIntoView(el, { block: "center" });
|
||||
assert.deepEqual(el.calls, [{ block: "center", behavior: "smooth" }]);
|
||||
});
|
||||
|
||||
test("and jumps when they have", () => {
|
||||
setReducedMotion(true);
|
||||
const el = fakeElement();
|
||||
Render.bringIntoView(el, { block: "center" });
|
||||
assert.deepEqual(el.calls, [{ block: "center", behavior: "auto" }]);
|
||||
});
|
||||
|
||||
test("the caller still chooses where it lands, only not how it gets there", () => {
|
||||
// Each of the three callers wants a different alignment — the raise field
|
||||
// centred, the seat sheet nearest, the sidebar section at the top — and the
|
||||
// helper must not flatten that into one answer.
|
||||
setReducedMotion(true);
|
||||
for (const block of ["center", "nearest", "start"]) {
|
||||
const el = fakeElement();
|
||||
Render.bringIntoView(el, { block });
|
||||
assert.equal(el.calls[0].block, block);
|
||||
}
|
||||
});
|
||||
|
||||
test("a caller that never chose gets a plain scroll rather than an exception", () => {
|
||||
setReducedMotion(false);
|
||||
const el = fakeElement();
|
||||
Render.bringIntoView(el);
|
||||
assert.equal(el.calls[0].behavior, "smooth");
|
||||
});
|
||||
|
||||
test("nothing to scroll is not an error", () => {
|
||||
// main.js reaches for #seatEditorPanel by id and passes whatever it finds.
|
||||
setReducedMotion(true);
|
||||
assert.doesNotThrow(() => Render.bringIntoView(null));
|
||||
assert.doesNotThrow(() => Render.bringIntoView(undefined));
|
||||
assert.doesNotThrow(() => Render.bringIntoView({}));
|
||||
});
|
||||
|
||||
test("the setting is re-read every time, not remembered from page load", () => {
|
||||
// A dealer who turns reduced motion on mid-session should be taken seriously
|
||||
// without reloading the table — and the table is a thing they cannot afford to
|
||||
// reload lightly.
|
||||
setReducedMotion(false);
|
||||
const el = fakeElement();
|
||||
Render.bringIntoView(el, { block: "start" });
|
||||
|
||||
setReducedMotion(true);
|
||||
Render.bringIntoView(el, { block: "start" });
|
||||
|
||||
assert.deepEqual(el.calls.map(c => c.behavior), ["smooth", "auto"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- The stylesheet ---------------------------------------------------------
|
||||
|
||||
// Everything inside the @media block whose condition matches, concatenated — there is
|
||||
// more than one reduced-motion block in the file and they govern different things.
|
||||
const reducedMotionBlocks = (extraCondition = null) => {
|
||||
const found = [];
|
||||
const needle = "@media ";
|
||||
for (let at = CSS.indexOf(needle); at >= 0; at = CSS.indexOf(needle, at + 1)) {
|
||||
const open = CSS.indexOf("{", at);
|
||||
const condition = CSS.slice(at + needle.length, open).trim();
|
||||
if (!/prefers-reduced-motion:\s*reduce/.test(condition)) continue;
|
||||
if (extraCondition && !condition.includes(extraCondition)) continue;
|
||||
let depth = 0;
|
||||
let i = open;
|
||||
for (; i < CSS.length; i++) {
|
||||
if (CSS[i] === "{") depth++;
|
||||
else if (CSS[i] === "}" && --depth === 0) break;
|
||||
}
|
||||
found.push(CSS.slice(open + 1, i));
|
||||
}
|
||||
return found;
|
||||
};
|
||||
|
||||
describe("the stylesheet stops the things that actually travel", () => {
|
||||
test("there is more than one reduced-motion block, and one of them is for the sheet", () => {
|
||||
assert.ok(reducedMotionBlocks().length >= 2,
|
||||
"the press-down block alone was never the whole job");
|
||||
assert.equal(reducedMotionBlocks("max-width").length, 1,
|
||||
"the seat sheet's override has to carry the same width condition as the rule it beats");
|
||||
});
|
||||
|
||||
test("the toast keeps its fade and loses its travel", () => {
|
||||
const all = reducedMotionBlocks().join("\n");
|
||||
const toast = all.slice(all.indexOf(".toast"));
|
||||
assert.match(toast, /transform:\s*none/,
|
||||
"a toast sliding up from the bottom edge is exactly the motion being asked about");
|
||||
// The guidance is not "delete the animation": something that appears already in
|
||||
// place reads as a rendering glitch rather than as news. Opacity survives.
|
||||
assert.match(toast, /transition:\s*opacity/,
|
||||
"the fade has to stay, or the toast stops reading as something that just happened");
|
||||
});
|
||||
|
||||
test("the seat sheet stops sliding, and stops being painted after it has gone", () => {
|
||||
const [sheet] = reducedMotionBlocks("max-width");
|
||||
assert.ok(sheet, "the sheet override should exist");
|
||||
assert.doesNotMatch(sheet, /transition:[^;]*transform/,
|
||||
"the transform transition is the slide, and it is the largest movement here");
|
||||
// The delay is what kept the slide-out on screen for its own quarter second.
|
||||
// With the slide gone it would leave a sheet that has already jumped away still
|
||||
// painted, which is a flicker rather than a courtesy.
|
||||
const flips = sheet.match(/transition:\s*visibility\s+0s[^;]*/g) || [];
|
||||
assert.equal(flips.length, 2,
|
||||
"both directions need saying: the rule that puts the sheet away and the one that opens it");
|
||||
assert.ok(flips.every(rule => /^transition:\s*visibility\s+0s(\s+linear\s+0s)?\s*$/.test(rule.trim())),
|
||||
`visibility has to flip at once in both directions once there is no slide to cover: ${flips}`);
|
||||
assert.doesNotMatch(sheet, /linear\s+0\.\d+s/,
|
||||
"no leftover delay measured against an animation that no longer runs");
|
||||
});
|
||||
});
|
||||
|
||||
// ---- And the same rule, applied to whatever the file grows next --------------
|
||||
//
|
||||
// The three checks above name the things that were wrong. This one is the general
|
||||
// version, and it is the one that will still be doing work in a year: it scans the
|
||||
// stylesheet for every `:hover` and `:active` transform there is and insists each one
|
||||
// has an off switch.
|
||||
//
|
||||
// Written because the existing block's membership had gone wrong in a way no
|
||||
// enumerated test would have caught. It held nine `:active` selectors and looked
|
||||
// deliberate, but it had been written to stop a *stuck press state* when a tap turns
|
||||
// into a scroll — reaching for the reduced-motion query as the nearest proxy — and
|
||||
// solving one problem with another's tool is how it came to cover `.dealerChip` and
|
||||
// `.raiseQuickBtn` while missing `.actionBtn`, the class on every button in the action
|
||||
// bar, and every `:hover` in the file.
|
||||
describe("every hover and press in the file has an off switch", () => {
|
||||
// Selectors that move something under the pointer.
|
||||
//
|
||||
// Parsed as blocks rather than scanned line by line, and that distinction is not
|
||||
// pedantry — the line-by-line version was written first and a fault injection walked
|
||||
// straight past it. A rule written on one line puts its selector and its declaration
|
||||
// in the same string, so neither "this line opens a rule" nor "this line is a
|
||||
// transform" matched, and the scan reported a clean file while the lift it was meant
|
||||
// to find sat in plain sight.
|
||||
//
|
||||
// The pattern matches innermost blocks only: a selector cannot contain braces, so an
|
||||
// @media wrapper's body never matches and its contents are reached individually.
|
||||
const pointerTransforms = () => {
|
||||
const withoutComments = CSS.replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
const found = [];
|
||||
for (const [, selector, body] of withoutComments.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
||||
const name = selector.trim();
|
||||
if (name.startsWith("@")) continue;
|
||||
if (!name.includes(":hover") && !name.includes(":active")) continue;
|
||||
const moves = body.match(/(^|[;{\s])transform\s*:\s*([^;]+)/);
|
||||
if (!moves || /^none\b/.test(moves[2].trim())) continue;
|
||||
// Split the group. A comma-separated rule is nine separate promises, and
|
||||
// checking the joined string against the reduce block is a test that can only
|
||||
// pass if both lists happen to be written in the same order — which is to say
|
||||
// a test that passes by luck. It also hid three selectors outright: the
|
||||
// line-scanning version kept only the last line of a multi-line group, so
|
||||
// .toastAction, .toastClose and .potWizardChoice were never looked at.
|
||||
for (const one of name.split(",")) {
|
||||
const selector = one.trim().replace(/\s+/g, " ");
|
||||
if (!selector.includes(":hover") && !selector.includes(":active")) continue;
|
||||
found.push({ selector, declaration: moves[2].trim() });
|
||||
}
|
||||
}
|
||||
return found;
|
||||
};
|
||||
|
||||
const reduceText = () => reducedMotionBlocks().join("\n");
|
||||
|
||||
test("there are some to check, or this test is measuring nothing", () => {
|
||||
// The guard the accent check taught us to write: a scan that finds nothing
|
||||
// passes just as green as a scan that finds everything covered.
|
||||
assert.ok(pointerTransforms().length >= 4,
|
||||
"the file should still have hover/press transforms in it");
|
||||
});
|
||||
|
||||
test("and every one of them is turned off under reduced motion", () => {
|
||||
const text = reduceText();
|
||||
// A selector counts as covered when it appears in a reduce block, comma-separated
|
||||
// list and all. Compared with whitespace flattened, because the reduce block puts
|
||||
// each selector on its own line and the rule it answers may not.
|
||||
const flat = text.replace(/\s+/g, " ");
|
||||
const missing = pointerTransforms()
|
||||
.filter(rule => !flat.includes(rule.selector))
|
||||
.map(rule => `${rule.selector} { transform: ${rule.declaration} }`);
|
||||
assert.deepEqual(missing, [],
|
||||
`these move under the pointer with no reduced-motion escape:\n ${missing.join("\n ")}`);
|
||||
});
|
||||
|
||||
test("including the one a dealer touches most", () => {
|
||||
// Named on its own as well as caught by the scan, because this is the specific
|
||||
// omission that prompted the scan: Fold, Call, Raise and Post blinds & deal all
|
||||
// carry .actionBtn, and it was the one control missing from a list of nine.
|
||||
assert.match(reduceText(), /\.actionBtn:active/,
|
||||
"the action bar's own buttons are the most-pressed thing in the tool");
|
||||
});
|
||||
|
||||
test("a looping animation only runs where motion is welcome", () => {
|
||||
// The acting seat breathes. An infinite animation is the one kind that cannot be
|
||||
// waited out, so it has to be inside a no-preference block rather than merely
|
||||
// overridden in a reduce one.
|
||||
const noPreference = CSS.indexOf("@media (prefers-reduced-motion: no-preference)");
|
||||
assert.ok(noPreference > 0, "the pulse should sit behind a no-preference query");
|
||||
const infinite = [...CSS.matchAll(/animation:[^;]*infinite/g)].map(m => m.index);
|
||||
assert.ok(infinite.length > 0, "there should still be a looping animation to check");
|
||||
assert.ok(infinite.every(at => at > noPreference),
|
||||
"every looping animation has to sit inside the no-preference block");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
// Reads every piece of text on the dealer's page and asks whether it can actually be
|
||||
// read, at the size and weight it is actually drawn.
|
||||
//
|
||||
// node tests/holdem/runContrastAudit.js [baseUrl]
|
||||
//
|
||||
// Needs Playwright and a running site, like runUiAudit.js, and is deliberately kept
|
||||
// out of the unit suite for the same reason.
|
||||
//
|
||||
// Why this exists next to the contrast check already in uiAudit.js: that one measures
|
||||
// WCAG 2 contrast ratio and the page passes it. WCAG 2's ratio is a luminance gap, and
|
||||
// a luminance gap overstates readability once one colour is near black — which is
|
||||
// every reading taken on a page whose background is #141414. APCA asks the question
|
||||
// the ratio can't. Both numbers are reported, because "passes AA, fails APCA" is the
|
||||
// interesting row and hiding either number would hide it.
|
||||
//
|
||||
// How the background is obtained matters more than the arithmetic. The first version
|
||||
// walked the DOM compositing `background-color` up the ancestors, and reported the gold
|
||||
// dealer button as black text on a black background at Lc 0.4 — a fault that does not
|
||||
// exist on an element anyone can see is legible, because a gradient was doing the
|
||||
// painting and `background-color` knew nothing about it. So: paint the page with its
|
||||
// text made transparent, screenshot it, and read the background out of the pixels. The
|
||||
// text's own client rects come from a Range, not the element box, so a label inside a
|
||||
// wide row is sampled where the glyphs are rather than wherever the row happens to end.
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { lc, requiredLc } = require("./apca");
|
||||
const { decode, pixelAt } = require("./png");
|
||||
|
||||
const BASE = process.argv[2] || "http://localhost:5199";
|
||||
|
||||
const VIEWPORTS = [
|
||||
[390, 844, "iPhone 14"],
|
||||
[768, 1024, "iPad"],
|
||||
[1280, 900, "desktop"],
|
||||
];
|
||||
|
||||
// WCAG 2 relative luminance — the piecewise curve, which is not the one APCA uses.
|
||||
function wcagLuminance([r, g, b]) {
|
||||
const f = c => {
|
||||
c /= 255;
|
||||
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
};
|
||||
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
|
||||
}
|
||||
|
||||
function wcagRatio(fg, bg) {
|
||||
const a = wcagLuminance(fg), b = wcagLuminance(bg);
|
||||
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
||||
}
|
||||
|
||||
function seedTable() {
|
||||
const state = HoldemState.createDefault();
|
||||
HoldemState.resizeSeats(state, 9);
|
||||
[["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500],
|
||||
["Bob", 250], ["Dee", 600]].forEach(([name, cash], i) => {
|
||||
state.seats[i].name = name;
|
||||
state.seats[i].inHand = true;
|
||||
state.seats[i].buyIns = cash;
|
||||
state.seats[i].stack = cash;
|
||||
});
|
||||
state.smallBlind = 5;
|
||||
state.bigBlind = 10;
|
||||
state.handNumber = 5;
|
||||
state.handHistory.push({
|
||||
handNumber: 4, timestamp: Date.now(), pot: 120,
|
||||
payouts: [{ id: state.seats[0].id, label: "Christopher", amount: 120 }],
|
||||
ledger: state.seats.slice(0, 5).map(p => ({ id: p.id, name: p.name, in: 30, won: 0 })),
|
||||
});
|
||||
HoldemState.save(state);
|
||||
}
|
||||
|
||||
// Every text node that is actually painted, with the rect its glyphs occupy.
|
||||
function collectText() {
|
||||
const out = [];
|
||||
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
||||
let node;
|
||||
while ((node = walker.nextNode())) {
|
||||
const text = node.textContent.trim();
|
||||
if (!text) continue;
|
||||
const el = node.parentElement;
|
||||
if (!el || el.closest("[hidden]")) continue;
|
||||
|
||||
const cs = getComputedStyle(el);
|
||||
if (cs.visibility === "hidden" || cs.display === "none") continue;
|
||||
|
||||
// Anything faded is faded onto what is behind it, and the screenshot already
|
||||
// shows that — but fully transparent text is not text.
|
||||
let opacity = 1;
|
||||
for (let n = el; n && n.nodeType === 1; n = n.parentElement) {
|
||||
opacity *= parseFloat(getComputedStyle(n).opacity);
|
||||
}
|
||||
if (opacity < 0.05) continue;
|
||||
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(node);
|
||||
const rects = [...range.getClientRects()].filter(r => r.width > 1 && r.height > 1);
|
||||
if (!rects.length) continue;
|
||||
const r = rects[0];
|
||||
if (r.bottom < 0 || r.top > innerHeight || r.right < 0 || r.left > innerWidth) continue;
|
||||
|
||||
const m = cs.color.match(/[\d.]+/g);
|
||||
if (!m) continue;
|
||||
|
||||
out.push({
|
||||
text: text.slice(0, 40),
|
||||
color: [+m[0], +m[1], +m[2]],
|
||||
alpha: m[3] !== undefined ? +m[3] : 1,
|
||||
opacity,
|
||||
px: parseFloat(cs.fontSize),
|
||||
weight: +cs.fontWeight || 400,
|
||||
cls: (el.className && el.className.toString().trim().split(/\s+/)[0]) || el.tagName.toLowerCase(),
|
||||
tag: el.tagName.toLowerCase(),
|
||||
disabled: !!el.closest(":disabled, [disabled], [aria-disabled='true']"),
|
||||
// Sample points along the glyph run, avoiding the very edges where a
|
||||
// border or the next box over would be sampled instead.
|
||||
points: [0.25, 0.5, 0.75].map(t => [r.left + r.width * t, r.top + r.height / 2]),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const HIDE_TEXT = `*, *::before, *::after {
|
||||
color: transparent !important;
|
||||
text-shadow: none !important;
|
||||
-webkit-text-stroke-color: transparent !important;
|
||||
caret-color: transparent !important;
|
||||
}`;
|
||||
|
||||
async function main() {
|
||||
let chromium;
|
||||
try {
|
||||
({ chromium } = require("playwright"));
|
||||
} catch {
|
||||
console.error("This script needs Playwright: npm i -D playwright && npx playwright install chromium");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const shotDir = fs.mkdtempSync(path.join(os.tmpdir(), "holdem-contrast-"));
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext({ deviceScaleFactor: 1 });
|
||||
const page = await context.newPage();
|
||||
|
||||
const rows = [];
|
||||
|
||||
for (const [width, height, label] of VIEWPORTS) {
|
||||
await page.setViewportSize({ width, height });
|
||||
await page.goto(`${BASE}/casino/texasholdem`);
|
||||
await page.evaluate(seedTable);
|
||||
await page.goto(`${BASE}/casino/texasholdem`);
|
||||
await page.click("#nextHandBtn");
|
||||
await page.waitForTimeout(300);
|
||||
await page.evaluate(() => {
|
||||
const deal = [...document.querySelectorAll("#actionBar button")]
|
||||
.find(b => /post blinds/i.test(b.textContent));
|
||||
if (deal) deal.click();
|
||||
});
|
||||
await page.waitForTimeout(250);
|
||||
// Open every panel, or the sidebar's hint text — the smallest, faintest text on
|
||||
// the page, and so the most likely to be the thing worth finding — is never
|
||||
// measured at all.
|
||||
await page.evaluate(() => {
|
||||
document.querySelectorAll("details").forEach(d => { d.open = true; });
|
||||
});
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const items = await page.evaluate(collectText);
|
||||
|
||||
const shot = path.join(shotDir, `${width}x${height}.png`);
|
||||
await page.addStyleTag({ content: HIDE_TEXT });
|
||||
await page.waitForTimeout(120);
|
||||
await page.screenshot({ path: shot });
|
||||
// Undo, so the next viewport starts from a page that still has its text.
|
||||
await page.evaluate(() => {
|
||||
const tags = [...document.querySelectorAll("style")];
|
||||
const injected = tags[tags.length - 1];
|
||||
if (injected && injected.textContent.includes("caret-color: transparent")) injected.remove();
|
||||
});
|
||||
|
||||
const image = decode(fs.readFileSync(shot));
|
||||
|
||||
for (const item of items) {
|
||||
// Most common sampled colour along the run: a median would invent a colour
|
||||
// that is on no pixel, and the mode lands on the flat background wherever
|
||||
// there is one.
|
||||
const tally = new Map();
|
||||
for (const [x, y] of item.points) {
|
||||
const key = pixelAt(image, x, y).join(",");
|
||||
tally.set(key, (tally.get(key) || 0) + 1);
|
||||
}
|
||||
const bg = [...tally.entries()].sort((a, b) => b[1] - a[1])[0][0]
|
||||
.split(",").map(Number);
|
||||
|
||||
// The text's painted colour: its own alpha, and any inherited opacity, both
|
||||
// land it on the background behind it.
|
||||
const a = item.alpha * item.opacity;
|
||||
const fg = item.color.map((c, i) => c * a + bg[i] * (1 - a));
|
||||
|
||||
const measured = Math.abs(lc(fg, bg));
|
||||
const needed = requiredLc(item.px, item.weight);
|
||||
rows.push({
|
||||
viewport: label,
|
||||
...item,
|
||||
bg, fg: fg.map(Math.round),
|
||||
lc: +measured.toFixed(1),
|
||||
need: +needed.toFixed(0),
|
||||
short: +(needed - measured).toFixed(1),
|
||||
wcag: +wcagRatio(fg, bg).toFixed(2),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
fs.rmSync(shotDir, { recursive: true, force: true });
|
||||
|
||||
// One row per distinct piece of text, worst viewport kept.
|
||||
const byKey = new Map();
|
||||
for (const row of rows) {
|
||||
const key = `${row.cls}|${row.px}|${row.weight}|${row.text}`;
|
||||
const prev = byKey.get(key);
|
||||
if (!prev || row.short > prev.short) byKey.set(key, row);
|
||||
}
|
||||
|
||||
const all = [...byKey.values()].sort((a, b) => b.short - a.short);
|
||||
const live = all.filter(r => !r.disabled);
|
||||
const failing = live.filter(r => r.short > 0);
|
||||
const passesAaFailsApca = failing.filter(r => r.wcag >= 4.5);
|
||||
|
||||
// Two numbers, because they answer different questions and only one of them is a
|
||||
// target anyone can hit here.
|
||||
//
|
||||
// The strict count is every piece of text below the published level for the size and
|
||||
// weight it is drawn at. APCA asks Lc 90 of anything at 12px/400 or below, and this
|
||||
// page is full of 10px and 11px labels that live inside a 58px seat puck. Reaching
|
||||
// Lc 90 there means white on black for micro-labels and a flat, shoutier page; the
|
||||
// honest way to clear it is bigger type, and bigger type is a layout change that
|
||||
// belongs to its own round. So the strict count is reported and not chased.
|
||||
//
|
||||
// The floors are what this page holds itself to: nothing below Lc 60, which is the
|
||||
// published minimum for content text that is not a column, and Lc 75 for a column of
|
||||
// body text. Those are checkable and they are the numbers that moved.
|
||||
const FLOOR = 60, BODY_FLOOR = 75;
|
||||
const belowFloor = live.filter(r => r.lc < FLOOR);
|
||||
const belowBody = live.filter(r => r.lc < BODY_FLOOR);
|
||||
const aaFails = live.filter(r => r.wcag < 4.5);
|
||||
const worst = live.reduce((min, r) => Math.min(min, r.lc), Infinity);
|
||||
|
||||
console.log(`${rows.length} readings, ${all.length} distinct pieces of text`);
|
||||
console.log(`worst contrast anywhere on the page: Lc ${worst.toFixed(1)}`);
|
||||
console.log(`below the Lc ${FLOOR} floor: ${belowFloor.length}`);
|
||||
console.log(`below Lc ${BODY_FLOOR}: ${belowBody.length}`);
|
||||
console.log(`still failing WCAG AA 4.5:1: ${aaFails.length}`);
|
||||
console.log(`below the strict APCA level for their size/weight: ${failing.length}`
|
||||
+ ` (${passesAaFailsApca.length} of them pass AA)\n`);
|
||||
|
||||
if (belowFloor.length) {
|
||||
console.log(`=== below the Lc ${FLOOR} floor ===`);
|
||||
belowFloor.sort((a, b) => a.lc - b.lc).forEach(r =>
|
||||
console.log(` Lc ${String(r.lc).padStart(5)} ${r.px}px/${r.weight} ${r.cls} "${r.text}"`));
|
||||
console.log("");
|
||||
}
|
||||
if (aaFails.length) {
|
||||
console.log("=== still failing WCAG AA ===");
|
||||
aaFails.sort((a, b) => a.wcag - b.wcag).forEach(r =>
|
||||
console.log(` ${String(r.wcag).padStart(5)}:1 ${r.px}px/${r.weight} ${r.cls} "${r.text}"`));
|
||||
console.log("");
|
||||
}
|
||||
|
||||
const show = r => ` Lc ${String(r.lc).padStart(5)} (needs ${String(r.need).padStart(2)},`
|
||||
+ ` short ${String(r.short).padStart(5)}) WCAG ${String(r.wcag).padStart(5)}:1`
|
||||
+ ` ${r.px}px/${r.weight} ${r.cls.padEnd(22)} rgb(${r.fg.join(",")}) on rgb(${r.bg.join(",")})`
|
||||
+ ` "${r.text}"`;
|
||||
|
||||
console.log("=== worst 30 ===");
|
||||
failing.slice(0, 30).forEach(r => console.log(show(r)));
|
||||
|
||||
const byClass = new Map();
|
||||
failing.forEach(r => {
|
||||
const cur = byClass.get(r.cls);
|
||||
if (!cur || r.short > cur.short) byClass.set(r.cls, r);
|
||||
});
|
||||
console.log(`\n=== ${byClass.size} distinct classes below level ===`);
|
||||
[...byClass.values()].sort((a, b) => b.short - a.short)
|
||||
.forEach(r => console.log(` ${String(r.short).padStart(5)} short ${r.px}px/${r.weight} ${r.cls}`));
|
||||
}
|
||||
|
||||
main().catch(err => { console.error(err); process.exit(1); });
|
||||
+178
-35
@@ -34,26 +34,64 @@ const OVERLAYS = [
|
||||
];
|
||||
|
||||
// A table part-way through a real night: long names, uneven stacks, a hand log.
|
||||
function seedTable() {
|
||||
//
|
||||
// Two of them. The sweep ran on one table shape for its whole life — five players at a
|
||||
// nine-handed table — and how much room the felt has depends on how many people are
|
||||
// sitting down more than on anything a viewport can tell you. A full table is where the
|
||||
// seats crowd, and it was never once looked at: measured when it finally was, seventeen
|
||||
// of fifty-four tables had one player's name or stack painted under another.
|
||||
const PACKED = [["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500],
|
||||
["Bob", 250], ["Dee", 600], ["Sam", 900], ["Priya", 1100], ["Jonathan", 480],
|
||||
["Al", 2000], ["Katarzyna", 340], ["Ted", 760], ["Wes", 1300]];
|
||||
|
||||
const TABLES = [
|
||||
["half full", 9, [["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500],
|
||||
["Bob", 250], ["Dee", 600]], false],
|
||||
["packed", 12, PACKED],
|
||||
// The same twelve, every one of them all-in for a different amount. This is the
|
||||
// shape that makes a pot layer per player, and the sweep had never reached it:
|
||||
// every table here was driven with Call and Check, so the side-pot breakdown — the
|
||||
// one thing on the bar whose height depends on the table — was empty at all 294
|
||||
// audits. Measured when it finally was: the bar took 411px of a 640px phone and
|
||||
// left 96px of felt for twelve seats.
|
||||
["all in", 12, PACKED, false, 12],
|
||||
// The half-full table again, with the board turned on and five cards on it. The
|
||||
// board is furniture in the middle of the felt that nothing else was ever measured
|
||||
// against — and where the middle cannot hold it, it moves to a strip above the
|
||||
// table and takes the felt's height with it, which is the seats' problem too.
|
||||
//
|
||||
// Deliberately the same five players in the same nine chairs as the shape above, so
|
||||
// the only thing that differs between them is the board. A seventh player would have
|
||||
// told us that a seven-handed table crowds, which is true, already known, and not
|
||||
// what this row is here to find out.
|
||||
["board", 9, [["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500],
|
||||
["Bob", 250], ["Dee", 600]], true],
|
||||
];
|
||||
|
||||
function seedTable([seats, players, board]) {
|
||||
const state = HoldemState.createDefault();
|
||||
HoldemState.resizeSeats(state, 9);
|
||||
[["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500], ["Bob", 250], ["Dee", 600]]
|
||||
.forEach(([name, cash], i) => {
|
||||
state.seats[i].name = name;
|
||||
state.seats[i].inHand = true;
|
||||
state.seats[i].buyIns = cash;
|
||||
state.seats[i].stack = cash;
|
||||
});
|
||||
HoldemState.resizeSeats(state, seats);
|
||||
players.forEach(([name, cash], i) => {
|
||||
state.seats[i].name = name;
|
||||
state.seats[i].inHand = true;
|
||||
state.seats[i].buyIns = cash;
|
||||
state.seats[i].stack = cash;
|
||||
});
|
||||
state.smallBlind = 5;
|
||||
state.bigBlind = 10;
|
||||
for (let hand = 1; hand <= 4; hand++) {
|
||||
state.handHistory.push({
|
||||
handNumber: hand, timestamp: Date.now(), pot: 120,
|
||||
payouts: [{ id: state.seats[hand % 5].id, label: "P", amount: 120 }],
|
||||
ledger: state.seats.slice(0, 5).map(p => ({ id: p.id, name: p.name, in: 30, won: 0 })),
|
||||
payouts: [{ id: state.seats[hand % players.length].id, label: "P", amount: 120 }],
|
||||
ledger: state.seats.slice(0, players.length)
|
||||
.map(p => ({ id: p.id, name: p.name, in: 30, won: 0 })),
|
||||
});
|
||||
}
|
||||
state.handNumber = 5;
|
||||
if (board) {
|
||||
state.trackBoard = true;
|
||||
state.board = ["KD", "9C", "4S", "AH", "2H"];
|
||||
}
|
||||
HoldemState.save(state);
|
||||
}
|
||||
|
||||
@@ -85,33 +123,138 @@ async function main() {
|
||||
const hand = touch ? "touch" : "mouse";
|
||||
|
||||
for (const [width, height, rawLabel] of VIEWPORTS) {
|
||||
const label = `${rawLabel} · ${hand}`;
|
||||
await page.setViewportSize({ width, height });
|
||||
|
||||
await page.goto(`${BASE}/Casino/TexasHoldem`);
|
||||
await page.evaluate(seedTable);
|
||||
await page.goto(`${BASE}/Casino/TexasHoldem`);
|
||||
await page.click("#nextHandBtn");
|
||||
await page.waitForTimeout(300);
|
||||
await page.evaluate(() => document.querySelectorAll("details.sidebarPanel").forEach(d => { d.open = true; }));
|
||||
await page.addScriptTag({ path: AUDIT });
|
||||
|
||||
const table = await page.evaluate(() => LayoutAudit.runAll());
|
||||
checks++;
|
||||
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}`));
|
||||
|
||||
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);
|
||||
if (!overlay) return { offenders: [] };
|
||||
return LayoutAudit.occlusion(`#${overlay.id} button, #${overlay.id} a, #${overlay.id} input`);
|
||||
for (const [shape, seats, players, board, shoves] of TABLES) {
|
||||
const label = `${rawLabel} · ${shape} · ${hand}`;
|
||||
await page.goto(`${BASE}/Casino/TexasHoldem`);
|
||||
await page.evaluate(seedTable, [seats, players, board]);
|
||||
await page.goto(`${BASE}/Casino/TexasHoldem`);
|
||||
await page.click("#nextHandBtn");
|
||||
await page.waitForTimeout(300);
|
||||
// Deal it, and get chips in front of a few players. The sweep used to
|
||||
// audit a table that had never had a bet on it, so the bets layer was
|
||||
// empty at every viewport it visited and nothing it drew was ever looked
|
||||
// at — which is a third of what is on the felt during a hand.
|
||||
await page.evaluate(() => {
|
||||
const deal = [...document.querySelectorAll("#actionBar button")]
|
||||
.find(b => /post blinds/i.test(b.textContent));
|
||||
if (deal) deal.click();
|
||||
});
|
||||
await page.evaluate(() => document.querySelectorAll("[id$='Overlay']").forEach(o => { o.hidden = true; }));
|
||||
if (result.offenders.length) failures.push({ where: `${name} · ${label} ${width}x${height}`, offenders: result.offenders });
|
||||
await page.waitForTimeout(220);
|
||||
// Everyone's whole stack in, which is what stacks up the side pots.
|
||||
// There is no one-tap shove: a player who can't cover the bet gets
|
||||
// "Call all-in", and anyone else has to open the raise pad, take its
|
||||
// All-in preset and confirm.
|
||||
for (let i = 0; i < (shoves || 0); i++) {
|
||||
const step = await page.evaluate(() => {
|
||||
const find = re => [...document.querySelectorAll("#actionBar button")]
|
||||
.find(b => !b.disabled && re.test(b.textContent.trim()));
|
||||
const short = find(/^Call all-in/);
|
||||
if (short) { short.click(); return "done"; }
|
||||
const open = find(/^Raise|^Bet/);
|
||||
if (!open) return null;
|
||||
open.click();
|
||||
return "pad";
|
||||
});
|
||||
if (!step) break;
|
||||
await page.waitForTimeout(120);
|
||||
if (step === "pad") {
|
||||
await page.evaluate(() => {
|
||||
const find = re => [...document.querySelectorAll("#actionBar button")]
|
||||
.find(b => !b.disabled && re.test(b.textContent.trim()));
|
||||
const max = find(/^All-in/);
|
||||
if (max) max.click();
|
||||
const go = find(/^Raise to|^Bet/);
|
||||
if (go) go.click();
|
||||
});
|
||||
await page.waitForTimeout(120);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < (shoves ? 0 : 3); i++) {
|
||||
const acted = await page.evaluate(() => {
|
||||
const btn = [...document.querySelectorAll("#actionBar button")]
|
||||
.find(b => /^(Call|Check)/.test(b.textContent.trim()) && !b.disabled);
|
||||
if (!btn) return false;
|
||||
btn.click();
|
||||
return true;
|
||||
});
|
||||
if (!acted) break;
|
||||
await page.waitForTimeout(120);
|
||||
}
|
||||
if (board) {
|
||||
await page.evaluate(() => {
|
||||
const s = HoldemState.load();
|
||||
s.street = "river";
|
||||
s.board = ["KD", "9C", "4S", "AH", "2H"];
|
||||
s.seats.filter(p => p.inHand).forEach((p, i) => {
|
||||
p.cards = [["AS", "KC"], ["AC", "3D"], ["9H", "9S"],
|
||||
["7C", "5D"], ["JD", "TS"]][i] || ["", ""];
|
||||
});
|
||||
HoldemState.save(s);
|
||||
});
|
||||
await page.reload();
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
await page.evaluate(() => document.querySelectorAll("details.sidebarPanel").forEach(d => { d.open = true; }));
|
||||
await page.addScriptTag({ path: AUDIT });
|
||||
|
||||
const table = await page.evaluate(() => LayoutAudit.runAll());
|
||||
checks++;
|
||||
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
|
||||
// was never once measured by any of this. It is the tallest thing the
|
||||
// bar ever holds and the only place in the tool a dealer types an
|
||||
// amount, and it had a 36px-tall field on every phone in the list: the
|
||||
// `hover: none` rule that sizes fields for a thumb names the settings
|
||||
// panels and the seat form, and the one field on the thumb surface was
|
||||
// not in the list. Nothing here could see that, because nothing here
|
||||
// ever opened it.
|
||||
if (await page.evaluate(() => {
|
||||
const btn = [...document.querySelectorAll("#actionBar button")]
|
||||
.find(b => /^Raise|^Bet/.test(b.textContent.trim()) && !b.disabled);
|
||||
if (!btn) return false;
|
||||
btn.click();
|
||||
return true;
|
||||
})) {
|
||||
await page.waitForTimeout(200);
|
||||
checks++;
|
||||
const pad = await page.evaluate(() => LayoutAudit.runAll({
|
||||
occlusionSelector: "#actionBar button, #actionBar input",
|
||||
}));
|
||||
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()));
|
||||
if (cancel) cancel.click();
|
||||
});
|
||||
await page.waitForTimeout(150);
|
||||
}
|
||||
|
||||
// The overlays don't change with the table's shape, so they are only
|
||||
// opened over the half-full one — twice the viewports is enough time
|
||||
// spent already.
|
||||
if (shape !== TABLES[0][0]) continue;
|
||||
|
||||
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);
|
||||
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; }));
|
||||
if (result.offenders.length) failures.push({ where: `${name} · ${label} ${width}x${height}`, offenders: result.offenders });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
// Whether two seats can end up wanting the same pixels.
|
||||
//
|
||||
// The felt used to decide this from one number: the straight-line distance between the
|
||||
// closest two *neighbouring* seats, judged against a fraction of a puck's width. Both
|
||||
// halves of that were wrong, and the running page showed it. Measured across nine
|
||||
// viewports and six table sizes:
|
||||
//
|
||||
// seventeen of fifty-four tables had one seat's name or stack painted under another
|
||||
// ten of them had the collision between two seats that are not neighbours
|
||||
//
|
||||
// The second number is the interesting one. On a phone held sideways the felt is 528px
|
||||
// wide and 87 tall, the ring is very nearly a straight line, and the seat that lands on
|
||||
// top of you is the one across the table — seats 1 and 7, overlapping by 68px, while
|
||||
// every neighbouring pair was fine and the check said the table was clean.
|
||||
//
|
||||
// The first is what a distance test can't fix even looking at every pair: distance has
|
||||
// one number in it, and a puck is half again as wide as it is tall. 84px between centres
|
||||
// is generous side by side and nowhere near enough on the diagonal.
|
||||
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 } = require("./load");
|
||||
|
||||
const CSS = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
||||
|
||||
describe("two boxes on a ring", () => {
|
||||
// A phone held sideways, once the action bar has taken its share: a felt far wider
|
||||
// than it is tall, on which the ring is very nearly a straight line.
|
||||
const felt = { width: 400, height: 70 };
|
||||
const flat = { rx: 44, ry: 42 };
|
||||
|
||||
test("a pair that is not neighbours still counts", () => {
|
||||
// Eight seats, a 48x60 puck. Every neighbouring pair is clear. Seats 1 and 5 are
|
||||
// on top of each other, and so are 2 and 4, and 6 and 8 — the seat that lands on
|
||||
// you here is the one across the table, because the rim doubles back at the ends
|
||||
// of a shape this flat and a long walk apart is no distance at all.
|
||||
assert.equal(Render.seatsCollide(8, felt, flat, 48, 60), true);
|
||||
|
||||
const neighbours = (count, w, h) => {
|
||||
const points = Render.ringPositions(count, flat.rx, flat.ry, felt.width / felt.height)
|
||||
.map(s => ({ x: (s.left / 100) * felt.width, y: (s.top / 100) * felt.height }));
|
||||
return points.some((point, i) => {
|
||||
const next = points[(i + 1) % points.length];
|
||||
return Math.abs(point.x - next.x) < w && Math.abs(point.y - next.y) < h;
|
||||
});
|
||||
};
|
||||
assert.equal(neighbours(8, 48 + Render.SEAT_CLEARANCE_PX, 60 + Render.SEAT_CLEARANCE_PX), false,
|
||||
"no neighbouring pair overlaps, which is exactly why this had to look at all of them");
|
||||
});
|
||||
|
||||
test("and the answer is the box, not a radius", () => {
|
||||
// Two seats 60px apart across and 35px down, with a 64x40 puck. The distance
|
||||
// between them is 69px, so the test this replaces — "are these two further apart
|
||||
// than a puck is wide?" — called it clear. The boxes overlap by 4px by 5px.
|
||||
const boxes = { width: 200, height: 100 };
|
||||
const twoSeats = (dx, dy) => {
|
||||
const original = Render.ringPositions;
|
||||
Render.ringPositions = () => [
|
||||
{ left: 50 - (dx / 2 / boxes.width) * 100, top: 50 - (dy / 2 / boxes.height) * 100 },
|
||||
{ left: 50 + (dx / 2 / boxes.width) * 100, top: 50 + (dy / 2 / boxes.height) * 100 },
|
||||
];
|
||||
try {
|
||||
return Render.seatsCollide(2, boxes, flat, 64, 40, 0);
|
||||
} finally {
|
||||
Render.ringPositions = original;
|
||||
}
|
||||
};
|
||||
assert.ok(Math.hypot(60, 35) > 64, "and a distance test would have passed it");
|
||||
assert.equal(twoSeats(60, 35), true, "60 across and 35 down, with a 64x40 puck");
|
||||
assert.equal(twoSeats(60, 45), false, "far enough apart vertically");
|
||||
assert.equal(twoSeats(66, 10), false, "far enough apart horizontally");
|
||||
});
|
||||
|
||||
test("with daylight, so rounded corners don't read as touching", () => {
|
||||
assert.ok(Render.SEAT_CLEARANCE_PX > 0);
|
||||
});
|
||||
|
||||
test("a table nobody is sitting at can't collide with itself", () => {
|
||||
assert.equal(Render.seatsCollide(1, felt, flat, 96, 60), false);
|
||||
assert.equal(Render.seatsCollide(9, null, flat, 96, 60), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the widths the felt may choose from", () => {
|
||||
test("run widest first, because the widest that fits is the one wanted", () => {
|
||||
const widths = Render.seatWidthCandidates();
|
||||
widths.slice(1).forEach((w, i) => assert.ok(w < widths[i], `${widths[i]} then ${w}`));
|
||||
});
|
||||
|
||||
test("and span the whole declared range", () => {
|
||||
const widths = Render.seatWidthCandidates();
|
||||
assert.equal(widths[0], Render.SEAT_WIDTH_MAX);
|
||||
assert.equal(widths[widths.length - 1], Render.SEAT_WIDTH_MIN);
|
||||
});
|
||||
|
||||
test("the floor is still a name, not a smudge", () => {
|
||||
// At 44px the tight layout shows about five characters and an ellipsis. Below
|
||||
// that a puck stops telling the dealer who is sitting there, which is the one
|
||||
// thing on the felt it exists to say.
|
||||
assert.ok(Render.SEAT_WIDTH_MIN >= 44);
|
||||
assert.ok(Render.SEAT_TIGHT_BELOW_PX > Render.SEAT_WIDTH_MIN);
|
||||
assert.ok(Render.SEAT_TIGHT_BELOW_PX < Render.SEAT_WIDTH_MAX);
|
||||
});
|
||||
|
||||
test("and there are few enough of them to measure after every tap", () => {
|
||||
// Each one costs a forced layout. Ten is the budget; the cache carries the rest.
|
||||
assert.ok(Render.seatWidthCandidates().length <= 12);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the stylesheet has to keep holding up", () => {
|
||||
test("the puck's width comes from the property, not from a breakpoint", () => {
|
||||
// Three media queries used to declare it — 78px, 64px and 62px — three guesses
|
||||
// at a number that depends on how many people are sitting down, which no media
|
||||
// query can see. The same 390px phone has room for 90px at four seats and 61 at
|
||||
// nine.
|
||||
const declarations = CSS.replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
const seatRules = [...declarations.matchAll(/(^|[,}])\s*(\.seat)\s*\{([^}]*)\}/g)]
|
||||
.map(m => m[3]);
|
||||
const hardWidths = seatRules
|
||||
.flatMap(body => body.match(/(?<![\w-])width:\s*[^;]+/g) || [])
|
||||
.filter(decl => !decl.includes("var(--seatPuckW"));
|
||||
assert.deepEqual(hardWidths, []);
|
||||
});
|
||||
|
||||
test("the puck measures what it says it measures", () => {
|
||||
// As content-box a 64px puck came out 74 across once its padding and border were
|
||||
// counted, and the search compares that number against distances on the felt.
|
||||
const rule = CSS.match(/\n\.seat \{[^}]*\}/);
|
||||
assert.ok(rule, ".seat rule not found");
|
||||
assert.match(rule[0], /box-sizing:\s*border-box/);
|
||||
});
|
||||
|
||||
test("and a blind badge never reaches sideways off its own puck", () => {
|
||||
// This is what makes the footprint the puck and nothing more. The badge's
|
||||
// remaining overhang is vertical, and it points away from the table at both ends
|
||||
// — `top` on the upper arc, `bottom` on the lower — so two seats facing each
|
||||
// other across the felt wear their badges back to back.
|
||||
//
|
||||
// It was not always so: at `left: -6px` the small blind hung off the puck's left
|
||||
// corner, and on an iPhone Pro Max at six seats — pucks not touching at all —
|
||||
// "SB" was painted across the next player's $1,137.
|
||||
const badges = [...CSS.matchAll(/\.(sb|bb|str)Badge\s*\{([^}]*)\}/g)];
|
||||
assert.equal(badges.length, 3, "expected all three blind badges");
|
||||
badges.forEach(([, which, body]) => {
|
||||
const left = body.match(/(?<![\w-])left:\s*(-?[\d.]+)px/);
|
||||
assert.ok(left, `.${which}Badge should place itself horizontally`);
|
||||
assert.ok(Number(left[1]) >= 0,
|
||||
`.${which}Badge sits at ${left[1]}px, off the left of its own puck`);
|
||||
});
|
||||
});
|
||||
|
||||
test("and all three sit in the same place, so there is one spot to look at", () => {
|
||||
// Two badges in two positions means the dealer's eye has to find which of them
|
||||
// this seat is using before it can read which blind it is, forty times a night.
|
||||
// A seat only ever wears one: the blinds are different players by construction,
|
||||
// and the straddle chain starts left of the big blind and stops short of the
|
||||
// small one.
|
||||
const lefts = [...CSS.matchAll(/\.(sb|bb|str)Badge\s*\{([^}]*)\}/g)]
|
||||
.map(([, , body]) => (body.match(/(?<![\w-])left:\s*(-?[\d.]+px)/) || [])[1]);
|
||||
assert.equal(new Set(lefts).size, 1, `badges sit at ${lefts.join(", ")}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the blind badge counts", () => {
|
||||
// It didn't, for a while, and the reasoning had one hole in it. The badge hangs 9px
|
||||
// off the puck and always points away from the table — `top: -9px` on the upper arc,
|
||||
// `bottom: -9px` on the lower — so two seats facing each other across the felt wear
|
||||
// them back to back and it costs nothing. That much is true.
|
||||
//
|
||||
// What was also claimed is that two seats on the *same* arc "wear them at the same
|
||||
// height, offset by exactly as much as the pucks are". True of a straight rail,
|
||||
// false of a ring: neighbours on a curve sit at different heights, so the badge
|
||||
// pointing away from the table on one of them points at the corner of the other.
|
||||
// Measured on the running page, three tables in the sweep had a player's stack
|
||||
// painted under the next player's SB or BB — and the badge outranks everything else
|
||||
// on the felt, so what the dealer reads in that spot is the wrong number entirely.
|
||||
test("a seat needs room for the badge it might be wearing", () => {
|
||||
const puck = { width: 64, height: 40 };
|
||||
assert.deepEqual(Render.seatFootprint(puck),
|
||||
{ width: 64, height: 40 + Render.SEAT_BADGE_OVERHANG_PX });
|
||||
});
|
||||
|
||||
test("sideways it costs nothing, because the badge doesn't go sideways", () => {
|
||||
// It sits inside the puck's own width — see "all three sit in the same place"
|
||||
// above, which holds the stylesheet to it.
|
||||
assert.equal(Render.seatFootprint({ width: 64, height: 40 }).width, 64);
|
||||
});
|
||||
|
||||
test("the number here is the number the stylesheet draws", () => {
|
||||
// Two files have to agree about one distance: holdem.css hangs the badge off the
|
||||
// puck by it, render.js charges every chair in the ring for it. They are wired
|
||||
// together by nothing but this test. Drift either way is silent — too small and
|
||||
// badges land on neighbours again, too large and every puck on a nine-handed
|
||||
// phone gives up a size step for clearance nothing needs.
|
||||
const top = CSS.match(/\.blindBadge \{[^}]*?(?<![\w-])top:\s*(-?[\d.]+)px/);
|
||||
const bottom = CSS.match(/\.seat\.seatBelowCentre \.blindBadge \{[^}]*?(?<![\w-])bottom:\s*(-?[\d.]+)px/);
|
||||
assert.ok(top && bottom, "the badge should hang off both arcs");
|
||||
assert.equal(Math.abs(Number(top[1])), Render.SEAT_BADGE_OVERHANG_PX);
|
||||
assert.equal(Math.abs(Number(bottom[1])), Render.SEAT_BADGE_OVERHANG_PX,
|
||||
"the same distance at both ends, or one arc is charged for the other's badge");
|
||||
});
|
||||
|
||||
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
|
||||
// two seats on the same arc each wearing one, and generous for the far commoner
|
||||
// case where only one of the two is in the blinds at all. Counting it on both
|
||||
// sides is what made a landscape table unsatisfiable the first time this was
|
||||
// tried, back when that felt was 87px tall.
|
||||
const puck = { width: 64, height: 40 };
|
||||
const grew = Render.seatFootprint(puck).height - puck.height;
|
||||
assert.equal(grew, Render.SEAT_BADGE_OVERHANG_PX);
|
||||
assert.ok(grew < 2 * Render.SEAT_BADGE_OVERHANG_PX);
|
||||
});
|
||||
});
|
||||
|
||||
describe("with no browser to measure", () => {
|
||||
test("nothing here throws", () => {
|
||||
assert.equal(Render.measuredSeatBox(), null);
|
||||
assert.equal(Render.seatFootprint(null), null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
// What the ring is allowed to be sized from.
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// 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².
|
||||
//
|
||||
// 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");
|
||||
const { Render, HoldemState, makeTable } = require("./load");
|
||||
|
||||
// seatShapeSignature reads Render.state, so a table has to be hung on it first.
|
||||
const signatureOf = state => {
|
||||
const before = Render.state;
|
||||
Render.state = state;
|
||||
try { return Render.seatShapeSignature(); } finally { Render.state = before; }
|
||||
};
|
||||
|
||||
const table = () => makeTable({ seatCount: 6, stack: 200 });
|
||||
|
||||
describe("the signature the ring layout is cached against", () => {
|
||||
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 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[1].committed = 10;
|
||||
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", () => {
|
||||
const state = table();
|
||||
state.seats[1].committed = 10;
|
||||
const small = signatureOf(state);
|
||||
state.seats[1].committed = 999999;
|
||||
assert.equal(signatureOf(state), small,
|
||||
"a wider number is the amount fitter's problem, not the ring's");
|
||||
});
|
||||
|
||||
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";
|
||||
const renamed = signatureOf(state);
|
||||
assert.notEqual(renamed, base);
|
||||
state.dealerSeatIndex = (state.dealerSeatIndex + 1) % state.seats.length;
|
||||
assert.notEqual(signatureOf(state), renamed);
|
||||
});
|
||||
|
||||
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("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 both rows open forever.
|
||||
const state = HoldemState.createDefault();
|
||||
HoldemState.resizeSeats(state, 9);
|
||||
assert.match(signatureOf(state), /\|$/,
|
||||
"an untouched table should carry neither flag");
|
||||
});
|
||||
|
||||
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.match(signatureOf(state), /a$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
// What the seat sheet shows before it is asked.
|
||||
//
|
||||
// The sheet is the second-most-used surface in the tool — every buy-in, every rename,
|
||||
// every correction goes through it — and it was the least finished. Seven things were
|
||||
// stacked flat at one weight and all open at once: a name, two status boxes, two
|
||||
// hole-card slots, a stack, a five-box chip counter with an override and a total, and a
|
||||
// list of custom fields. Measured on a 390px phone it came to 594px, and the three
|
||||
// controls a dealer touches on nearly every visit were 132px of that — 22%. The chip
|
||||
// counter alone was 174px, the tallest single thing in the sheet, and its five number
|
||||
// boxes were told apart by five coloured dots and a `title` attribute, which is a
|
||||
// tooltip, which is nothing at all on a phone.
|
||||
//
|
||||
// Two of these describe layout decisions and are checked as text — the markup for what
|
||||
// sits in front of a dealer at rest, the stylesheet for the scrim and the order two
|
||||
// bottom-anchored things stack in. The third describes behaviour and is run: which
|
||||
// sections stand open is a question about the seat, and the answer is computed.
|
||||
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 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 template = id => {
|
||||
const at = PAGE.indexOf(`<template id="${id}">`);
|
||||
assert.ok(at >= 0, `there is no <template id="${id}">`);
|
||||
return PAGE.slice(at, PAGE.indexOf("</template>", at));
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("what the sheet puts in front of a dealer at rest", () => {
|
||||
const seatForm = () => template("seatEditorTemplate");
|
||||
|
||||
// Everything the form holds that is *not* inside one of the folded sections. The
|
||||
// <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, the two status boxes and the stack, and nothing else", () => {
|
||||
const flat = alwaysVisible();
|
||||
["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`));
|
||||
|
||||
// And the four that used to be up here with them. Each is a job most tables do
|
||||
// once a night or never, and between them they were two thirds of the sheet.
|
||||
["cardSlot", "chipCounts", "amountOverrideInput", "customFields", "addFieldBtn"]
|
||||
.forEach(cls => assert.ok(!flat.includes(cls),
|
||||
`${cls} is open at rest again — it belongs inside a folded section`));
|
||||
});
|
||||
|
||||
test("every folded section says what it holds and what is in it", () => {
|
||||
const sections = [...seatForm().matchAll(/<details class="([^"]*seatSubPanel[^"]*)">([\s\S]*?)<\/details>/g)];
|
||||
assert.equal(sections.length, 3, "the sheet folds cards, contribution and notes");
|
||||
|
||||
sections.forEach(([, className, markup]) => {
|
||||
const summary = markup.match(/<summary>([\s\S]*?)<\/summary>/);
|
||||
assert.ok(summary, `${className} has no summary to press`);
|
||||
// A name a dealer would recognise, and a place for the one fact that says
|
||||
// whether opening it is worth the tap.
|
||||
assert.match(summary[1], /<span class="seatSubPanelName">[^<]+<\/span>/,
|
||||
`${className}'s summary has no name in it`);
|
||||
assert.match(summary[1], /class="seatGlance/,
|
||||
`${className} says nothing while it is shut`);
|
||||
});
|
||||
});
|
||||
|
||||
test("the chip counter's values are text, not a tooltip", () => {
|
||||
// Five identical number boxes, each worth a different amount, told apart by a
|
||||
// coloured dot and a `title`. A title is a hover, and there is no hover here.
|
||||
// It also meant the wrapping <label> had no text in it, so the boxes had no
|
||||
// accessible name either — five unnamed spinbuttons in a row.
|
||||
assert.match(template("chipCountTemplate"), /class="chipCountValue"/,
|
||||
"the chip's value has to be in the row, where it can be read and announced");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the sheet is in front of the table, and says so", () => {
|
||||
test("there is a scrim, and it is furniture the desktop panel does not get", () => {
|
||||
assert.match(PAGE, /id="seatSheetScrim"/, "the sheet has nothing behind it");
|
||||
// Above 700px this element is not a scrim for anything — the seat form is a
|
||||
// panel in the sidebar column, with the whole page still live around it.
|
||||
const off = CSS.match(/\.seatSheetGrip,[\s\S]{0,120}?#seatSheetScrim\s*\{\s*display:\s*none/);
|
||||
assert.ok(off, "the scrim has to be off by default, with the sheet's other furniture");
|
||||
});
|
||||
|
||||
test("and it is not called an Overlay, because Modal would adopt it", () => {
|
||||
// Modal.mount() watches every id ending in "Overlay" and makes a dialog of it.
|
||||
// This one is a sidebar panel two thirds of the time.
|
||||
assert.ok(!/id="seatSheet[A-Za-z]*Overlay"/.test(PAGE));
|
||||
});
|
||||
|
||||
test("and it is put away with visibility, not with opacity", () => {
|
||||
// The same lesson the sheet above it already learned about transforms, and the
|
||||
// layout sweep caught it the same way: a fixed, full-window box at `opacity: 0`
|
||||
// is invisible and still entirely there. bottomFurniture() read it as furniture
|
||||
// standing 640px tall at the bottom of a 640px phone, in every state and on
|
||||
// every screen, and asked scroll-padding to reserve the whole window for it.
|
||||
const phone = CSS.slice(CSS.indexOf("@media (max-width: 700px)"));
|
||||
const rule = phone.match(/#seatSheetScrim\s*\{[^}]*\}/);
|
||||
assert.ok(rule, "the phone block should make a scrim of it");
|
||||
assert.match(rule[0], /visibility:\s*hidden/,
|
||||
"opacity alone leaves a full-window fixed element in the layout and the a11y tree");
|
||||
});
|
||||
|
||||
test("the scrim stops where the sheet stops when a phone is on its side", () => {
|
||||
// Fold, Call and Raise are a rail down the right-hand edge in landscape, and the
|
||||
// sheet is deliberately kept clear of it. A scrim that reached across would dim
|
||||
// the one part of the screen the sheet was making room for — and swallow its
|
||||
// taps, which is worse.
|
||||
const landscape = CSS.slice(CSS.indexOf("@media (orientation: landscape) and (max-height: 560px) and (max-width: 900px)"));
|
||||
const rule = landscape.match(/#seatSheetScrim\s*\{[^}]*\}/);
|
||||
assert.ok(rule, "the landscape block never tells the scrim about the rail");
|
||||
assert.match(rule[0], /right:\s*var\(--actionRailW\)/);
|
||||
});
|
||||
|
||||
test("the toast steps over the sheet, and the rule that says so comes last", () => {
|
||||
// Two things are fixed to the bottom of a phone and the toast is the one that
|
||||
// gives way. `body.hasActionBar #toastHost` and `body.seatSheetOpen #toastHost`
|
||||
// have identical specificity, so this is decided by source order and by nothing
|
||||
// else — a copy written above the other would look right and do nothing.
|
||||
const bar = CSS.indexOf("body.hasActionBar #toastHost");
|
||||
const sheet = CSS.indexOf("body.seatSheetOpen #toastHost");
|
||||
assert.ok(bar > 0 && sheet > 0, "both rules should exist");
|
||||
assert.ok(sheet > bar,
|
||||
"at equal specificity the later rule wins, and the sheet is the taller of the two");
|
||||
// Measured, not assumed: the height changes the moment a section is unfolded.
|
||||
assert.match(CSS.slice(sheet, sheet + 240), /var\(--seatSheetHeight/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The form the disclosures are rendered into, reduced to the two things
|
||||
// renderSeatDisclosures asks of it: find a section, find its glance.
|
||||
const fakeForm = () => {
|
||||
const sections = {};
|
||||
for (const name of ["cardsPanel", "contributionPanel", "notesPanel"]) {
|
||||
sections[`.${name}`] = {
|
||||
open: false,
|
||||
glance: null,
|
||||
querySelector(selector) {
|
||||
assert.equal(selector, ".seatGlance");
|
||||
return { set textContent(text) { sections[`.${name}`].glance = text; } };
|
||||
},
|
||||
};
|
||||
}
|
||||
return { querySelector: selector => sections[selector] || null, sections };
|
||||
};
|
||||
|
||||
const disclose = (state, player) => {
|
||||
const form = fakeForm();
|
||||
Render.state = state;
|
||||
Render.renderSeatDisclosures(form, player);
|
||||
return form.sections;
|
||||
};
|
||||
|
||||
describe("which sections stand open is a question about the seat", () => {
|
||||
test("a seat with nothing on it opens nothing", () => {
|
||||
const state = makeTable({ seatCount: 4, stack: 200 });
|
||||
const sections = disclose(state, state.seats[0]);
|
||||
assert.deepEqual(
|
||||
Object.values(sections).map(s => s.open), [false, false, false],
|
||||
"an empty seat's sheet is three lines and the three fields above them");
|
||||
});
|
||||
|
||||
test("a seat holding cards opens its cards, and names them while shut", () => {
|
||||
const state = makeTable({ seatCount: 4 });
|
||||
state.seats[0].cards = ["as", "kd"];
|
||||
const sections = disclose(state, state.seats[0]);
|
||||
assert.equal(sections[".cardsPanel"].open, true);
|
||||
// Whatever formatCard makes of them — the point is that both are in it.
|
||||
assert.match(sections[".cardsPanel"].glance, /A/);
|
||||
assert.match(sections[".cardsPanel"].glance, /K/);
|
||||
});
|
||||
|
||||
test("a table that asked to be shown the cards opens them before the first one", () => {
|
||||
// Every seat at such a table is about to have two. Opening the slots after the
|
||||
// cards arrive would be opening them one tap too late, every hand.
|
||||
const state = makeTable({ seatCount: 4 });
|
||||
state.trackBoard = true;
|
||||
assert.equal(disclose(state, state.seats[0])[".cardsPanel"].open, true);
|
||||
});
|
||||
|
||||
test("a seat with chips counted opens the count, with the total on the row", () => {
|
||||
const state = makeTable({ seatCount: 4 });
|
||||
const fiver = state.chipLegend.find(chip => chip.value === 5);
|
||||
state.seats[0].chipCounts = { [fiver.id]: 4 };
|
||||
const sections = disclose(state, state.seats[0]);
|
||||
assert.equal(sections[".contributionPanel"].open, true);
|
||||
assert.equal(sections[".contributionPanel"].glance, "$20");
|
||||
});
|
||||
|
||||
test("a typed amount counts as something on the seat too", () => {
|
||||
// Overriding the total is the other way of answering the same question, and a
|
||||
// dealer who typed a number has to be able to see it without hunting.
|
||||
const state = makeTable({ seatCount: 4 });
|
||||
state.seats[0].amountOverride = 60;
|
||||
assert.equal(disclose(state, state.seats[0])[".contributionPanel"].open, true);
|
||||
});
|
||||
|
||||
test("and a zero someone actually typed is not the same as never having typed one", () => {
|
||||
const state = makeTable({ seatCount: 4 });
|
||||
state.seats[0].amountOverride = 0;
|
||||
assert.equal(disclose(state, state.seats[0])[".contributionPanel"].open, true,
|
||||
"0 is a value; only null means the field was left alone");
|
||||
});
|
||||
|
||||
test("a seat with a note on it opens the notes and counts them", () => {
|
||||
const state = makeTable({ seatCount: 4 });
|
||||
state.seats[0].customFields = [{ label: "Owes", value: "$40" }, { label: "", value: "" }];
|
||||
const sections = disclose(state, state.seats[0]);
|
||||
assert.equal(sections[".notesPanel"].open, true);
|
||||
assert.equal(sections[".notesPanel"].glance, "1 note",
|
||||
"an empty row the dealer has not filled in yet is not a note");
|
||||
});
|
||||
|
||||
test("a section is only ever pushed open, never pulled shut", () => {
|
||||
// This runs again after every keystroke in the sheet. A dealer who opened the
|
||||
// chip counter to start counting would have it shut under them on the first
|
||||
// digit if this ever wrote `open = false`.
|
||||
const state = makeTable({ seatCount: 4 });
|
||||
const form = fakeForm();
|
||||
form.sections[".notesPanel"].open = true;
|
||||
Render.state = state;
|
||||
Render.renderSeatDisclosures(form, state.seats[0]);
|
||||
assert.equal(form.sections[".notesPanel"].open, true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
// The seat holding the dealer button has to still be a seat you can tap.
|
||||
//
|
||||
// On a fresh table the button is on seat 1, and seat 1 is where the first player goes.
|
||||
// Measured on a 390px phone, a 72x46 puck: the dealer chip's invisible tap pad ran from
|
||||
// the puck's own centre to its right edge and from its top to 3px off its bottom. The
|
||||
// chip's click handler calls stopPropagation, so every tap that landed there reached
|
||||
// nothing at all — the seat holding the button kept 52% of its own face, 130 of 276
|
||||
// sampled points going to a chip whose only answer was "give the button to the seat
|
||||
// that has it". Seating the first player, on the seat a dealer reaches for first, did
|
||||
// nothing. Every other seat kept 97%, and the button moves every hand, so the dead puck
|
||||
// moved around the table with it.
|
||||
//
|
||||
// Three things had to agree for that to happen and they are the three checked here: the
|
||||
// pad exists, the chip under it is live, and its handler eats the event. The pad is gone
|
||||
// (nothing needs it — no finger can reach any chip), the chip that has the button is
|
||||
// inert to the pointer, and the handler bails rather than swallowing a tap it has no
|
||||
// answer for.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const src = (...parts) => fs.readFileSync(path.join(__dirname, "..", "..", "JoshHeaps.Net", ...parts), "utf8");
|
||||
|
||||
const CSS = src("wwwroot", "css", "casino", "holdem.css");
|
||||
const RENDER = src("wwwroot", "js", "casino", "holdem", "render.js");
|
||||
const MAIN = src("wwwroot", "js", "casino", "holdem", "main.js");
|
||||
const AUDIT = fs.readFileSync(path.join(__dirname, "uiAudit.js"), "utf8");
|
||||
|
||||
// The body of every rule written at exactly this selector, concatenated. Every one of
|
||||
// them, because the phone block redeclared the chip's pad under the same name and it
|
||||
// was the redeclared copy — 44px centred on the disc — that reached furthest in.
|
||||
const rulesFor = selector => {
|
||||
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const re = new RegExp(`(^|[},])\\s*${escaped}\\s*\\{([^}]*)\\}`, "gm");
|
||||
const bodies = [];
|
||||
let found;
|
||||
while ((found = re.exec(CSS)) !== null) bodies.push(found[2]);
|
||||
return bodies;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("the seat under the dealer button is still a seat", () => {
|
||||
test("the chip has no invisible pad reaching back over its own puck", () => {
|
||||
// Not "a smaller pad". A fixed pad cannot be safe at every puck size: the seat
|
||||
// search hands out pucks from 48px wide to 100-odd depending on the screen and
|
||||
// the number of chairs, and any pad big enough to matter on the large one
|
||||
// covers the middle of the small one.
|
||||
assert.equal(rulesFor(".dealerChip::after").length, 0,
|
||||
"the dealer chip grew a tap pad again — see the note above .dealerChip in holdem.css");
|
||||
});
|
||||
|
||||
test("the chip that has the button takes no taps", () => {
|
||||
const bodies = rulesFor(".dealerChip.active");
|
||||
assert.ok(bodies.length > 0, ".dealerChip.active has no rule at all");
|
||||
assert.ok(bodies.some(body => /pointer-events:\s*none/.test(body)),
|
||||
"the seat holding the button has a live control sitting on it");
|
||||
});
|
||||
|
||||
test("the other chips stay inert until a pointer that can hover arrives", () => {
|
||||
// This is what makes the pad unnecessary rather than merely unwanted. On touch
|
||||
// there is no hover, so no ghost chip is reachable by a finger and the only live
|
||||
// chip is the inert one above. Nothing a thumb can press is 22px.
|
||||
const ghost = rulesFor(".dealerChip:not(.active)");
|
||||
assert.ok(ghost.some(body => /pointer-events:\s*none/.test(body)),
|
||||
"a chip nobody can see is taking taps");
|
||||
|
||||
const hover = CSS.slice(CSS.indexOf("@media (hover: hover)"));
|
||||
const revealed = hover.match(/\.seat:hover\s+\.dealerChip:not\(\.active\)\s*\{([^}]*)\}/);
|
||||
assert.ok(revealed, "hovering a seat no longer offers to put the button there");
|
||||
assert.match(revealed[1], /pointer-events:\s*auto/,
|
||||
"the mouse path to moving the button is gone");
|
||||
});
|
||||
|
||||
test("and the handler does not swallow a tap it has no answer for", () => {
|
||||
// stopPropagation is what turned a pointless control into a hole. It still runs
|
||||
// for the eight seats the button could move to; the ninth returns first.
|
||||
const handler = RENDER.slice(RENDER.indexOf('querySelector(".dealerChip").addEventListener'));
|
||||
const body = handler.slice(0, handler.indexOf("});"));
|
||||
const guard = body.indexOf("dealerSeatIndex");
|
||||
const stop = body.indexOf("stopPropagation");
|
||||
assert.ok(guard >= 0, "the chip's click handler does not check whose button it is");
|
||||
assert.ok(stop >= 0, "the chip stopped guarding the seat behind it from the other chips");
|
||||
assert.ok(guard < stop,
|
||||
"the guard has to come first, or the tap is eaten before anyone decides it meant nothing");
|
||||
});
|
||||
|
||||
test("moving the button to where it already is is not a move", () => {
|
||||
// The keyboard can still reach the chip, and the same no-op would take the
|
||||
// blinds off the felt and re-post the same chips to announce that nothing
|
||||
// happened. The seat menu has always hidden "Give the button" on the seat that
|
||||
// holds it; this is the same rule where the money is.
|
||||
const from = MAIN.indexOf("makeDealer: index =>");
|
||||
assert.ok(from >= 0, "makeDealer is gone or renamed");
|
||||
const body = MAIN.slice(from, MAIN.indexOf("blindsArePosted", from));
|
||||
assert.match(body, /if\s*\(\s*index\s*===\s*state\.dealerSeatIndex\s*\)\s*return/,
|
||||
"makeDealer will re-post the blinds to move the button nowhere");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the sweep can see this kind of failure now", () => {
|
||||
test("a control inside a seat counts as a rival for the seat's tap", () => {
|
||||
// The layout sweep already had a check named "a seat's centre belongs to that
|
||||
// seat", written after the same chip's pad grew *outward* and started taking
|
||||
// taps from the seat next door. It ran clean through all of this: it waved
|
||||
// through anything the seat contained, and the chip is inside the seat. The
|
||||
// check that existed for this exact bug could not see the second half of it.
|
||||
const from = AUDIT.indexOf("seatOwnership()");
|
||||
assert.ok(from >= 0, "the seat-ownership check is gone");
|
||||
const body = AUDIT.slice(from, AUDIT.indexOf("oneDealerButton()", from));
|
||||
|
||||
assert.match(body, /seat\.contains\(rival\)/,
|
||||
"a seat's own control is being waved through again");
|
||||
assert.doesNotMatch(body, /if\s*\(\s*!hit\s*\|\|\s*seat\.contains\(hit\)/,
|
||||
"the early return that hid this is back: it exempts every descendant, chip included");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
// How wide a seat puck is allowed to get, and on which screens.
|
||||
//
|
||||
// The felt grows with the window and the pucks did not: 96px was the only ceiling there
|
||||
// was, so nine-handed the seats covered 19% of the felt on a phone, 11% on a 1280px
|
||||
// desktop and 8% at 1440px and beyond — with the stack figure a dealer reads sitting at
|
||||
// 10px on a 2560px monitor, the same 10px as on a 360px phone.
|
||||
//
|
||||
// Two things stop the fix from costing more than it buys, and both are checked here.
|
||||
//
|
||||
// Every candidate width is a forced layout, and raising the ceiling for everybody took
|
||||
// the layout pass from 24ms to 73ms on a twelve-handed desktop — so a screen that cannot
|
||||
// use the extra widths must not measure them. And a 144px puck on an iPhone's 278px ring
|
||||
// is half the width of the table: the first version of this let phones through, and
|
||||
// six-handed all six seats sat over the rail by 15px against 5px before, reading as a
|
||||
// stack of cards rather than a table with people around it.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Render } = require("./load");
|
||||
|
||||
// Ring boxes read off the running page, not invented. #seatsLayer, nine-handed, at the
|
||||
// viewports the sweep visits.
|
||||
const RINGS = {
|
||||
"Galaxy S8": { width: 233, height: 282 },
|
||||
"iPhone 14": { width: 263, height: 470 },
|
||||
"Pro Max": { width: 303, height: 535 },
|
||||
"phone sideways": { width: 565, height: 257 },
|
||||
iPad: { width: 689, height: 689 },
|
||||
desktop: { width: 857, height: 617 },
|
||||
"desktop 1440": { width: 992, height: 714 },
|
||||
};
|
||||
const SMALL = ["Galaxy S8", "iPhone 14", "Pro Max", "phone sideways"];
|
||||
const LARGE = ["iPad", "desktop", "desktop 1440"];
|
||||
|
||||
describe("which felts may have wider pucks at all", () => {
|
||||
test("no phone does, held either way up", () => {
|
||||
// The guarantee the phones are owed: they measure the widths they always
|
||||
// measured and settle on the puck they always settled on.
|
||||
SMALL.forEach(name => {
|
||||
assert.ok(Render.roomyWidthCeiling(RINGS[name]) <= Render.SEAT_WIDTH_MAX,
|
||||
`${name} would be allowed a ${Render.roomyWidthCeiling(RINGS[name])}px puck`);
|
||||
[2, 4, 6, 9, 12].forEach(count => assert.equal(
|
||||
Render.roomForWiderPucks(count, RINGS[name]), false,
|
||||
`${name} ${count}-handed`));
|
||||
});
|
||||
});
|
||||
|
||||
test("and every tablet and desktop does", () => {
|
||||
LARGE.forEach(name => {
|
||||
assert.ok(Render.roomyWidthCeiling(RINGS[name]) > Render.SEAT_WIDTH_MAX, name);
|
||||
});
|
||||
});
|
||||
|
||||
test("with a comfortable margin either side of the line", () => {
|
||||
// The rule is a fifth of the shorter span, and the point of five is that it is
|
||||
// not near either group. If a change to the felt ever moves these together,
|
||||
// this says so before the phones quietly start paying for it.
|
||||
const biggestSmall = Math.max(...SMALL.map(n => Math.min(RINGS[n].width, RINGS[n].height)));
|
||||
const smallestLarge = Math.min(...LARGE.map(n => Math.min(RINGS[n].width, RINGS[n].height)));
|
||||
assert.ok(smallestLarge > biggestSmall * 1.8,
|
||||
`${biggestSmall}px and ${smallestLarge}px are too close for one divisor to separate`);
|
||||
// Every divisor that keeps the phones out is at least the widest phone span over
|
||||
// the ordinary ceiling; every one that lets the tablets in is below the narrowest
|
||||
// large span over the same. 303/96 = 3.2 and 617/96 = 6.4, and five is between.
|
||||
const floor = biggestSmall / Render.SEAT_WIDTH_MAX;
|
||||
const cap = smallestLarge / Render.SEAT_WIDTH_MAX;
|
||||
assert.ok(Render.SEAT_ROOMY_SPAN_RATIO >= floor,
|
||||
`${Render.SEAT_ROOMY_SPAN_RATIO} lets a phone through; it has to be at least ${floor.toFixed(2)}`);
|
||||
assert.ok(Render.SEAT_ROOMY_SPAN_RATIO < cap,
|
||||
`${Render.SEAT_ROOMY_SPAN_RATIO} shuts the tablets out; it has to be under ${cap.toFixed(2)}`);
|
||||
});
|
||||
|
||||
test("and a felt with no size at all is not one of them", () => {
|
||||
assert.equal(Render.roomForWiderPucks(9, null), false);
|
||||
assert.equal(Render.roomForWiderPucks(9, { width: 0, height: 0 }), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the widths a felt actually measures", () => {
|
||||
const widthsFor = (name, count) => Render.seatWidthCandidates(count, RINGS[name]);
|
||||
|
||||
test("a phone measures exactly the ten it measured before", () => {
|
||||
const expected = [];
|
||||
for (let w = Render.SEAT_WIDTH_MAX; w > Render.SEAT_WIDTH_MIN; w -= Render.SEAT_WIDTH_STEP) {
|
||||
expected.push(w);
|
||||
}
|
||||
expected.push(Render.SEAT_WIDTH_MIN);
|
||||
SMALL.forEach(name => [6, 9, 12].forEach(count =>
|
||||
assert.deepEqual(widthsFor(name, count), expected, `${name} ${count}-handed`)));
|
||||
});
|
||||
|
||||
test("a desktop measures a handful more, and no more than a handful", () => {
|
||||
const extra = widthsFor("desktop 1440", 9).length - widthsFor("Galaxy S8", 9).length;
|
||||
assert.ok(extra > 0, "the wider widths never got offered");
|
||||
assert.ok(extra <= 6, `${extra} extra forced layouts is more than this is worth`);
|
||||
});
|
||||
|
||||
test("never wider than the felt's own ceiling", () => {
|
||||
Object.keys(RINGS).forEach(name => [2, 6, 9, 12].forEach(count => {
|
||||
const widest = Math.max(...widthsFor(name, count));
|
||||
assert.ok(widest <= Math.max(Render.SEAT_WIDTH_MAX, Render.roomyWidthCeiling(RINGS[name])),
|
||||
`${name} ${count}-handed offered ${widest}px`);
|
||||
assert.ok(widest <= Render.SEAT_WIDTH_ROOMY_MAX);
|
||||
}));
|
||||
});
|
||||
|
||||
test("always widest first, always down to the floor", () => {
|
||||
// The search takes the first width that fits, so the order is the whole
|
||||
// algorithm — and the floor has to be on the list or a crowded table has
|
||||
// nothing left to fall back to.
|
||||
Object.keys(RINGS).forEach(name => [2, 9, 12].forEach(count => {
|
||||
const widths = widthsFor(name, count);
|
||||
widths.forEach((w, i) => {
|
||||
if (i === 0) return;
|
||||
assert.ok(w < widths[i - 1], `${name}: ${widths.join(", ")} is not descending`);
|
||||
});
|
||||
assert.equal(widths[widths.length - 1], Render.SEAT_WIDTH_MIN);
|
||||
}));
|
||||
});
|
||||
|
||||
test("and a crowded table on a big felt is told no like any other", () => {
|
||||
// Room on the felt is necessary, not sufficient: the seats still have to clear
|
||||
// each other. Enough of them on any ring and the wider widths stop being
|
||||
// offered, which is the arithmetic doing the refusing rather than the
|
||||
// measurement having to.
|
||||
const crowded = [24, 30, 40].map(count => Render.roomForWiderPucks(count, RINGS.desktop));
|
||||
assert.deepEqual(crowded, [false, false, false]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
// Which fields replace their contents when a dealer reaches for them.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Money } = require("./load");
|
||||
|
||||
@@ -51,3 +52,68 @@ test("nothing at all is not a field", () => {
|
||||
test("a field with no type stated is a text box, so it does not", () => {
|
||||
assert.equal(Money.selectsOnFocus({ tagName: "INPUT" }), false);
|
||||
});
|
||||
|
||||
// ---- A scroll is not an edit ------------------------------------------------
|
||||
//
|
||||
// A focused `<input type="number">` is a spinbutton, and the browser lets the mouse
|
||||
// wheel drive it. Measured on the running page at 1280x900 with the sidebar open: focus
|
||||
// the small blind and scroll, $5 becomes $5.50. Focus a seat's stack and scroll, $500
|
||||
// becomes $501. Both go straight through to the saved table, because these fields save
|
||||
// on `input` like everything else here.
|
||||
//
|
||||
// Nothing is announced and nothing looks wrong. The tool has invented a chip, and the
|
||||
// gesture that did it was a dealer reaching for another panel.
|
||||
//
|
||||
// The rule is narrow on purpose. Every other way of driving a spinbutton is deliberate —
|
||||
// the arrow keys, the stepper arrows, typing — and none of them is touched. Scrolling is
|
||||
// the one actuation nobody means.
|
||||
describe("a wheel over a field the dealer is in", () => {
|
||||
const box = (type, tag = "INPUT") => ({ tagName: tag, type });
|
||||
|
||||
test("a focused number box would be edited by it", () => {
|
||||
const stack = box("number");
|
||||
assert.equal(Money.wheelWouldEdit(stack, stack), true);
|
||||
});
|
||||
|
||||
test("the same box, not focused, would not", () => {
|
||||
// An unfocused spinbutton ignores the wheel. There is nothing to defend, and
|
||||
// blurring something that is not focused is a no-op with a side effect.
|
||||
const stack = box("number");
|
||||
assert.equal(Money.wheelWouldEdit(stack, null), false);
|
||||
assert.equal(Money.wheelWouldEdit(stack, box("number")), false);
|
||||
});
|
||||
|
||||
test("the amount boxes that already moved off type=number are safe as they are", () => {
|
||||
// Add chips, the raise pad and Quick Split use text + inputmode="decimal", which
|
||||
// is not a spinbutton and cannot be scrolled into a different number.
|
||||
const amount = { tagName: "INPUT", type: "text", inputMode: "decimal" };
|
||||
assert.equal(Money.wheelWouldEdit(amount, amount), false);
|
||||
// Still an amount field for the other rule, though — the two questions are
|
||||
// different and this one must not quietly answer both.
|
||||
assert.equal(Money.selectsOnFocus(amount), true);
|
||||
});
|
||||
|
||||
test("a name, a note and a table are left alone", () => {
|
||||
const name = { tagName: "INPUT", type: "text" };
|
||||
const notes = { tagName: "TEXTAREA", type: "textarea" };
|
||||
assert.equal(Money.wheelWouldEdit(name, name), false);
|
||||
assert.equal(Money.wheelWouldEdit(notes, notes), false);
|
||||
assert.equal(Money.wheelWouldEdit(null, null), false);
|
||||
assert.equal(Money.wheelWouldEdit(undefined, undefined), false);
|
||||
});
|
||||
|
||||
test("the type is read case-insensitively, the way an attribute comes back", () => {
|
||||
const shouty = box("NUMBER");
|
||||
assert.equal(Money.wheelWouldEdit(shouty, shouty), true);
|
||||
});
|
||||
|
||||
test("a select is a spinbutton to nobody", () => {
|
||||
// The page has one <select> — which way the blinds move — and it was the obvious
|
||||
// next suspect. It isn't one: focused, hovered and scrolled 300px in Chromium it
|
||||
// stays on "clockwise", because a select only takes the wheel while its dropdown
|
||||
// is open. Checked rather than assumed, and left out rather than defended
|
||||
// against, so this rule keeps meaning exactly one thing.
|
||||
const select = { tagName: "SELECT", type: "select-one" };
|
||||
assert.equal(Money.wheelWouldEdit(select, select), false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
// Who the tool asks to turn over, and in what order.
|
||||
//
|
||||
// The board half of the card feature always worked: every street, a sheet asks which
|
||||
// cards came off the deck. The hands half never did. A dealer entered five community
|
||||
// cards, pressed the showdown button, and the pot wizard told them "Chris, Mike and
|
||||
// Alex haven't turned over, so who won is still yours to say" — with nothing in the
|
||||
// sheet to turn anybody over with. The only way in was to close the wizard, tap a seat
|
||||
// puck, find the card slots in the seat editor, and repeat it for each player, with the
|
||||
// pot sitting on the table.
|
||||
//
|
||||
// So the showdown button now asks, the same way the street button does. This file is
|
||||
// the list it asks from. The sheet itself is DOM and is checked in the browser; the
|
||||
// question of who is on the list, and in which order, is state and lives here.
|
||||
//
|
||||
// Three rules hold it together:
|
||||
//
|
||||
// Only when asked for. The setting is off by default and the list is empty when it
|
||||
// is off, so a table that never wanted this never sees a sheet.
|
||||
//
|
||||
// Only what isn't known. A hand already entered is an answer already given, and
|
||||
// asking again offers to overwrite it.
|
||||
//
|
||||
// In the order the cards actually come over — the showdown order the action bar
|
||||
// already announces, then round the table from there. A dealer looking at the seat
|
||||
// the bar just named should not be asked about the one behind them.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { Render, Betting, HoldemState, makeTable } = require("./load");
|
||||
|
||||
// A hand played to the river with everyone still in, which is the only shape that
|
||||
// has a showdown to ask about.
|
||||
const atShowdown = ({ seats = 4, tracking = true } = {}) => {
|
||||
const state = makeTable({ seatCount: seats, stack: 500, smallBlind: 5, bigBlind: 10 });
|
||||
Betting.beginHand(state);
|
||||
state.street = "river";
|
||||
state.board = ["2C", "7D", "9S", "JH", "3S"];
|
||||
state.trackBoard = tracking;
|
||||
state.seats.forEach(player => { player.hasActed = true; });
|
||||
return state;
|
||||
};
|
||||
|
||||
const queue = state => Render.showdownCardQueue(state);
|
||||
|
||||
describe("only when the dealer asked to be asked", () => {
|
||||
test("the setting off means nobody is asked anything", () => {
|
||||
assert.deepStrictEqual(queue(atShowdown({ tracking: false })), []);
|
||||
});
|
||||
|
||||
test("the setting on asks everyone still in", () => {
|
||||
assert.strictEqual(queue(atShowdown()).length, 4);
|
||||
});
|
||||
|
||||
test("a table nobody is sitting at is not a showdown", () => {
|
||||
const state = atShowdown();
|
||||
state.seats.forEach(player => { player.inHand = false; });
|
||||
assert.deepStrictEqual(queue(state), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a hand won without a showdown", () => {
|
||||
// Everyone else folded, so there is one hand left and it never comes over. The
|
||||
// bar says "Award pot to Chris" here, not "Showdown", and asking Chris to turn
|
||||
// over a hand nobody paid to see is asking for a card the tool will never use.
|
||||
test("one player left is asked for nothing", () => {
|
||||
const state = atShowdown();
|
||||
[1, 2, 3].forEach(i => { state.seats[i].folded = true; });
|
||||
assert.deepStrictEqual(queue(state), []);
|
||||
});
|
||||
|
||||
test("two left is a showdown, however it was arrived at", () => {
|
||||
const state = atShowdown();
|
||||
[2, 3].forEach(i => { state.seats[i].folded = true; });
|
||||
assert.strictEqual(queue(state).length, 2);
|
||||
});
|
||||
|
||||
// Everybody all-in on the flop is still a showdown: the cards go on their backs,
|
||||
// the board runs out, and the hands are read exactly as they would have been.
|
||||
test("all-in with the betting finished is still asked about", () => {
|
||||
const state = atShowdown();
|
||||
state.seats.forEach(player => { player.stack = 0; });
|
||||
assert.ok(!Betting.canStillBet(state));
|
||||
assert.strictEqual(queue(state).length, 4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("only the hands it doesn't already have", () => {
|
||||
test("a hand already entered is not asked for again", () => {
|
||||
const state = atShowdown();
|
||||
state.seats[1].cards = ["AS", "AH"];
|
||||
assert.ok(!queue(state).includes(1));
|
||||
});
|
||||
|
||||
test("half a hand entered is still asked for", () => {
|
||||
const state = atShowdown();
|
||||
state.seats[1].cards = ["AS", ""];
|
||||
assert.ok(queue(state).includes(1));
|
||||
});
|
||||
|
||||
test("every hand entered means the sheet never opens", () => {
|
||||
const state = atShowdown();
|
||||
const hands = [["AS", "AH"], ["KS", "KH"], ["QS", "QH"], ["9H", "9D"]];
|
||||
state.seats.forEach((player, i) => { player.cards = hands[i]; });
|
||||
assert.deepStrictEqual(queue(state), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("in the order the cards come over", () => {
|
||||
// The bar already says "Mike shows first — last to bet or raise" out loud. The
|
||||
// sheet asking about somebody else first would be the tool contradicting itself
|
||||
// in the same breath.
|
||||
test("it starts with whoever the bar says shows first", () => {
|
||||
const state = atShowdown();
|
||||
state.streetAggressorIndex = 2;
|
||||
assert.strictEqual(Betting.showdownOrder(state).index, 2);
|
||||
assert.strictEqual(queue(state)[0], 2);
|
||||
});
|
||||
|
||||
test("and then goes round the table from there", () => {
|
||||
const state = atShowdown();
|
||||
state.streetAggressorIndex = 2;
|
||||
assert.deepStrictEqual(queue(state), [2, 3, 0, 1]);
|
||||
});
|
||||
|
||||
test("nobody checked the river, so it starts left of the button", () => {
|
||||
const state = atShowdown();
|
||||
state.streetAggressorIndex = null;
|
||||
state.handAggressorIndex = null;
|
||||
const first = Betting.showdownOrder(state);
|
||||
assert.strictEqual(first.reason, "leftOfButton");
|
||||
assert.strictEqual(queue(state)[0], first.index);
|
||||
});
|
||||
|
||||
test("a player already entered drops out of the order without moving the rest", () => {
|
||||
const state = atShowdown();
|
||||
state.streetAggressorIndex = 2;
|
||||
state.seats[3].cards = ["KS", "KH"];
|
||||
assert.deepStrictEqual(queue(state), [2, 0, 1]);
|
||||
});
|
||||
|
||||
test("everyone still in is asked exactly once", () => {
|
||||
const state = atShowdown({ seats: 9 });
|
||||
const asked = queue(state);
|
||||
assert.strictEqual(new Set(asked).size, asked.length);
|
||||
assert.strictEqual(asked.length, Betting.liveSeats(state).length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
// What the sidebar is a list of.
|
||||
//
|
||||
// The panels were Table Setup, Session, Chip Legend, Keyboard and Table Notes, and
|
||||
// "Session" was a container word rather than a name. It held the standings, a waiting
|
||||
// list, the hand log and the save/load/reset buttons — a topic, a task, a log and three
|
||||
// utilities under one label, which is the "don't mix apples and oranges" failure: a
|
||||
// navigation layer with more than one logic in it. Measured at 1206px and 103 visible
|
||||
// elements, against 555 and 29 for the next biggest, and "The table itself" sat 1052px
|
||||
// down inside it, so reaching Reset table meant opening a panel called Session and
|
||||
// scrolling past the money and the log.
|
||||
//
|
||||
// This is a markup test because that is where the fault lived. Nothing was broken —
|
||||
// every control worked and every id resolved — so nothing failed. What was wrong was
|
||||
// which panel a thing was in and what that panel was called.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const PAGE = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
|
||||
|
||||
// A sidebar panel never contains another one — Table Setup's sub-panels carry a
|
||||
// different class — so splitting on the opening tag is enough to say which panel any
|
||||
// piece of markup is in, without parsing the document.
|
||||
const panels = () => PAGE
|
||||
.split(/<details class="sidebarPanel"/)
|
||||
.slice(1)
|
||||
.map(chunk => {
|
||||
const name = chunk.match(/<summary>\s*(?:<h2>)?([^<]+)/);
|
||||
return { name: name ? name[1].trim() : "(no summary)", markup: chunk };
|
||||
});
|
||||
|
||||
const panelHolding = id => {
|
||||
const found = panels().filter(p => p.markup.includes(`id="${id}"`));
|
||||
assert.equal(found.length, 1, `${id} is in ${found.length} panels, expected exactly 1`);
|
||||
return found[0].name;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("the sidebar's names", () => {
|
||||
test("say what is inside them", () => {
|
||||
// Every one of these is a thing a dealer goes looking for. None of them is a
|
||||
// word that could hold anything — which is the test "Session" fails.
|
||||
assert.deepEqual(panels().map(p => p.name), [
|
||||
"Table Setup",
|
||||
"Standings",
|
||||
"Waiting list",
|
||||
"Hands",
|
||||
"Chip Legend",
|
||||
"Keyboard",
|
||||
"Table Notes",
|
||||
"Save & reset",
|
||||
]);
|
||||
});
|
||||
|
||||
test("and each one is a heading, not only a button", () => {
|
||||
// A <summary> is announced as a button. The sidebar's names are the whole of its
|
||||
// navigation, so without a heading inside, none of them is reachable by anyone
|
||||
// moving through the page by heading — and splitting Session removed the four
|
||||
// subheads that used to be the only headings down here.
|
||||
panels().forEach(p => assert.match(p.markup, /<summary><h2>/,
|
||||
`the ${p.name} panel's name is not a heading`));
|
||||
});
|
||||
});
|
||||
|
||||
describe("where things live", () => {
|
||||
test("the money is together, and the warning about losing it is with it", () => {
|
||||
assert.equal(panelHolding("standingsList"), "Standings");
|
||||
assert.equal(panelHolding("settleUp"), "Standings");
|
||||
assert.equal(panelHolding("bankReconcile"), "Standings");
|
||||
// renderStorageWarning() says this "sits at the top of the panel a dealer opens
|
||||
// to check the money". That panel is now called Standings.
|
||||
assert.equal(panelHolding("storageWarning"), "Standings");
|
||||
});
|
||||
|
||||
test("the queue for a seat is its own panel", () => {
|
||||
assert.equal(panelHolding("waitingList"), "Waiting list");
|
||||
assert.equal(panelHolding("waitingListForm"), "Waiting list");
|
||||
});
|
||||
|
||||
test("the log and the two ways out of it are together", () => {
|
||||
assert.equal(panelHolding("handHistoryList"), "Hands");
|
||||
assert.equal(panelHolding("handHistoryFilter"), "Hands");
|
||||
assert.equal(panelHolding("exportHistoryBtn"), "Hands");
|
||||
assert.equal(panelHolding("clearHistoryBtn"), "Hands");
|
||||
});
|
||||
|
||||
test("and the file is last, with the danger on the label", () => {
|
||||
assert.equal(panelHolding("saveTableBtn"), "Save & reset");
|
||||
assert.equal(panelHolding("loadTableBtn"), "Save & reset");
|
||||
assert.equal(panelHolding("resetTableBtn"), "Save & reset");
|
||||
assert.equal(panels()[panels().length - 1].name, "Save & reset",
|
||||
"the panel that ends the night belongs at the end, away from the working ones");
|
||||
});
|
||||
});
|
||||
|
||||
describe("no panel goes back to being a grab-bag", () => {
|
||||
// Not a size limit — Table Setup is legitimately long and Chip Legend legitimately
|
||||
// repeats a row per chip. What this counts is *subjects*: a panel that needs its own
|
||||
// headings to say what its parts are is a panel holding more than one thing, which
|
||||
// is what Session was and how it got that way.
|
||||
test("at most one subject needs a heading inside a panel", () => {
|
||||
panels().forEach(p => {
|
||||
const subheads = [...p.markup.matchAll(/<h3 class="panelSubhead">([^<]+)</g)]
|
||||
.map(m => m[1].trim());
|
||||
assert.ok(subheads.length <= 1,
|
||||
`${p.name} has ${subheads.length} sections inside it (${subheads.join(", ")}) — ` +
|
||||
`if a panel needs headings to list its parts, its name is not doing the work`);
|
||||
});
|
||||
});
|
||||
|
||||
test("and the ones that remain are a detail of their panel, not a second subject", () => {
|
||||
// Settle up is the standings once the night ends; Stacks in chips is the chip
|
||||
// legend counted out. Both are the same subject at a different moment, which is
|
||||
// the case a subhead is for.
|
||||
const kept = panels().flatMap(p =>
|
||||
[...p.markup.matchAll(/<h3 class="panelSubhead">([^<]+)</g)]
|
||||
.map(m => `${p.name}: ${m[1].trim()}`));
|
||||
assert.deepEqual(kept, ["Standings: Settle up", "Chip Legend: Stacks in chips"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
// The toast that outlived the thing it was talking about.
|
||||
//
|
||||
// Toasts are news about the last action, and they carry an Undo. Take another action
|
||||
// and both of those go wrong at once: the sentence describes a step that is now two
|
||||
// steps back, and the Undo behind it restores a snapshot from before everything since.
|
||||
// "Next hand — button on seat 2. [Undo]" left up while a hand is dealt and three
|
||||
// players call is an offer to move the button, wired to a button that deletes the hand.
|
||||
//
|
||||
// It is also simply in the way. The toast is a fixed strip near the bottom of the
|
||||
// screen; on a 360x640 phone there is nowhere for it to sit that is clear of both the
|
||||
// felt and the action bar, so a stale one covers seats and controls for eight seconds.
|
||||
//
|
||||
// So the bar drops it as it takes the next action. These tests hold that funnel shut:
|
||||
// every action a dealer can take from the bar goes through `commit`, and `commit`
|
||||
// clears the toast before it does anything else.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { ActionBar, Betting, makeTable } = require("./load");
|
||||
|
||||
const SRC = path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem");
|
||||
const barSource = fs.readFileSync(path.join(SRC, "actionBar.js"), "utf8");
|
||||
|
||||
// A bar sat at a real table, with the toast replaced by something that keeps a log
|
||||
// and the re-render stubbed out — none of these rules need the DOM back.
|
||||
function onTable() {
|
||||
const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 1, bigBlind: 2, dealerSeatIndex: 0 });
|
||||
const log = [];
|
||||
globalThis.Toast = {
|
||||
onScreen: null,
|
||||
show(message) { this.onScreen = message; log.push(`show ${message}`); },
|
||||
dismiss() { this.onScreen = null; log.push("dismiss"); },
|
||||
};
|
||||
ActionBar.state = state;
|
||||
ActionBar.render = () => {};
|
||||
ActionBar.hooks = {
|
||||
beginAction: label => log.push(`begin ${label}`),
|
||||
save: () => log.push("save"),
|
||||
rerender: () => log.push("rerender"),
|
||||
};
|
||||
return { bar: ActionBar, state, log };
|
||||
}
|
||||
|
||||
test.afterEach(() => { delete globalThis.Toast; });
|
||||
|
||||
describe("a toast does not outlive its own news", () => {
|
||||
test("the next action from the bar clears it", () => {
|
||||
const { bar, state, log } = onTable();
|
||||
Toast.show("Next hand — button on seat 2.");
|
||||
|
||||
bar.commit("dealing the hand", () => Betting.beginHand(state));
|
||||
|
||||
assert.equal(Toast.onScreen, null, "the hand is dealt; the sentence about seating the table is not news any more");
|
||||
assert.ok(log.indexOf("dismiss") < log.indexOf("begin dealing the hand"),
|
||||
`the toast goes before the action is recorded, so nothing shown during the action is swept up with it: ${log.join(", ")}`);
|
||||
});
|
||||
|
||||
test("a toast raised by the action itself survives", () => {
|
||||
const { bar, log } = onTable();
|
||||
bar.commit("something that talks back", () => Toast.show("Bob is all in."));
|
||||
|
||||
assert.equal(Toast.onScreen, "Bob is all in.",
|
||||
"commit clears what was there when it started, not what its own work puts up");
|
||||
assert.deepEqual(log, ["dismiss", "begin something that talks back", "show Bob is all in.", "save", "rerender"]);
|
||||
});
|
||||
|
||||
test("with no toast on screen it is still just one dismiss", () => {
|
||||
const { bar, log } = onTable();
|
||||
bar.commit("a fold", () => {});
|
||||
assert.equal(log.filter(l => l === "dismiss").length, 1);
|
||||
});
|
||||
|
||||
test("a page with no Toast at all does not throw", () => {
|
||||
const { bar, state } = onTable();
|
||||
delete globalThis.Toast;
|
||||
assert.doesNotThrow(() => bar.commit("dealing the hand", () => Betting.beginHand(state)));
|
||||
});
|
||||
});
|
||||
|
||||
describe("every bar action really does go through that funnel", () => {
|
||||
// The dismiss is worth nothing if some button reaches past `commit` to mutate the
|
||||
// table itself. Actions are written as `this.commit(...)`; a `Betting.` call made
|
||||
// straight from a click handler would be one that skips the clean-up — and the
|
||||
// undo history with it, which is the older reason this funnel exists.
|
||||
test("no click handler calls the betting engine directly", () => {
|
||||
const strays = barSource
|
||||
.split("\n")
|
||||
.map((line, i) => [i + 1, line])
|
||||
.filter(([, line]) => /=>\s*Betting\.(fold|check|call|allIn|raiseTo|beginHand|closeStreet)\(/.test(line))
|
||||
.filter(([, line]) => !/this\.commit\(/.test(line))
|
||||
.map(([n, line]) => `actionBar.js:${n}: ${line.trim()}`);
|
||||
|
||||
assert.deepEqual(strays, [], "these change the table without going through commit");
|
||||
});
|
||||
|
||||
test("commit is what clears the toast, not each button", () => {
|
||||
const dismissals = barSource.split("\n").filter(l => /Toast\.dismiss\(\)/.test(l)).length;
|
||||
// Two: this one, and the raise pad — which opens without committing anything,
|
||||
// and needs the strip clear because the pad makes the bar tall enough to push
|
||||
// a toast up over the seats.
|
||||
assert.equal(dismissals, 2,
|
||||
"a third dismissal means a button is doing by hand what commit already does");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
// What stands between the top of a phone and the felt.
|
||||
//
|
||||
// The header was 120px of a 390x844 phone — 16% of the window, spent before the table
|
||||
// started — and only 46 of that was a control. The other 74 were a 22px page title with
|
||||
// a gold rule under it, a back link, and a gold "Hand #1" pill: three things read once,
|
||||
// above a screen whose subject is the green table underneath.
|
||||
//
|
||||
// A phone held sideways had already solved this. The landscape block clips the title and
|
||||
// puts everything on one row, because at 390px tall there was no other option. Upright
|
||||
// never got the same answer, and upright is how this tool is held: measured after, the
|
||||
// header is 44px and the felt went from 496px to 570.
|
||||
//
|
||||
// Two claims are checked here. The stylesheet's, as text — the same way the seat sheet's
|
||||
// and the reduced-motion rules are — because "which row is this on" is a layout decision
|
||||
// that lives in the file. And the action bar's, by running it: the hand number had to go
|
||||
// somewhere, and where it went is a rendering question.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { ActionBar, 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");
|
||||
|
||||
// Every @media block written at exactly this condition, concatenated.
|
||||
//
|
||||
// All of them, not the first one — `@media (max-width: 700px)` is opened twice in this
|
||||
// file, the second time to hold the phone layout proper, and a helper that stopped at
|
||||
// the first found a block with none of these rules in it and reported 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);
|
||||
return found ? found[2] : null;
|
||||
};
|
||||
|
||||
const PHONE = () => mediaBlock("(max-width: 700px)");
|
||||
// The header's sideways rules are in the plain landscape query, not the one that also
|
||||
// caps the width — that later block is the seat sheet's and the action rail's. Both
|
||||
// exist, and picking the wrong one finds a block with no header in it at all.
|
||||
const LANDSCAPE = () => mediaBlock("(orientation: landscape) and (max-height: 560px)");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("the header is one row on a phone", () => {
|
||||
test("upright gets the shape a phone on its side already had", () => {
|
||||
// These four together are the whole change: don't wrap, drop the word beside the
|
||||
// arrow, clip the title, and let the buttons size themselves in what is left.
|
||||
const phone = PHONE();
|
||||
assert.match(ruleIn(phone, "#holdemHeader") || "", /flex-wrap:\s*nowrap/,
|
||||
"a wrapping header is a two-row header");
|
||||
assert.match(ruleIn(phone, "#holdemHeaderLeft") || "", /display:\s*contents/,
|
||||
"the arrow has to be a flex item of the header, not a block inside a block");
|
||||
assert.ok(ruleIn(phone, ".backLinkWord"), "the word beside the arrow is not dropped");
|
||||
assert.ok(ruleIn(phone, "#holdemHeader h1"), "the title is not addressed on a phone");
|
||||
});
|
||||
|
||||
test("both narrow layouts clip the title rather than removing it", () => {
|
||||
// `display: none` would take the page's only <h1> off the accessibility tree, and
|
||||
// a page whose heading disappears below 700px is a page with no heading on the
|
||||
// device it is mostly read on.
|
||||
[["upright", PHONE()], ["sideways", LANDSCAPE()]].forEach(([which, block]) => {
|
||||
const rule = ruleIn(block, "#holdemHeader h1");
|
||||
assert.ok(rule, `${which} does not address the title`);
|
||||
assert.doesNotMatch(rule, /display:\s*none/,
|
||||
`${which} removes the page's heading instead of hiding it`);
|
||||
assert.match(rule, /clip-path:\s*inset\(50%\)/,
|
||||
`${which} should clip the heading the way .visuallyHidden does`);
|
||||
});
|
||||
});
|
||||
|
||||
test("the actions row stops claiming a row of its own", () => {
|
||||
// `width: 100%` is what made it a second row: a flex child at full width cannot
|
||||
// share a line with anything, whatever the wrap setting says.
|
||||
const rule = ruleIn(PHONE(), "#holdemHeaderActions") || "";
|
||||
assert.doesNotMatch(rule, /width:\s*100%/);
|
||||
assert.match(rule, /flex:\s*1/, "it should take the width the title gave up");
|
||||
});
|
||||
|
||||
test("and the buttons grow into that width without ever shrinking below their labels", () => {
|
||||
// `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/);
|
||||
assert.match(rule, /min-height:\s*46px/, "across is the only direction that tightens");
|
||||
});
|
||||
|
||||
test("the arrow keeps a thumb-sized box once it loses its word", () => {
|
||||
// The word used to carry the width, and the padding only had to make up the
|
||||
// height. With the word gone the arrow is the whole target, and the first
|
||||
// attempt at 32px was measured by the layout sweep at 31x43 on every touch
|
||||
// viewport it runs — 56 scenarios, one control.
|
||||
[["upright", PHONE()], ["sideways", LANDSCAPE()]].forEach(([which, block]) => {
|
||||
const rule = ruleIn(block, ".holdemBackLink") || "";
|
||||
assert.match(rule, /min-width:\s*44px/, `${which}'s back arrow is under the touch floor`);
|
||||
// The negative margins that gave the hit area back to a two-row header would
|
||||
// now pull the arrow out of a row it is the first item of.
|
||||
assert.match(rule, /margin:\s*0/);
|
||||
});
|
||||
});
|
||||
|
||||
test("and it still says where it goes when the word is gone", () => {
|
||||
// An "←" on its own is announced as "left arrow" and names nothing. The label
|
||||
// contains the visible text on the sizes that still show it, which is what
|
||||
// WCAG 2.5.3 asks of a name that says more than the label does.
|
||||
const link = PAGE.match(/<a href="\/casino"[^>]*>/);
|
||||
assert.ok(link, "the back link is gone");
|
||||
const label = link[0].match(/aria-label="([^"]+)"/);
|
||||
assert.ok(label, "the back link has no accessible name for when the word is hidden");
|
||||
assert.match(label[1], /Casino/,
|
||||
"the visible word has to appear in the spoken name");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the hand number found somewhere to live", () => {
|
||||
test("it is not a pill beside the page title any more", () => {
|
||||
assert.ok(!PAGE.includes("handCounterBadge"),
|
||||
"a counter read after the fact does not earn a line of a phone's header");
|
||||
});
|
||||
|
||||
test("it is in the row that already says what hand you are in", () => {
|
||||
// The street, the pot and the player count are the other three facts about the
|
||||
// current hand, and they are drawn together. This one costs nothing there: the
|
||||
// row was on the screen already.
|
||||
const source = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem", "actionBar.js"),
|
||||
"utf8");
|
||||
// Sliced from the top of renderStatus to the first mention of the street, so
|
||||
// finding the hand chip inside that slice is the ordering claim as well as the
|
||||
// presence one: it is appended before the street or it is not in here.
|
||||
const status = source.slice(source.indexOf("renderStatus()"), source.indexOf("actionStreet"));
|
||||
assert.match(status, /actionHand/,
|
||||
"the status row does not say which hand this is, or says it after the street");
|
||||
});
|
||||
|
||||
test("a table that has never dealt says #1, not #0 and not #undefined", () => {
|
||||
const state = makeTable({ seatCount: 4 });
|
||||
delete state.handNumber;
|
||||
assert.equal(ActionBar.handLabel(state), "#1");
|
||||
assert.equal(ActionBar.handLabel({ handNumber: 0 }), "#1");
|
||||
assert.equal(ActionBar.handLabel({ handNumber: 12 }), "#12");
|
||||
assert.equal(ActionBar.handLabel(null), "#1");
|
||||
});
|
||||
|
||||
test("it is drawn quieter than the street beside it", () => {
|
||||
// The street is read every deal; this is read when somebody asks about a hand
|
||||
// that is already over. Same row, different job, and the colour says so.
|
||||
const rule = CSS.match(/\.actionHand\s*\{([^}]*)\}/);
|
||||
assert.ok(rule, ".actionHand has no rule");
|
||||
assert.match(rule[1], /color:\s*var\(--text-dim\)/);
|
||||
// A number that ticks over while the dealer is looking at it.
|
||||
assert.match(rule[1], /font-variant-numeric:\s*tabular-nums/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,500 @@
|
||||
// A phone held sideways, and the three pieces of arithmetic that make the table fit on
|
||||
// one.
|
||||
//
|
||||
// The layout audit is what proved this was broken and what proves it is fixed — it
|
||||
// measures the drawn page, which is the only place a layout is really true. What is
|
||||
// here is the part underneath that can be wrong on its own: which shape the bar is in,
|
||||
// how much of the bottom that leaves spoken for, how a name is shortened when it will
|
||||
// not fit, and how the ring is pulled back in when a puck lands past the rail.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { ActionBar, Render } = require("./load");
|
||||
|
||||
const CSS = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("which shape the bar is in", () => {
|
||||
// The layout is chosen by a media query — orientation, height and width together.
|
||||
// The one thing that must not happen is a second copy of that query in JavaScript,
|
||||
// because the first thing anybody changes is the breakpoint, in one of the two
|
||||
// places. So the stylesheet declares which layout it picked and actionBar.js reads
|
||||
// the answer back.
|
||||
const withComputed = (value, run) => {
|
||||
const host = { id: "actionBar" };
|
||||
global.getComputedStyle = el => ({
|
||||
getPropertyValue: prop => (el === host && prop === "--actionBarLayout" ? value : ""),
|
||||
});
|
||||
try { return run(host); } finally { delete global.getComputedStyle; }
|
||||
};
|
||||
|
||||
test("the stylesheet says, and this reads", () => {
|
||||
assert.equal(withComputed(" rail ", host => ActionBar.isRail(host)), true);
|
||||
assert.equal(withComputed(" bar ", host => ActionBar.isRail(host)), false);
|
||||
});
|
||||
|
||||
test("anything it does not recognise is the bar it has always been", () => {
|
||||
assert.equal(withComputed("", host => ActionBar.isRail(host)), false);
|
||||
assert.equal(withComputed("sidebar", host => ActionBar.isRail(host)), false);
|
||||
});
|
||||
|
||||
test("and with no browser at all, likewise", () => {
|
||||
assert.equal(ActionBar.isRail(null), false);
|
||||
assert.equal(ActionBar.isRail({ id: "actionBar" }), false,
|
||||
"no getComputedStyle to ask — this runs in the unit suite too");
|
||||
});
|
||||
|
||||
test("the stylesheet declares both halves of it", () => {
|
||||
// If either of these goes, isRail() quietly answers "bar" forever and the felt
|
||||
// is measured against a bar that is not underneath it.
|
||||
assert.match(CSS, /#actionBar\s*\{[^}]*--actionBarLayout:\s*bar/,
|
||||
"the default, on the base rule");
|
||||
assert.match(CSS, /--actionBarLayout:\s*rail/,
|
||||
"and the rail, inside the landscape query");
|
||||
});
|
||||
|
||||
test("the rail is scoped to a phone, not to any short window", () => {
|
||||
// Above 900px the sidebar is already a right-hand column and a rail would land
|
||||
// on top of it.
|
||||
const query = CSS.match(/@media \(orientation: landscape\)[^{]*\{[\s\S]{0,400}?--actionBarLayout: rail/);
|
||||
assert.ok(query, "the rail lives in a landscape media query");
|
||||
assert.match(query[0], /max-width:\s*900px/);
|
||||
assert.match(query[0], /max-height:\s*560px/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("the bar lines up with the table", () => {
|
||||
// On a desktop the bar was centred on the window and the table on its own column,
|
||||
// and the sidebar takes 320px out of the right of that — so the two things a dealer
|
||||
// is looking at were 172px apart, at 960px wide and at 1600px and everywhere in
|
||||
// between, because the offset is half the sidebar and the sidebar is a constant.
|
||||
//
|
||||
// The rule that fixes it is four numbers that have to keep agreeing with four other
|
||||
// numbers, none of them anywhere near each other in the file. That is what this
|
||||
// block is for: the arithmetic is checked on the running page by measurement (bar
|
||||
// centre against table-column centre, which came out 0px off at every width), and
|
||||
// the numbers it depends on are checked here so they cannot drift apart afterwards.
|
||||
const rule = CSS.match(/@media \(min-width: 901px\) \{\s*#actionBar \{([^}]*)\}/);
|
||||
|
||||
test("there is a rule, and it is scoped above the one-column breakpoint", () => {
|
||||
// Below 901px the sidebar is not a column at all — it is stacked under the table
|
||||
// — so there is nothing to lean away from and the bar is centred as it was.
|
||||
assert.ok(rule, "no min-width: 901px rule for #actionBar");
|
||||
assert.match(CSS, /@media \(max-width: 900px\)[\s\S]{0,200}#holdemLayout \{\s*grid-template-columns: 1fr;/,
|
||||
"the one-column layout should start exactly where this rule stops");
|
||||
});
|
||||
|
||||
test("it leans away from the sidebar by the sidebar's own width", () => {
|
||||
assert.match(rule[1], /left:\s*var\(--sp-6\)/);
|
||||
assert.match(rule[1], /right:\s*calc\(var\(--sidebarW\)\s*\+\s*var\(--sp-5\)\s*\+\s*var\(--sp-6\)\)/);
|
||||
});
|
||||
|
||||
test("and the sidebar's width is one number, not two", () => {
|
||||
// The grid draws the column and the bar leans away from it. Written out twice,
|
||||
// the day somebody widens the sidebar is the day the bar goes crooked and no
|
||||
// test says so.
|
||||
assert.match(CSS, /--sidebarW:\s*\d+px/, "the token should be declared");
|
||||
assert.match(CSS, /grid-template-columns:\s*1fr var\(--sidebarW\)/,
|
||||
"the grid should draw the column from the token");
|
||||
});
|
||||
|
||||
test("no 100vw anywhere in it", () => {
|
||||
// The bar's own centring comment warns about this: on a page with a scrollbar
|
||||
// 100vw is wider than the viewport, and the overflow drags the table sideways.
|
||||
// The offsets are exact without it — see the derivation in the stylesheet.
|
||||
assert.equal(/100vw/.test(rule[1]), false, rule[1]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("the button seat's name", () => {
|
||||
// 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("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 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("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("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 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");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("how much of the bottom is spoken for", () => {
|
||||
// feltRoomFor asks one question — what is under the felt — and gets two different
|
||||
// answers depending on the layout. Under the bar it is the bar. Under the rail it is
|
||||
// the table hint and the two links, because the bar is off to the side.
|
||||
const fakeSection = (wrapBottom, sectionBottom) => {
|
||||
const wrap = {
|
||||
getBoundingClientRect: () => ({ bottom: wrapBottom }),
|
||||
parentElement: { getBoundingClientRect: () => ({ bottom: sectionBottom }) },
|
||||
};
|
||||
global.document = { getElementById: id => (id === "pokerTableWrap" ? wrap : null) };
|
||||
};
|
||||
|
||||
test.afterEach(() => { delete global.document; });
|
||||
|
||||
test("it is the gap under the felt, links and all", () => {
|
||||
fakeSection(300, 342);
|
||||
assert.equal(ActionBar.roomBelowFelt(), 42);
|
||||
});
|
||||
|
||||
test("a difference, not a height — so it does not chase the felt it is sizing", () => {
|
||||
// The wrap's own height comes from --feltRoomPx, which is what this feeds. Read
|
||||
// as "how tall is everything under the felt" off the section's height it would
|
||||
// move every time the felt did, and settle nowhere.
|
||||
fakeSection(300, 342);
|
||||
const first = ActionBar.roomBelowFelt();
|
||||
fakeSection(500, 542); // the felt grew by 200; what is under it did not
|
||||
assert.equal(ActionBar.roomBelowFelt(), first);
|
||||
});
|
||||
|
||||
test("nothing under the felt is nothing to reserve", () => {
|
||||
fakeSection(300, 300);
|
||||
assert.equal(ActionBar.roomBelowFelt(), 0);
|
||||
});
|
||||
|
||||
test("and a section that measures shorter than its own child is not negative room", () => {
|
||||
fakeSection(300, 288);
|
||||
assert.equal(ActionBar.roomBelowFelt(), 0);
|
||||
});
|
||||
|
||||
test("with no table on the page it asks for nothing", () => {
|
||||
global.document = { getElementById: () => null };
|
||||
assert.equal(ActionBar.roomBelowFelt(), 0);
|
||||
});
|
||||
|
||||
test("the felt gets the height the bar used to take", () => {
|
||||
// The whole point, in one line. At 640x360 the bar was 205px of a 360px window
|
||||
// and the felt got 85. Beside the table instead, the same window gives the felt
|
||||
// everything but the header and the links.
|
||||
const asABar = ActionBar.feltRoomFor(360, 205, 70);
|
||||
const asARail = ActionBar.feltRoomFor(360, 30, 70);
|
||||
assert.ok(asARail > asABar * 2, `${asABar}px became ${asARail}px`);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("a name that will not fit", () => {
|
||||
// Shortened at a word, not chopped in the middle of one. A puck is 34px of text on
|
||||
// a nine-handed phone, and what the ellipsis produced for the four names on the test
|
||||
// table was "Christo…", "Mike / from…", "Alexan…". Only one of those is throwing
|
||||
// away something the dealer does not need: he is Mike, and the rest of it is how
|
||||
// this table tells him from the other Mike.
|
||||
//
|
||||
// The label element is faked here rather than measured. What is under test is the
|
||||
// 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 el = {
|
||||
textContent: text,
|
||||
title: "",
|
||||
// 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; },
|
||||
get clientWidth() { return 10; },
|
||||
get scrollHeight() { return 10; },
|
||||
get clientHeight() { return 10; },
|
||||
querySelector: () => el,
|
||||
};
|
||||
return el;
|
||||
};
|
||||
|
||||
const layer = (labels) => ({
|
||||
children: labels,
|
||||
childElementCount: labels.length,
|
||||
});
|
||||
|
||||
const seatsNamed = (...names) => {
|
||||
Render.state = { seats: names.map(name => ({ name })) };
|
||||
};
|
||||
|
||||
test.afterEach(() => { Render.state = null; });
|
||||
|
||||
test("trailing words go until it fits", () => {
|
||||
const el = label("Christopher John Robinson", text => text === "Christopher John");
|
||||
seatsNamed("Christopher John Robinson");
|
||||
Render.fitSeatNames(layer([el]));
|
||||
assert.equal(el.textContent, "Christopher John");
|
||||
});
|
||||
|
||||
test("and it will go all the way to the first word", () => {
|
||||
const el = label("Christopher John Robinson", text => text === "Christopher");
|
||||
seatsNamed("Christopher John Robinson");
|
||||
Render.fitSeatNames(layer([el]));
|
||||
assert.equal(el.textContent, "Christopher");
|
||||
});
|
||||
|
||||
test("a name never ends on a word a name cannot end on", () => {
|
||||
// Dropping "work" off "Mike from work" leaves "Mike from", which is not a
|
||||
// shorter name — it is a name that looks cut off, and it reads worse than the
|
||||
// ellipsis this was meant to replace. He is Mike.
|
||||
const el = label("Mike from work", () => false);
|
||||
seatsNamed("Mike from work");
|
||||
Render.fitSeatNames(layer([el]));
|
||||
assert.equal(el.textContent, "Mike");
|
||||
});
|
||||
|
||||
test("and it takes a run of them in one go", () => {
|
||||
const el = label("Al with the hat", () => false);
|
||||
seatsNamed("Al with the hat");
|
||||
Render.fitSeatNames(layer([el]));
|
||||
assert.equal(el.textContent, "Al");
|
||||
});
|
||||
|
||||
test("but a joining word inside a name is nobody's business", () => {
|
||||
// The list is closed and small on purpose: it is not trying to understand names,
|
||||
// only the handful of English joining words people label each other with at a
|
||||
// home game. Anything it doesn't know stays, which is the safe way to be wrong —
|
||||
// a name that keeps a word it could have dropped is merely long.
|
||||
const el = label("Katarzyna Nowak Kowalska", text => text === "Katarzyna Nowak");
|
||||
seatsNamed("Katarzyna Nowak Kowalska");
|
||||
Render.fitSeatNames(layer([el]));
|
||||
assert.equal(el.textContent, "Katarzyna Nowak");
|
||||
});
|
||||
|
||||
test("dropping never goes below the first word", () => {
|
||||
// Every word after the first being a joining word is not a real name, but it is
|
||||
// typeable, and it must not come out empty.
|
||||
assert.equal(Render.dropAWord(["Mike", "from", "of", "the"], 4), 1);
|
||||
assert.equal(Render.dropAWord(["from", "the"], 2), 1);
|
||||
assert.equal(Render.dropAWord(["Mike"], 1), 1, "one word has nothing to drop");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
test("a name that fits is not touched", () => {
|
||||
const el = label("Mike from work", () => true);
|
||||
seatsNamed("Mike from work");
|
||||
Render.fitSeatNames(layer([el]));
|
||||
assert.equal(el.textContent, "Mike from work");
|
||||
});
|
||||
|
||||
test("the whole name is on the tooltip whether it was shortened or not", () => {
|
||||
// A label clamped to two lines was already hiding some of it before any of this.
|
||||
const shortened = label("Mike from work", text => text === "Mike");
|
||||
const untouched = label("Bob", () => true);
|
||||
seatsNamed("Mike from work", "Bob");
|
||||
Render.fitSeatNames(layer([shortened, untouched]));
|
||||
assert.equal(shortened.title, "Mike from work");
|
||||
assert.equal(untouched.title, "Bob");
|
||||
});
|
||||
|
||||
test("every seat is written to before any of them is measured", () => {
|
||||
// Three passes over all the seats, not 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 cost
|
||||
// nine layouts, and this runs on every render.
|
||||
const reads = [];
|
||||
const watched = (text, fits) => {
|
||||
const el = label(text, fits);
|
||||
Object.defineProperty(el, "scrollWidth", {
|
||||
get() { reads.push(this.textContent); return fits(this.textContent) ? 10 : 100; },
|
||||
});
|
||||
return el;
|
||||
};
|
||||
const a = watched("Christopher John Robinson", t => t === "Christopher");
|
||||
const b = watched("Katarzyna Nowak Kowalska", t => t === "Katarzyna");
|
||||
seatsNamed("Christopher John Robinson", "Katarzyna Nowak Kowalska");
|
||||
Render.fitSeatNames(layer([a, b]));
|
||||
|
||||
// Both names are read at each length before either is written down again.
|
||||
assert.deepEqual(reads.slice(0, 2),
|
||||
["Christopher John Robinson", "Katarzyna Nowak Kowalska"]);
|
||||
assert.deepEqual(reads.slice(2, 4), ["Christopher John", "Katarzyna Nowak"]);
|
||||
assert.equal(a.textContent, "Christopher");
|
||||
assert.equal(b.textContent, "Katarzyna");
|
||||
});
|
||||
|
||||
test("an empty chair has no name to shorten", () => {
|
||||
const el = label("", () => false);
|
||||
seatsNamed("");
|
||||
assert.doesNotThrow(() => Render.fitSeatNames(layer([el])));
|
||||
});
|
||||
|
||||
test("and with no layer there is nothing to do", () => {
|
||||
assert.doesNotThrow(() => Render.fitSeatNames(null));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("pulling the ring back onto the table", () => {
|
||||
// seatRingRadii works out the widest ring whose outermost puck still finishes inside
|
||||
// the rail, and it is right about that — but it is right about a puck it measured
|
||||
// before the ring moved, before the width search settled, and before a name was
|
||||
// shortened or wrapped to a second line. Each of those changes a puck's height by up
|
||||
// to 15px after the arithmetic is done.
|
||||
//
|
||||
// Measured on the running page, four of the thirteen viewports finished with the top
|
||||
// seat 5-6px past the rail. Not a wrong formula: a stale input to a right one. So
|
||||
// the ring is settled the way the audit judges it, by looking at where the pucks
|
||||
// actually landed.
|
||||
//
|
||||
// The felt is 400x400 at (100, 100) throughout. Seats are placed by writing a
|
||||
// percentage into style.left/top, which this reads back to work out where the puck
|
||||
// would be — the same relationship the browser has, without a browser.
|
||||
const felt = { left: 100, top: 100, right: 500, bottom: 500, width: 400, height: 400 };
|
||||
|
||||
const seat = (puckW, puckH, box) => ({
|
||||
style: { left: "", top: "" },
|
||||
classList: { toggle() {} },
|
||||
getBoundingClientRect() {
|
||||
// Whatever percentage was last written, resolved against the ring box.
|
||||
const cx = box.left + (parseFloat(this.style.left) / 100) * box.width;
|
||||
const cy = box.top + (parseFloat(this.style.top) / 100) * box.height;
|
||||
return {
|
||||
left: cx - puckW / 2, right: cx + puckW / 2,
|
||||
top: cy - puckH / 2, bottom: cy + puckH / 2,
|
||||
width: puckW, height: puckH,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// The ring box the percentages resolve against, deliberately the same as the felt so
|
||||
// the arithmetic in the test stays readable.
|
||||
const ringBox = { ...felt };
|
||||
|
||||
const setUp = (count, puckW, puckH) => {
|
||||
const seats = Array.from({ length: count }, () => seat(puckW, puckH, ringBox));
|
||||
Render.state = { seats: seats.map(() => ({ name: "" })), seatCount: count };
|
||||
global.document = { getElementById: id => (id === "pokerTable"
|
||||
? { getBoundingClientRect: () => felt }
|
||||
: { getBoundingClientRect: () => ringBox }) };
|
||||
// ringBox() reads #seatsLayer; the fake above hands the same box back for it.
|
||||
return { children: seats, childElementCount: seats.length };
|
||||
};
|
||||
|
||||
const place = (layer, radii) => {
|
||||
for (let i = 0; i < layer.childElementCount; i++) {
|
||||
Render.placeSeat(layer.children[i], i, 1, radii);
|
||||
}
|
||||
};
|
||||
|
||||
const worstOverhang = layer => {
|
||||
let over = 0;
|
||||
for (let i = 0; i < layer.childElementCount; i++) {
|
||||
const b = layer.children[i].getBoundingClientRect();
|
||||
over = Math.max(over, felt.left - b.left, b.right - felt.right,
|
||||
felt.top - b.top, b.bottom - felt.bottom);
|
||||
}
|
||||
return over;
|
||||
};
|
||||
|
||||
test.afterEach(() => { Render.state = null; delete global.document; });
|
||||
|
||||
test("a ring already inside the rail is left exactly where it is", () => {
|
||||
const layer = setUp(6, 60, 50);
|
||||
const radii = { rx: 30, ry: 30 };
|
||||
place(layer, radii);
|
||||
assert.deepEqual(Render.settleSeatsOnTable(layer, 1, radii), radii);
|
||||
});
|
||||
|
||||
test("a puck past the rail brings the whole ring in", () => {
|
||||
// 44% of 400 puts a seat's centre 176px from the middle; half a 60px puck is
|
||||
// another 30, against a felt whose half-height is 200. Six pixels over, at the
|
||||
// top and the bottom and both sides — which is the size the running page was
|
||||
// actually wrong by.
|
||||
const layer = setUp(4, 60, 60);
|
||||
const radii = { rx: 44, ry: 44 };
|
||||
place(layer, radii);
|
||||
assert.equal(Math.round(worstOverhang(layer)), 6, "the fault is there to fix");
|
||||
|
||||
const settled = Render.settleSeatsOnTable(layer, 1, radii);
|
||||
assert.ok(settled.ry < radii.ry, "the ring came in");
|
||||
assert.ok(worstOverhang(layer) <= Render.SEAT_SETTLE_SLACK_PX,
|
||||
`still ${worstOverhang(layer).toFixed(1)}px over`);
|
||||
});
|
||||
|
||||
test("it comes in by what it was over, not by a step it guessed", () => {
|
||||
const layer = setUp(4, 60, 60);
|
||||
const radii = { rx: 44, ry: 44 };
|
||||
place(layer, radii);
|
||||
const over = worstOverhang(layer);
|
||||
const settled = Render.settleSeatsOnTable(layer, 1, radii);
|
||||
// Six pixels of a 400px felt is 1.5% of the ring, so 44 becomes 42.5 — the
|
||||
// measurement, not a step size that would have to be tuned per screen.
|
||||
assert.equal(settled.ry.toFixed(2), (44 - (over / felt.height) * 100).toFixed(2));
|
||||
assert.equal(settled.ry, 42.5);
|
||||
});
|
||||
|
||||
test("and it stops at the floor rather than collapsing into the pot", () => {
|
||||
// A puck taller than the felt has an overhang no ring can fix. Pulling in
|
||||
// forever would put every seat on top of the pot badge, which is a worse table
|
||||
// than one with a puck touching the rail.
|
||||
const layer = setUp(4, 60, 600);
|
||||
const radii = { rx: 40, ry: 40 };
|
||||
place(layer, radii);
|
||||
const settled = Render.settleSeatsOnTable(layer, 1, radii);
|
||||
assert.equal(settled.ry, Render.RING_MIN_R);
|
||||
assert.ok(settled.ry > 0);
|
||||
});
|
||||
|
||||
test("a table with nothing laid out yet is handed back untouched", () => {
|
||||
Render.state = { seats: [], seatCount: 0 };
|
||||
global.document = { getElementById: () => null };
|
||||
const radii = { rx: 44, ry: 42 };
|
||||
assert.deepEqual(Render.settleSeatsOnTable({ children: [], childElementCount: 0 }, 1, radii), radii);
|
||||
});
|
||||
|
||||
test("it is bounded — this runs after every tap a dealer makes", () => {
|
||||
assert.ok(Render.SEAT_SETTLE_PASSES <= 2,
|
||||
"each pass is a forced layout of the whole felt");
|
||||
assert.ok(Render.SEAT_NAME_PASSES <= 3);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user