The site
Testing and checks
How the site checks itself before a change goes live, from quick rule tests to a real browser playing every game, and which check to run after which change.
On this page
Before a change reaches players, the site tests itself. Quick tests check the rules and the server. Slower ones open a real browser, measure every screen and play the games the way a person would.
There are three layers, cheapest first:
- The Django suite (
uv run python manage.py test tests): the server, every table game’s rules and bots, referees, dailies, ratings, accounts and content banks. It needs Python, and Node for the tests that run a referee. - Node checks (
npm run check:<thing>): puzzle engines proven by the thousand, the kits’ logic on a pretend page, and the token contract over every stylesheet. No browser, no server. - Browser checks: a headless Chrome (and Safari’s engine, for fine details) opens the running site at many window sizes, measures every screen and plays games with real clicks, drags and keys.
npm run check:guide also holds this guide to the code (How this guide stays true). Every check is a script in package.json’s scripts, not a hosted service.
What to run when#
| You changed | Run | Needs |
|---|---|---|
| Anything | uv run python manage.py test tests; npm run check:guide |
uv, Node |
| Anything, before a pull request | npm run check:pr (below) |
uv, Node |
| The Register’s engine | check:register (and variety:register for balance) |
Node |
| Hashi’s engine | check:hashi; maybe bump ENGINE_VERSION (below) |
Node |
| Solitaire’s engine | check:solitaire; bump ENGINE_VERSION if any deal or legal move changes |
Node |
A kit puzzle’s engine, the kit’s shared engine, Griddles’ pictures.txt, Low Poly’s pictures |
check:puzzles; maybe bump that puzzle’s ENGINE_VERSION (below) |
Node |
| The chess bundle or its engine | check:chess; check:chess:play |
Node; Chrome and the site for play |
| The room kit, the card room, the house kit | check:room or check:house, and check:tokens |
Node |
Talk at a table (room/kit/talk.js, tables/talk.py) |
tests.test_talk, then check:talk:play |
uv; Chrome and the site for play |
| Any look (a stylesheet, tokens, fonts) | check:tokens, then check:layout and check:details |
Node; Chrome and the site for the last two |
| A page’s layout or type | check:layout |
Chrome, the site at UO_URL |
| Anything you see | check:details and check:details:webkit; fix with the house’s primitives, or add to ALLOW with why |
Chrome, WebKit, the site |
The details check itself (inspect.js, ink.mjs) |
check:details:self in both engines, then check:details |
Chrome, WebKit |
| A new screen or game | add it to SCREENS in tools/house/layout.mjs, then both measuring checks |
Chrome, the site |
| A game’s box art or tagline, or a new game | UO_URL=… node tools/house/cards.mjs, then commit static/img/og/ (its preview card, ADR 0048); tests/test_seo.py fails while a game has none |
Chrome, the site |
| A table game’s rules | its tests/test_<slug>.py, including its TableGameContract case |
uv |
| Last One Standing’s boards or pictures | rebuild with tools/last-one-standing/build.mjs, look at the board’s contact sheet (tools/last-one-standing/sheet.mjs), then its tests and check:last-one-standing |
Node, Chrome for the sheet, uv |
| Fictionary’s bank, pronunciations or spelling list | its word, verification and pronunciation tests; check:fictionary; check:fictionary:play after table changes |
uv, Node; Chrome and the site for play |
| Chess, Galaxies, Griddles, Low Poly, Bowl, Fictionary, Alphabet Roulette or Quiz Whiz interface | that game’s *:play (Galaxies’ in WebKit too) |
Chrome, the site |
Quiz Whiz’s question library (tables/quiz_whiz/library/) |
check:quiz-whiz:library |
Node |
| Chess, Galaxies, Griddles, Low Poly, Bowl, Fictionary, Alphabet Roulette or Vibe Check interface | that game’s *:play (Galaxies’ in WebKit too) |
Chrome, the site |
| The Dailies page, a result sheet’s “next daily” | check:dailies:play |
Chrome, the site |
| Feedback, its screenshot, or a solo game’s board under a sheet | check:feedback:play (and look at its SHOTS) |
Chrome, the site |
| Covered code of any chapter | read its diff, update the chapter, npm run guide:stamp <chapter> |
uv |
For Hashi and the kit puzzles, bump ENGINE_VERSION if a code would now make a different puzzle. Keep the old version in VERSIONS if it can still be made.
Two tools measure rather than check, run by hand when tuning bots: tools/ratings/anchors.py plays each table game’s bot levels against each other to set their fixed ratings (Ratings), and tools/get_in_line/strength.py does the same for Get In Line.
What the checks need#
| Need | For | Notes |
|---|---|---|
| uv | the Django suite, check:guide, finish_table.py |
uv sync once (AGENTS.md, “Working”) |
| Node 24 | every npm run check, and the suite’s referee tests |
"node": "24.x" in package.json. Nothing to npm install: the checks use only Node’s standard library, including its built-in WebSocket. |
| Chrome | check:layout, check:details, every *:play |
Found by tools/house/chrome.mjs; CHROME=/path/to/chrome names another. |
| Playwright’s WebKit | check:details:webkit, check:details:self |
Set up once in tools/webkit/ (its README says how). Its own package.json keeps Playwright out of the deploy. |
| The site running | the same | uv run uvicorn obstacles.asgi:application --port <port>, with UO_BOT_NAMES=ordered so bots get the same names every run. tools/house/site.mjs finds it at UO_URL, else http://localhost:8000. If nothing answers, requireSite() stops with “No site at …”, so a check never plays someone else’s server by accident. |
Run browser checks against a development server, never production: check:details plays tables to their end in the real database. Each worktree has its own SQLite file and port.
The Django suite#
uv run python manage.py test tests # everything
uv run python manage.py test tests --parallel 4 # everything, four at a time (check:pr's way)
uv run python manage.py test tests.test_chess # one file
uv run python manage.py test tests.test_euchre.EuchreContract # one class
Its settings#
manage.py switches to the test settings before Django starts:
manage.pymain · lines 7–21def main():
"""Run administrative tasks."""
# Tests get their own settings (no background referee or card listener),
# so a plain `manage.py test` can't race the test database.
test = sys.argv[1:2] == ['test']
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'obstacles.settings_test' if test else 'obstacles.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
obstacles/settings_test.py is the usual settings with three changes:
REFEREE_WARMis off. A background thread building Register cases would race the test database.CARDS_LISTENis off. Its open PostgresLISTENconnection would stop the test database being dropped.- Passwords use a fast MD5 hasher.
The database is whatever DATABASE_URL names, or SQLite when it’s unset.
How the tests are laid out#
All tests live in tests/, one test_<area>.py per game or system (test_euchre.py, test_ratings.py, …), well over a thousand in all. A test’s name is a sentence about the system (AGENTS.md, “Style”), such as test_a_case_is_scored_once_per_player. Pure code uses SimpleTestCase (no database), anything that saves uses TestCase, and the few tests of live table streams use TransactionTestCase.
Tests that run a referee under Node are marked @skipUnless(NODE, "Node isn't installed"), so the suite still runs without Node and says what it skipped.
Beside the tests sit two helpers and one tiny game:
tests/table_contract.py: the contract every table game meets (below).tests/lineups.py: runs a test on either side of the one-daily switch (below).tests/tablegames/odd_one_out.py: Odd One Out, the smallest possible table game. Everyone secretly picks heads or tails, and the smaller side scores. It’s never on the site. The table service’s tests use it, and new multiplayer games follow it as the worked example.
The table game contract#
Every multiplayer game’s rules are a module that meets tables/engine.py (the table service). Some promises hold for all of them. Above all, a seat never sees another seat’s secrets, and a bot never decides from them. tests/table_contract.py states these once, as a mixin. A game’s test file adds one small class:
class EuchreContract(TableGameContract, SimpleTestCase):
game = game
def scramble(self, state, keep, rng):
...
and gets eight tests:
| Test | What it proves |
|---|---|
test_bots_of_every_level_play_a_whole_game_at_the_fewest_and_most_seats |
A bots-only game ends at its smallest and largest table, and its state is plain JSON (no Infinity, which Postgres refuses). |
test_the_same_seed_plays_the_same_game |
The same seed plays the same game, byte for byte. |
test_every_view_is_plain_json |
Every seat’s view, and a spectator’s, can be sent as JSON at every moment. |
test_a_seat_sees_nothing_that_depends_on_another_seats_secrets |
No leaks (below). |
test_a_bot_decides_only_from_what_its_seat_can_see |
A bot of every level makes the same move whatever the secrets it shouldn’t know. |
test_a_spectator_sees_no_private_lines |
Anything with to reaches only those seats; a spectator (seat=None) sees no private line. |
test_the_result_has_a_score_and_place_for_every_seat |
A game that keeps history scores and places every seat, every round. |
test_check_finds_every_required_part |
engine.check names any part of the contract a game leaves out. |
play runs the games with bots of every level in every seat, simulating time. Seat 0 moves a couple of seconds after everyone else on purpose. That creates moments when every other seat has made a secret move and one hasn’t, which is where a leak would show.
tests/table_contract.py TableGameContract.def play(self, n, seed="contract", steps=None, look=None):
"""Bots play a game of n seats (time simulated). Seat 0 is slow: it
moves as a bot would, but a couple of seconds after the others, so
there are moments when everyone else has made a secret move and it
hasn't. `look(state)` is called every `every` steps; play stops
after `steps` if given."""
g = self.game
rng = random.Random(seed)
state = g.new(n, seed, 0.0, g.options({}))
bots, now, slow = {s: self.levels[s % len(self.levels)] for s in range(1, n)}, 0.0, None
for step in range(steps or 100_000):
if look and step % self.every == 0:
look(state)
nxt = advance(g, state, bots, now, rng, limit=5000)
if g.over(state):
break
mine = next((d for d in g.deciding(state) if d[0] == 0), None)
if mine is None:
slow = None
elif slow is None or slow[0] != mine[2]:
slow = (mine[2], now + 2.0)
elif now >= slow[1]:
try:
g.act(state, 0, g.bot_action(state, 0, self.levels[0], rng), now)
except Illegal:
g.act(state, 0, g.fallback(state, 0, mine[1]), now)
slow = None
continue
times = [t for t in (nxt, slow and slow[1]) if t is not None]
self.assertTrue(times, "the game stalled")
now = max(now + 0.001, min(times))
else:
if not steps:
self.fail("the game never ended")
return state
The leak test matters most. Every few steps (every), it copies the state and calls the game’s own scramble to change everything seat keep has no right to know. The seat’s view of both states must match. If anything the seat sees depends on a secret, the views differ and the test fails, naming the seat.
tests/table_contract.py TableGameContract.def test_a_seat_sees_nothing_that_depends_on_another_seats_secrets(self):
rng = random.Random(7)
def look(state):
for seat in range(state["n"]):
other = copy.deepcopy(state)
self.scramble(other, seat, rng)
self.assertEqual(self.game.view(other, seat), self.game.view(state, seat), f"seat {seat} saw a secret")
for n in self.seats():
self.play(n, f"leak-{n}", steps=self.steps, look=look)
So scramble is where a game declares its secrets. Odd One Out’s are other seats’ sealed picks, the private lines in its log, and the private beats of its reveal:
tests/test_table_service.py OddOneOutContract.def scramble(self, state, keep, rng):
c = state.get("commit")
if c:
for s in list(c["picks"]):
if s != str(keep):
c["picks"][s] = rng.choice(odd_one_out.SIDES)
for e in state["events"]:
if isinstance(e.get("to"), list) and keep not in e["to"]:
e["side"] = rng.choice(odd_one_out.SIDES)
for beat in (state.get("reveal") or {}).get("beats", []):
if isinstance(beat.get("to"), list) and keep not in beat["to"]:
beat["say"] = "Something else entirely."
A scramble that forgets a secret lets the test pass while the game leaks. That’s why AGENTS.md asks for one “that changes every secret” (“Adding a multiplayer game”, step 4), and why a reviewer reads it beside the rules. The bot test uses the same scramble: a bot given the scrambled state and the real one, with the same random stream, must choose the same move.
Either side of the one-daily switch#
From settings.ONE_DAILY_FROM (ADR 0034), every game has one daily a day instead of three. Tests about a lineup of three (Easy, Medium and Hard; three sizes; three bands) must keep passing after that date, and tests of the one daily must pass before it. tests/lineups.py moves the switch instead of the clock:
tests/lineups.py three_dailies · lines 14–15# Every day is before the switch: three dailies a game.
three_dailies = override_settings(ONE_DAILY_FROM=LATER, WEEK_RAMP_FROM=LATER)
tests/lineups.py one_daily · lines 16–17# Every day is after it: one daily a game, at its normal level.
one_daily = override_settings(ONE_DAILY_FROM=EARLIER, WEEK_RAMP_FROM=LATER)
Both are override_settings, so they decorate a class or a test (@three_dailies) and put every day in it before (or after) the switch, whatever today is. Since ADR 0041 the one daily follows the week from settings.WEEK_RAMP_FROM, so both also keep every day before that. A test of the week itself uses the third:
tests/lineups.py week_dailies · lines 18–19# Every day's one daily follows the week: gentlest Monday, hardest Sunday.
week_dailies = override_settings(ONE_DAILY_FROM=EARLIER, WEEK_RAMP_FROM=EARLIER)
What else the suite holds#
Besides each game’s rules, the suite covers content (the Fictionary bank against its hand review, the chess bundle’s shards, every Low Poly and Griddles picture’s license and daily order: Content pipelines), every referee accepting a real solve and refusing tampered ones (Referees and anti-cheat), dailies, ratings, the shelf, accounts and the table service.
Node checks#
These need only Node. Each is npm run <name>.
| Script | Runs | What it proves |
|---|---|---|
check:register |
tools/register/check.mjs |
Register cases at every difficulty and in both modes (cold case and inquiry) pass every validation check and come out the same when remade from their code. --stats adds balance numbers; variety:register (tools/register/variety.mjs) measures how alike cases are. |
check:hashi |
tools/hashi/check.mjs, tools/hashi/chart.test.mjs, tools/hashi/store.test.mjs |
The same code makes the same puzzle, above its depth floor, and its answer solves it. Up to 10×10, a brute-force search confirms exactly one answer. Fixed charts haven’t moved for any version still made. Then the board and the store (undo cap, full storage, versions). |
check:solitaire |
tools/solitaire/check.mjs, tools/solitaire/fit.mjs, tools/solitaire/rate.test.mjs, tools/solitaire/leave.test.mjs |
Deals are fixed by their codes. Random games make only legal moves, Undo restores exactly, and a second reading of the rules agrees with the engine. The solver and the replay referee agree. Cards fit at every window size. Then the win-rate record, and losses held until they can be posted. |
check:puzzles |
tools/puzzles/check.mjs, tools/griddles/build.mjs --check, tools/chicago-traffic/seeds.mjs --check, the kit’s tests |
For every pencil-puzzle engine: the same code makes the same puzzle; it rates as asked with no trial and error; a plain search finds exactly one answer, the engine’s; check() and the referee accept it and refuse every tampered copy (tools/puzzles/tamper.mjs); an engine with a depth floor (DEPTH) never goes under it; fixed puzzles haven’t moved without an ENGINE_VERSION bump. A judged engine like Chicago Traffic’s has no single answer, so a search of the checks’ own (tools/puzzles/judged.mjs) re-proves each puzzle’s fewest moves instead, and its seeds. Every Low Poly and Griddles picture is re-proved. Then the kit’s own logic, keyboard play, and the uniqueness gate in generate (tools/puzzles/unique.test.mjs). |
check:chess |
tools/chess/check.mjs, tools/chess/hub.test.mjs |
Every puzzle in the bundle is in its shard, rated in its band, legal, and solved by its own line but not by a wrong, missing or extra move. Then castling, en passant, promotion, a mate off the line, and misses held until they can be posted. |
check:room |
tools/room/*.test.mjs |
The room kit’s pure parts (among them a table’s Share result words and the seat bar’s rows, the host’s only), the card hand’s sorts, Michigan Rummy’s table logic, and reveal cues (each beat’s change happens exactly once, even under reduced motion, a late arrival or Skip). Then Your move on the fake page (tools/room/cue.test.mjs): when it rings, when a prompt plays, and Show what to do. Catching up on your turn (tools/room/cues.test.mjs): older moves end in order, the last still plays, a held sweep is never cut; a queue too far behind or too long keeps its last move too; a hidden tab ends every move; the moves caught up at once show only the newest banner. The seat bar’s host rows settling before they show, an emote heard in a hidden tab not floating, and each game’s intro switch its own, the rummies keeping the shared one’s choice (tools/room/gameover.test.mjs). A table’s game over (tools/room/gameover.test.mjs): a win, else a rating high, never a hand over, a loss or a spectator, marked once it has played, a second game at the same table its own; and Escape in Chat, a drawer or the log’s tab, focus back to its button. And Auction House’s whole table on the fake page (tools/room/auction-house.test.mjs): every moment and every kind of news, for each seat and a spectator. And every card table style’s indicators, measured from its tokens (tools/room/styles.test.mjs, the card room): a style whose turn ring, picked card or cues fade into its felt fails. |
check:house |
tools/house/*.test.mjs |
Every design rule held by something, and every check citing only real rules (Rules and checks). The house kit: modules load under Node without a page; sheets and focus; h(); the Home Screen prompt; Your move; the site’s dailies and stats (streaks, sweeps, spreads, the Full Sweep’s share, a chess solution shown making no sweep, a streak’s milestone said with the sweep); celebrations (behind the result sheet, the room’s for a game over, a still picture each under reduced motion, put away by a press, a game over in a hidden tab played when it’s seen, a best never played twice on a sheet, on a phone the sheet waiting below or a later one over it a moment) and announcements (one at a time, read once, a pile-up dropping the oldest, replace, Escape over a sheet taking the announcement first); banners caught up at once showing only the newest; a toast under the top bar; key caps that hide with their button (no stylesheet in any game gives one visibility: visible); the pixel pass finding magenta ink in a PNG (tools/house/ink.test.mjs); Share result’s words (tools/house/brag.test.mjs: an emoji for every game on the shelf, the rows and the standings); and Feedback’s logic, including limits that match the server’s (tools/house/feedback.test.mjs). |
check:tokens |
tools/tokens/check.mjs |
The token contract (below). |
check:fictionary |
tools/fictionary/spell.test.mjs |
The spelling check against the real word list. |
check:last-one-standing |
tools/last-one-standing/build.mjs --check, tools/last-one-standing/balance.mjs |
Built boards match their sources, every board keeps its balance, and the question matcher finds what players type (tools/last-one-standing/match.test.mjs). |
check:millbury-mansion |
tools/millbury-mansion/deduce.test.mjs |
Millbury Mansion’s notebook: what it inks in each way of keeping it (Facts only, Full assist, Blank), its reasons, the hint, and an accusation’s prefill. |
check:quiz-whiz:library |
tools/quiz-whiz/library.mjs |
Every category file of Quiz Whiz’s library parses; ids are unique across the library; every field is there; levels are easy, medium or hard; each answer is one of its own accept; questions and facts stay within 25 words; checked names its date and source; no question repeats another (the same answer in mostly the same words, in any category). It prints each category’s count and level mix against the 35/45/20 target; --answers lists the answers two questions share, to review. |
check:release-video |
tools/release-video/pace.test.mjs |
The release videos’ pacing arithmetic (tools/release-video/lib/pace.mjs): reading time for a headline and its captions, in turn or from their own times; a take’s action at its speed with its hold; the fewest bars that cover a need; the rushed, dragging and fits verdicts; freezedetect’s log read. |
The engine checks take a count and a seed (node tools/hashi/check.mjs 200 --size=10 --seed=WORD). Some use a fresh seed each run by default, so repeated runs cover more ground. Solitaire’s uses a fixed seed so its win rates don’t wobble.
The fake page#
Tests that click and type need a page but not a browser. tools/house/fakedom.mjs is just enough of one for the kit’s code to run under node --test: elements, events with capture and bubbling, focus, inert, simple selectors, matchMedia and localStorage. That keeps these tests fast. Layout, fonts and real touch are the browser checks’ job.
Where browsers differ, it takes the strictest. createElement refuses a tag with a # or a space in it, as Safari does and Chrome doesn’t, because h('h2#id') once crashed a whole page on an iPhone.
check:tokens#
Every game has its own look, but each look declares the same small set of CSS custom properties, called tokens (ADR 0012, ADR 0015, ADR 0025, ADR 0026). The shared kits draw only from them. tools/tokens/check.mjs parses every stylesheet (no browser) and fails if:
- any root (the hub, The Register, the card room’s table styles, the pencil-puzzle kit, each room game’s stylesheet) lacks the house tokens:
tools/tokens/check.mjs HOUSE · lines 42–43export const HOUSE = ['--t-display', '--t-heading', '--t-body', '--t-label', '--t-note', '--s1', '--s2', '--s3', '--s4', '--s5',
'--pick-tint', '--pick-ring', '--pick-weight'];
- a room game or card room style lacks the room’s tokens (
ROOM), or the kit, read on the paper, lacks its tokens (KIT); - the paper (
games/static/games/house/paper.css, ADR 0036) lacks one of its tokens, or a root printed on it (site.css,guide.css,puzzles.css) sets one again. The palette is declared once so it can’t drift. The toy box (games/static/games/brainteasers/toybox.css) follows the same rules, and a brainteaser’s accent is checked on it (ADR 0042):
tools/tokens/check.mjs PAPER · lines 138–140// ── the paper, declared once ─────────────────────────────────────────────
export const PAPER = ['--paper', '--paper-2', '--paper-edge', '--desk', '--ink', '--soft', '--line', '--line-bold', '--good', '--bad',
'--card-shadow', '--card-shadow-up', '--paper-grain', '--display', '--ui'];
- soft ink reads under 4.5:1 on a sheet, any felt color, the paper, its cards or its desk, or a puzzle’s accent ink under 4.5:1 on its accent (WCAG’s formula on the hex values);
room.cssorhouse.css, the shared structure, sets a color of its own (anything but black or white shadows and scrims), or the Stats tab’sstats.css, the card room’scards.cssor the puzzle kit’skit.csssets one but black, white or channels from a token (rgba(var(--shade), .25), the paper’s warm shadow, whichpuzzles.cssdeclares);- a game on the house kit doesn’t give
house.cssthe--house-*tokens it reads, or the room’s names for them (tools/house/tokens.mjs); - any stylesheet sets a type role or space to anything but the house’s one size, or sets a layout token at all (only
house.cssdoes), or a house-kit game has a page that doesn’t loadhouse.css:
tools/tokens/check.mjs SCALE · lines 246–252// ── the house's layout and type scale (docs/guide/design/tokens.md) ──
// The type roles and spaces every root declares have one size each (the
// rules type-roles and one-skeleton). The layout tokens are house.css's alone: every game on the
// kit inherits them (its page loads house.css), and no stylesheet anywhere
// sets one to another value, except the few that are a game's own.
export const SCALE = { '--t-heading': '20px', '--t-body': '16px', '--t-label': '16px', '--t-note': '14px',
'--s1': '4px', '--s2': '8px', '--s3': '16px', '--s4': '24px', '--s5': '40px' };
Run it after touching any look, the room kit or the house kit.
Browser checks#
The launcher#
Every browser check, and the picture builders in Content pipelines, drives Chrome through tools/house/chrome.mjs: about 130 lines, no Puppeteer or Playwright, nothing to install. It finds a Chrome:
tools/house/chrome.mjs CANDIDATES · lines 12–17const CANDIDATES = [
process.env.CHROME,
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser',
].filter(Boolean);
It starts Chrome headless with a throwaway profile and --remote-debugging-port=0, reads the DevTools WebSocket address from Chrome’s startup line, and speaks the DevTools protocol over Node’s built-in WebSocket.
A page() is a tab with:
size(w, h): under 768px wide, or withtouch, it emulates a touch screen with five touch points;reducedMotion(),goto,until(fn);run(fn, …args): sends a function as source, runs it in the page, returns the result as JSON;tap(x, y)andclick(x, y): real touch and mouse events;- cropped
screenshots, andshot(clip), a PNG of part of the window for the pixel pass; - the page’s uncaught errors,
front(), andcdpfor anything else.
A page made with { isolated: true } gets its own browser context, so pages open at once don’t share localStorage, where a table seat lives.
check:layout#
tools/house/layout.mjs holds every game to the house’s layout and type scale (ADR 0026; Tokens and layout). It opens each screen in SCREENS at these windows:
tools/house/layout.mjs SIZES · line 29const SIZES = (opt('size')?.split(',') || ['1280x800', '1920x1080', '375x812', '320x640']).map(s => s.split('x').map(Number));
A screen is an entry like { id: 'euchre', family: 'cards', kind: 'play', path: '/games/euchre/', go: dealMeIn, ready: '.felt.euchre .dock' }. go runs in the page to get there (deal in against bots, open the archive), and ready is a selector that says it has arrived. The kind is home, tables, wait, play, archive, dailies, stats, account, or a moment of a game (Last One Standing’s ask and zoom). sheets: true also opens How to play and Options. A seed sets what the browser has stored before the page opens (SEEDS: nothing yet, some dailies solved and one begun, all solved, a month of history), so the Dailies page and Stats tabs are measured as a player finds them.
Some screens need setting up:
- Every table game’s menu is also measured with Your tables showing (
tables). The seed makes a game against bots and a table for friends over the table API, as the menu’s buttons would. It’s held to a home’s rules (FRONT: its arrow is “More games”). - Get In Line’s and Michigan Rails’ menus, Your tables and waiting rooms are on the felt, so they’re measured with the cards family (ADR 0036). Their tables stay with the board games.
- The account pages a guest sees are screens too.
- People-only Bowl, Alphabet Roulette and Vibe Check get three more people joined over the API.
- Michigan Rummy’s table is dealt in under a 24-letter name, the longest allowed, since its seats have the least room.
- The guide is a held family: its front page and the chapters with the longest snippet captions, which must wrap on a phone, not be cut off.
On each screen, measure reads computed styles and boxes: the gutters (how far the nearest words or control sit from each window edge), sideways scrolling, column width, the top bar and its way-up arrow, every type role’s size and weight, buttons, panels, docks and sheet padding. It prints them as tables, so one command shows a whole family side by side. With a game log pinned in a column beside the table (Last One Standing’s log screen, at 1440px and up), the top bar is held to the table’s column, the log’s column taking the gutter’s place.
Then check holds each held family to the house’s numbers, within a pixel (half a pixel for type):
tools/house/layout.mjs RULES · lines 40–61export const RULES = {
gutter: w => (phone(w) ? 16 : 24),
barHeight: 44,
type: {
title: { size: w => (phone(w) ? 32 : 44) },
barTitle: { size: w => (phone(w) ? 15 : 17) },
section: { size: () => 20 },
lead: { size: () => 16 },
column: { size: () => 16, name: 'body' },
note: { size: () => 14 },
label: { size: () => 16, weight: () => 600 },
kicker: { size: () => 12, weight: () => 700 },
tool: { size: () => 15, weight: () => 700 },
},
// The way up, top left of every bar (house/nav.js): a finger square, one
// per screen, at the bar's start.
back: { size: 44 },
button: { height: 44, padX: w => (phone(w) ? 12 : 16) },
tool: { height: w => (w < 768 ? 44 : 36) },
panelPad: 16,
sheet: { pad: w => (w <= 560 ? 16 : 24) },
};
It also holds every screen of a family, at one window size, to the others of its kind: the same bar, the same way up, the same column width and title position on a home. The families:
tools/house/layout.mjs FAMILIES · lines 216–224export const FAMILIES = {
cards: { held: true, about: 'the room kit, the card room, Contract Rummy, Michigan Rummy, Euchre, Bullshit, Sixty-Six and Solitaire, and Get In Line’s, Michigan Rails’ and Auction House’s foyers, on the felt (ADR 0036)' },
board: { held: true, about: 'board and party games on the room kit in looks of their own (Get In Line, Michigan Rails, Fictionary, Bowl, Alphabet Roulette, Quiz Whiz)' },
puzzles: { held: true, about: 'the pencil-puzzle kit (Star Battle, Fillomino, Yin-Yang, Galaxies, Aquarium, Griddles, Low Poly) and Hashi and Chess Puzzles, which wear its look' },
original: { held: true, about: 'The Register' },
hub: { held: false, about: 'the shelf and the Dailies page' },
pages: { held: false, about: 'a game’s Django-served pages in its look (its Stats tab)' },
guide: { held: true, about: 'the technical guide (/guide/, ADR 0035): its front page and a chapter' },
};
A family that isn’t held (the shelf and Dailies page, a game’s Stats tab) is printed but kept only to a floor: it loads, never scrolls sideways, keeps words at least 8px from each edge, has a title with its kicker (except the shelf), and leads with a button a finger tall. To move a family onto the scale, mark it held. A held screen may differ from the scale only where EXCEPTIONS names it, one place and one token each:
tools/house/layout.mjs EXCEPTIONS · lines 141–148// The named exceptions to RULES, each one place and one token (docs/guide/design/rules.md,
// "Exceptions"): a held family's screen may keep a
// role's size other than the scale's only as listed here, and is checked
// against that size instead.
export const EXCEPTIONS = {
// The Register's masthead, its home's one title: --t-masthead (84px; 56 under 860px, 48 under 720px).
masthead: { family: 'original', kind: 'home', role: 'title', size: w => (w <= 720 ? 48 : w <= 860 ? 56 : 84) },
};
--only euchre,solitaire (an id prefix or a family), --size 375x812 and --json out.json narrow a run or save its numbers.
check:details#
check:layout holds a page’s skeleton. tools/house/details.mjs holds what’s inside it (ADR 0033; Fine details): is a number centered in its badge, does anything overlap, are words cut off. It opens every screen of SCREENS, plus some of its own (below), at seven windows:
| Window |
|---|
| 1440x900 |
| 1280x800 |
| 1920x1080 |
| 390x844 |
| 375x812 |
| 320x568 |
| 844x390 |
tools/house/details.mjs, SIZESA window under 768px wide, or under 500px tall (a phone on its side), is a touch screen. On each screen the check waits for fonts, then 800 ms more before it closes any sheet that opened by itself: Solitaire’s How to play opens half a second after its home, and one that came up after the check had looked was measured as the page, its words taken for a segmented control’s. Then it waits for motion to settle. On a puzzle it presses five points on the board, like a player’s first moves. Then it scrolls a window at a time, up to six stops (inside an open sheet, if any), and runs tools/house/inspect.js at each.
A starting board shows few of a puzzle’s states. Aquarium’s ring round a number, for example, is invisible until its row is met. So the check adds the classes the game’s code uses for lit, met, wrong or chosen, one look at a time, and measures again. A selector that finds nothing fails the screen, so a renamed class can’t quietly stop the looking:
tools/house/details.mjs STATES · lines 165–184// ── states: the lit, met, wrong and chosen looks ───────────────────────
// A puzzle's starting board draws none of them (Aquarium's ring round a
// number is invisible until its row is met), so after the screen as it opens
// each of these puts the classes the game's own code puts on a sample of
// what's drawn, one after another, and the screen is measured again. A
// selector that finds nothing fails the screen: the check can't quietly stop
// looking when a class is renamed.
// { name, sel, classes }: element i of those matching `sel` (the first 12) gets classes[i % n].
export const STATES = {
aquarium: [{ name: 'met and wrong clues', sel: '.aq-clue', classes: ['met', 'bad'] }],
'star-battle': [{ name: 'full and over counters', sel: '.ctr', classes: ['full', 'over'] }],
galaxies: [{ name: 'lit and wrong dots', sel: '.play .dot', classes: ['lit', 'bad'] }],
griddles: [{ name: 'finished clues', sel: '.gd-num', classes: ['done'] }],
'low-poly': [{ name: 'dots with all their lines', sel: '.lp-dot', classes: ['full'] }],
'chicago-traffic': [{ name: 'picked and dragged vehicles', sel: '.veh', classes: ['picked', 'dragging'] }],
codebreaker: [{ name: 'pegs already in a Beginner row', sel: '.cb-peg', classes: ['used'] }],
michigan: [{ name: 'low card counts', sel: '.seat .count, .tile .count', classes: ['low'] }],
hashi: [{ name: 'finished, wrong and chosen islands', sel: '.isle', classes: ['done', 'over', 'sel'] }],
'last-one-standing': [{ name: 'ruled-out, guessable, beckoning and flipped cards', sel: '.tray .lcard', classes: ['out', 'pickable', 'house-beckon', 'down'] }],
};
Two engines, and the pixel pass#
npm run check:details:webkit runs the same check in Playwright’s WebKit, Safari’s engine, as an iPhone: the two phone sizes, three device pixels per CSS pixel, an iPhone’s user agent and touch (tools/webkit/browser.mjs, the same interface as chrome.mjs). The family plays on iPhones, and Safari lays out SVG text and fonts its own way. Its first run found a Stats tab that crashed only on Safari, an 18px title button, and a trump’s suit 2px low in its roundel. --static-from needs Chrome; every other option works in both.
The geometric checks trust each engine’s boxes, but engines disagree about SVG text baselines. Safari’s box for an Aquarium clue said it was centered while the number was painted high. So inspect.js also returns cands, every glyph set in a circle, ring, badge or box. The pixel pass, tools/house/ink.mjs, paints each one’s text pure magenta, photographs that part of the window, and measures where the magenta landed against the ring’s middle. That replaces the boxes’ guess. A glyph more than 1.5px off is a finding. It measures only what’s whole in the window, and only a few of each kind (ten clues in one look are one design). --no-pixels skips it.
A second pixel pass measures contrast as painted (contrastPass in the same file). inspect.js returns texts: each element’s own words, their lines’ boxes (a glyph or two by the middle of its ink), their computed color with its alpha times every opacity above it, and the ratio they need (4.5:1, or 3:1 at 24px or 18.66px bold). The pass hides every word at once, with a 2px mark of a new color each time in the window’s corner (a picture without it is a frame drawn before, a tab not in front, and is taken again) (-webkit-text-fill-color: transparent, so borders in currentColor and a text-shadow’s halo stay, and nothing moves), photographs the window (all of it, as shown: a clip offset by the scroll lands elsewhere on a phone’s emulated window), and lays each color over the pixels under its lines. Only lines whole in the window and 8px clear of its top and foot are measured (a page’s fixed stripe lies there over what scrolls under it), none over something that pulses for ever (a beckon), and none that moved between being measured and the picture (each is marked data-uo-text and looked up again). The ratio is the tenth percentile of those pixels, so words over a painting are as readable as their worst part; a glyph or two takes the median, since its box’s corners are past the small shape it’s on. Words on a disabled control are left out, and so is a mark’s initial in an SVG 32px or smaller (a seat’s letter: its box is mostly the shape’s edge).
The check checks itself: npm run check:details:self (tools/house/ink.selftest.mjs) draws glyphs in rings, some deliberately off, and both engines must find exactly the off ones. tools/house/rules.selftest.mjs does the same for the three newer kinds: it plants Auction House’s two old bugs as they were (two ways round the board sized by their words, one in the dock’s .main slot; the block’s column 76%×82% of the velvet’s circle, its caption reaching past it), words 4px from the next control, grey words over a picture’s light half and 10px type, each beside a right one that must be left alone. Neither needs a site.
Result sheets and staged moments#
A result sheet can’t be reached by clicking, so check:details gets there another way:
- A table game is played to its end with bots in every seat by
tools/house/finish_table.py, run under uv against the site’s database, and the page reopens on the result. The browser’s own seat is played “as if a bot covered it”, so the page opens as that seat. - Solitaire gives up its deal.
- A puzzle’s sheet needs a real solve, so it’s drawn by calling the house kit’s
result()as the game does.
tools/house/finish_table.py finish · lines 32–48def finish(code: str) -> int:
with transaction.atomic():
table = Table.objects.select_for_update().get(code=code)
rules = GAMES[table.game]
bots = {s.index: s.bot or "medium" for s in table.seats.all()}
state = table.state
state["_pace"] = 0
now = float(state.get("_now", 0)) or __import__("time").time()
rng = random.Random(7)
steps = 0
while not rules.over(state) and steps < 20000:
wake = advance(rules, state, bots, now, rng, limit=500)
now = max(now + 0.5, wake or now + 0.5)
steps += 1
service._advance(table, now)
service._bump(table)
return 0 if rules.over(state) else 1
A few mid-game moments (STAGED) work the same way: a game’s own tool sets the table to a moment and the page reopens on it. Today that’s Auction House (tools/auction-house/stage.py): choosing a direction after a roll, live auctions with and without bids, a dozen paintings to choose from, your own painting on the block, and the log open (pinned beside the table where there’s room). Talk at a table (TALK, ADR 0052) is two more: a table for friends with Chat on and a line said, in the waiting room with Chat’s drawer open, and dealt with Chat open as a tab of Euchre’s log.
What inspect.js finds#
tools/house/inspect.js is one plain function with no imports, sent to the page as source. It looks only at what the window shows (just the top sheet, if one is open). It skips toasts, banners, the splash and screen-reader-only text, and doesn’t judge a card face-down or tipped at an angle, nor a glyph that a drawer (the log’s, Chat’s) or the emote tray lies over: what’s painted there is the drawer’s, through its blur. It reports thirteen kinds of finding. alike fails like the rest. The last two (room, readable) are report-only (REPORT_ONLY): printed and written to --report, but they don’t fail the run until what’s left of the audit that introduced them (docs/audits/2026-10-30-alike-room-readable.md) is fixed. --strict makes them fail.
tools/house/details.mjs NAMES · lines 542–548// ── the findings, grouped ────────────────────────────────────────────────
const NAMES = {
center: 'Glyph off the middle of its box, ring or circle', icon: 'Icon off the middle of its button', overlap: 'Overlap', clip: 'Words cut off or ellipsized',
overflow: 'Words running out of their box', offscreen: 'Off the side of the window', align: 'Out of line in a row', tap: 'Under 44px to press on a touch screen',
taken: 'A press on it taken by something you can’t see', stray: 'A key cap showing for a hidden control',
alike: 'Alike things that don’t look alike', room: 'Words without room to breathe', readable: 'Words hard to read (contrast as painted, or under 12px)',
};
| Finding | How it’s measured |
|---|---|
center |
The ink of a badge’s, pill’s, button’s or key cap’s words (glyph bounds from canvas measureText, not the line box) against the middle of its padding box. Words are centered by their capitals, so descenders don’t count. Fails when off by more than 0.75px or 4% of the box’s smaller side, whichever is more. An SVG number is measured against its shape’s filled-area middle (a triangle’s is low). |
icon |
An icon’s drawing against the middle of its button, same tolerance. |
overlap |
Words, controls and small icons drawn over each other, by ink, found by hit-testing. An icon in its own button, or stacked cards, don’t count, nor something in a sheet over the page against what lies under it (a bottom sheet’s card over Last One Standing’s seat marks): the sheet is a layer, the page behind it inert. |
clip |
Words cut off by a box that hides overflow, or ellipsized. |
overflow |
Ink more than a pixel past the nearest box with a look of its own (a background, edge or shadow), with nothing clipping it. Something positioned on purpose is held only to its own box. Made for a one-line label wider than its card (ADR 0036). |
offscreen |
Something past the window’s side, sideways scrolling, or something below the window on a page that can’t scroll. |
align |
In a flex row: middles or baselines more than a pixel apart, alike buttons of different heights, uneven gaps among three or more alike things. |
tap |
On a touch screen, a pressable area under a finger’s 44px, found by hit-testing outward from the middle, so .house-reach or a pseudo-element’s padding counts. Links inside sentences are exempt, and a control under something else is left to taken. A control off the window is scrolled into view to be measured, and every box that scrolled (an open sheet’s own overflow: hidden box and its scrim too) is put back, so the pixel passes after it photograph the page as it was. |
taken |
Each control is pressed at its middle and four inset corners. It’s a finding when something else answers from outside its own boxes, since only an invisible hit area (a reach’s ::before) can do that. An inline box’s boxes include its line’s leading, words count their ink, and a wrapped link is pressed on its longest line. A link answering for another to the same place doesn’t count, and something drawn over the control in plain sight is the overlap check’s job. It caught a ticker’s reach taking every press on a phone’s table (ADR 0046). |
alike |
Siblings of one role, one class list in one group (a dock’s moves, a segmented control’s options, a row of seats, a list’s rows), less the classes that name a state (.on, .mine, a seat’s color) or an emphasis (.alt, .primary, .danger), and less .main, .left, .right: those name a dock slot’s place (room/kit/slots.js), and no stylesheet gives them a look. Against the group’s usual: height (a control, or a chip up to 48px), fill, type size and weight, and, for choices side by side in one row (a slot, a radio, a tab, a toggle, an option of a segmented control), width within 2px. A state or emphasis class, aria-pressed or :disabled excuses a fill or a weight; only an emphasis excuses a width; nothing excuses a height or a type size. Tools in a bar (Undo, Redo, Restart) aren’t held to one width: each is its own action; nor is a segmented control on a phone 360px or narrower, or a small one (a hand’s sort switch) on any phone 480px or narrower, which shares its row by its words (Fine details). A sticky cell’s fill (the score table’s Total column, so what scrolls under it is hidden) isn’t compared. It finds Auction House’s old fork, the clockwise way in the .main slot sized by its words. |
room |
Each line of words, by its ink, against the marks round it: another box’s drawn sides (a background or shadow draws all four, a border only its own), a picture, an SVG’s shapes. A board’s shape is measured by its outline, not its box (a ring’s wedges overlap the words’ box when their edges are well clear): points in an 8px band round the words are hit-tested against its fill and stroke, and count only where the shape is what’s seen there (a plate over a portrait, a drawer over the table, covers it). Closer than 8px, and not touching (that’s overlap), is a finding. Its own box’s drawn edges: 4px, across by ink, up and down by capitals and baseline, for words of three glyphs or more (a badge’s number is center’s). Past the outline of a round shape it’s set in (border-radius: 50%, or an SVG circle that’s the first thing drawn under it) by its corners, not its box. Words count as seen when nothing that paints is over them: with the probe giving every layer the pointer, an empty layer over the table (the emotes’ float) would otherwise hide every word on it. Not counted: its own control’s key cap or icon, an icon beside it in its box, a chip in the same line of words, cards touching cards, and a sheet’s foot or bar that scrolling words pass. |
readable |
Type under 12px (an SVG’s at the size it’s drawn, so a board’s labels count; a transform’s scale counts, offsetHeight’s whole-pixel rounding doesn’t), and contrast as painted (above). Not held to 12px: a seat mark’s letter (svg.seatmark, as large as its shape allows), and on a phone (the window’s shorter side 480px or less) words drawn on a board (DRAWN: .boardsvg, .mapsvg, .grid, .cb-squares, Get In Line’s .boardframe), which are as large as their space allows (Fine details). Words under a filter that dims them (brightness under 1: Auction House’s spaces you can’t move to) aren’t measured for contrast, as a disabled control’s aren’t. |
stray |
A key cap showing while the control it presses is hidden: a cap inside a button, or just after the control it presses (the sort switch’s O), when that control is visibility: hidden or not drawn. A dock’s empty slot keeps its last label and cap in its space, and a cap set visibility: visible outlived it (Contract Rummy, 2026-10-29); caps now inherit their button’s visibility. |
Each kind of finding is headed by the design rule it breaks ([centered-by-ink], [words-fit]; Rules and checks), as check:tokens’ and check:layout’s problems end with theirs. Each finding has a signature (the element’s tag and lasting classes, and its parent’s), so the same thing at seven sizes is one finding, with where it was seen and its worst measure. --shots dir saves a crop of each, --frames dir every screen’s whole window, and --report file.md the table.
ALLOW#
An intended finding goes in ALLOW, with the reason, and is reported as allowed instead of failing:
tools/house/details.mjs ALLOW · lines 69–125// ── what's meant ─────────────────────────────────────────────────────────
// A finding listed here is reported as allowed, with why, and doesn't fail
// the check: { type, sel (matched against either element, or an ancestor), why }.
export const ALLOW = [
// Badges on a corner: drawn over the corner of what they're about, as a badge is.
{ type: 'overlap', sel: '.dealer', why: 'the dealer’s chip sits on the corner of the seat’s mark' },
{ type: 'overlap', sel: '.ahouse .frame .tag, .ah-result .frame .tag, .ah-choose .frame .tag', why: 'Auction House’s value card is a tag clipped to the frame’s foot, over the painting’s corner' },
{ type: 'overlap', sel: '.fict .lstat', why: 'where a player is (★ ✓ ⋯) sits on the corner of their mark' },
{ type: 'overlap', sel: '.cb-file, .cb-rank', why: 'a chessboard’s coordinates sit in its squares’ corners, under the pieces' },
// (and so they sit close to what they're on, the way a badge on a corner does)
{ type: 'room', sel: '.dealer, .fict .lstat', why: 'a badge on the corner of a seat’s mark (the dealer’s chip, Fictionary’s ★ ✓ ⋯) reaches out toward the name beside the mark: that’s where a corner badge is' },
{ type: 'room', sel: '.cb-file, .cb-rank', why: 'a chessboard’s coordinates sit in its squares’ corners, against the next square, as a printed board’s do' },
// Drawers over the table: the game log's, Chat's.
{ type: 'overlap', sel: '.gamelog.drawer', why: 'the game log’s drawer opens over the table, opaque in its sheet’s color, so what’s under it is hidden until ✕, L or Escape closes it; pinned beside the table on a wide window, it covers nothing' },
{ type: 'taken', sel: '.gamelog.drawer', why: 'what’s under the open log’s drawer is pressed on the drawer (its ✕’s reach, near its corner), as anything under a sheet is' },
...['tap', 'taken'].map(type => ({ type, sel: '.talkdrawer', why: 'Chat’s drawer, where a screen has no game log, lies over the page from under the top bar, as a drawer does; what it covers waits under it (✕ or the bubble puts it away)' })),
// A list over the page while you type, as a menu is.
{ type: 'overlap', sel: '.lstand .qpop', why: 'Last One Standing’s question box lists what it finds over the questions so far (a computer) or the board (a phone) while you type; what it covers waits under it' },
// Targets as big as the table allows: a hand of cards, a board's cells.
{ type: 'tap', sel: '.hand .card, .pilefan .card', why: 'a fanned hand or pile shows each card’s corner, as a held hand does; on a phone it fans to the width' },
{ type: 'tap', sel: '.cb-sq', why: 'a chessboard’s square is an eighth of the board' },
{ type: 'tap', sel: '.gil .brow .sp', why: 'Get In Line’s board is ten spaces a row' },
{ type: 'tap', sel: '.mrails .fan .held', why: 'Michigan Rails’ hand is fanned in its rack, each card’s edge showing as a held hand does; pressing one only lights its routes on the map (pointing at it does the same)' },
{ type: 'tap', sel: '.sol-cards .cd', why: 'Solitaire’s seven columns share the width' },
// Lines that keep to their room, so nothing below them jumps.
{ type: 'clip', sel: '.say', why: 'the dock’s line keeps to its two lines (one on a phone’s side), so the moves under it never jump' },
{ type: 'clip', sel: '.mrummy .pilehead .pickline', why: 'what taking a card from the pile would bring keeps to the pile line’s lines, so the table never jumps (the count and who threw the top card are never cut: fitPileHead leaves off the rest)' },
{ type: 'clip', sel: '.cs-text', why: 'The Register’s clue strip shows two lines of the clue; pressed, it opens to all of them' },
{ type: 'clip', sel: '.who .name, .ename, .bseat .sname, .bseat .stag', why: 'a player’s name, then a bot’s manner, give way in a seat of its own width' },
{ type: 'clip', sel: '.mrails .pname b, .mrails .pdid', why: 'a player’s name, then what they did last, give way in Michigan Rails’ players, a slim row each beside their trains and points (the popover says it all)' },
{ type: 'clip', sel: '.ahouse .prow .pname b, .ahouse .prow .pdid', why: 'a collector’s name, then what they did last (a long title, say), give way in Auction House’s rows beside their paintings; the log says it all' },
{ type: 'clip', sel: '.block .placard .ptitle', why: 'Auction House’s block keeps a title to two lines (one on a phone) so the bid and the gavel stay on the velvet; pressed, the painting opens up close with all of it' },
{ type: 'clip', sel: '.ahouse .bankstrip .banktitle, .ahouse .bankstrip .banksub', why: 'Auction House’s Next from the bank keeps its title and artist to a line each, so the side never changes height between paintings; pressed, the painting opens up close with all of it' },
{ type: 'clip', sel: '.tb-name', why: 'a long case’s title gives way first in The Register’s bar, at 1280 and under' },
{ type: 'clip', sel: '.sol-bar .title', why: 'at 320px Solitaire’s bar keeps its clock and three tools a finger wide; the deal’s name gives way' },
{ type: 'overlap', sel: 'table.scores td.total, table.scores thead th:last-child', why: 'the Total column stays put while the hands scroll under it' },
{ type: 'clip', sel: '.g-code pre, .g-body .hl', why: 'the technical guide’s code keeps its lines as written: a long one scrolls sideways in its box, never wraps' },
// Spacing that groups on purpose.
{ type: 'align', sel: '.pk .moves', why: 'Restart stands a step apart from Undo and Redo' },
{ type: 'align', sel: '.tray .chip.on', why: 'The Register’s clue chip in use lifts 2px' },
{ type: 'align', sel: '.arletterrow > .arletter', why: 'Alphabet Roulette’s rolled letter is a 64px display capital beside a line of text: the two are centered as boxes, and a capital has no descender to balance its box' },
// A glyph whose ink is off its own middle in its face.
{ type: 'center', sel: '.g-body code', why: 'a name set as code in the technical guide is words in a line, not a badge: a one-letter one (`a`, `k`) sits on the line with the rest' },
{ type: 'center', sel: '.keys kbd', why: 'a bracket’s ink sits to one side of its advance in a monospace face' },
// Pictures.
{ type: 'center', sel: '.shelf .art, .dl-art', why: 'a box’s art is a drawing: its letters sit where the picture puts them' },
{ type: 'center', sel: '.die .dieletter', why: 'Alphabet Roulette’s die letters are drawn on its faces, each in its face’s own tilted plane: a face seen at an angle is a drawing, not a ring to center in' },
{ type: 'readable', sel: '.die .dieletter', why: 'Alphabet Roulette’s die is drawn in three dimensions: the faces turned away are shaded and foreshortened on purpose; the letter rolled is shown on its own, large, beside it' },
// Pictures of a board, not words to read: a box's art, a daily's thumbnail (the board itself is measured in play).
{ type: 'readable', sel: '.shelf .art, .dl-art, .daily-art', why: 'a box’s art and a daily’s thumbnail are pictures of the game; their numbers are part of the drawing (the board itself is measured where it’s played)' },
// David's calls, October 30, 2026 (docs/audits/2026-10-30-alike-room-readable.md).
{ type: 'readable', sel: '.griddles .gd-num.done', why: 'Griddles fades a finished clue on purpose, so the clues left to do stand out (David’s call: keep the fade)' },
{ type: 'readable', sel: 'figure.chart svg', why: 'the rummy score chart is drawn to its width: on a phone its ticks and end labels are 9–11px rather than a redrawn chart (David’s call)' },
{ type: 'readable', sel: '.mrails .engrave, .mrails .lipcount', why: 'Michigan Rails’ rack lip is engraved into the wood, dark on dark by design (David’s call: keep it)' },
// A bar that keeps six tools on a phone.
{ type: 'tap', sel: '.game.compact .tb-actions .btn.icon', why: 'The Register’s phone bar keeps Find, Undo, Redo and ⋯ beside Accuse: its icon buttons are 32–36px wide (44 tall)' },
];
Each entry is a type and a selector, matched against the element, the other element in an overlap (or, for a control under 44px to press, the thing drawn over its edge, as Chat’s drawer is over the waiting room), or any ancestor. AGENTS.md’s rule: fix a finding with the house’s primitives (a drawn icon, .house-glyph, .house-reach), never a pixel nudge. Add to ALLOW only a design choice, with a reason a reviewer can disagree with.
Useful flags: --only hashi,euchre, --kind play,over, --size 390x844, --jobs 4 (pages at once, four by default), --engine webkit, and --static-from origin/main, which serves the games’ static files from a git ref so you can measure the look before and after a change.
Playing the games#
Each *:play check plays one game in headless Chrome against the running site, with real input, and fails on a wrong outcome or any page error. They’re slower than the measuring checks and cover what a unit test can’t: real drags, reloads mid-move, reveals, result sheets, several seats at once.
| Script | Plays | Notable options |
|---|---|---|
check:chess:play (tools/chess/play.mjs) |
Castling, en passant, promotion and underpromotion, a mate off the line, a wrong move, a reload mid-move. It picks puzzles from the bundle by what they need, so a remade bundle doesn’t break it. | 1280×800 |
check:griddles:play (tools/griddles/play.mjs) |
Every size solved by dragging along each run; a cross put down and taken back; Undo, Check, the reveal, the result sheet; a past daily from the archive. | WIDTH, HEIGHT, SIZES=10,15, REDUCED=1; SESSION (a signed-in session) posts the solves, which must be accepted |
check:galaxies:play (tools/galaxies/play.mjs) |
The Lines | Paint tool; painting by tap and drag, the rubber, Undo, Redo, a reload, Restart; the answer’s walls; the archive; Solitaire’s old daily address landing on its home. The one play check that also runs in WebKit. | --size=390x844, --engine webkit, SHOTS |
check:low-poly:play (tools/low-poly/play.mjs) |
A picture at each size: lines by drag and by tapping each end, a wrong line rubbed out, a line through a dot refused, a double-tap clearing a dot, Undo, Redo, a reload, the reveal and result sheet. | three phones (one on its side) and a desktop by default; --size, --only=hard |
check:bowl:play (tools/bowl/play.mjs) |
Four seats: the browser hosts on a phone, the others play through the API as fast as the rules allow. Long team names fit, and banners stay true through flurries of turns, a reload mid-turn and a dropped connection. | --size, --reduced |
check:alphabet-roulette:play (tools/alphabet-roulette/play.mjs) |
Three seats, the browser hosting, for three rounds: the server’s letter on the die, the pad opening only at Go! and keeping its text through a reload, a wrong letter marked, a challenge voted on, a shared answer struck for both, through to the result sheet. The page never scrolls or repeats a banner. | --size, --reduced |
check:quiz-whiz:play (tools/quiz-whiz/play.mjs) |
A whole board game of three, the browser hosting: a category picked in the waiting room, rolls, lit spaces picked on the board, answers typed (from the card, read by tools/quiz-whiz/peek.py), a near miss each way voted on, a light’s strong band, through to the result sheet. No errors, no sideways scroll. |
--size, --reduced, --quick, --shots |
check:millbury-mansion:play (tools/millbury-mansion/play.mjs) |
You and three hard bots with Quick moves, to the end: picking a suspect, a lit room pressed on the plan, the suggest sheet prefilled from the notebook, a card shown to you inking in, a tag telling its turn, a pencil mark, showing a card, and the result sheet naming the case file. Nothing throws or runs off the side. | --size, --reduced |
check:vibe-check:play (tools/vibe-check/play.mjs) |
A whole game of six in two teams, the browser hosting: its psychic’s clue typed, the needle dragged and locked in through the page, a teammate’s turn of the dial arriving with the mover’s mark, the call, the strong band at every reveal, the target drawn only on the psychic’s screen until then, through to the result sheet. No errors, no sideways scroll. | --size, --reduced, --coop, --shots |
check:talk:play (tools/room/talk.mjs) |
Talk at a table with Chat on, in separate browsers: two seats and, once dealt, a spectator. An emote one seat sends floats up the others’ screens with its name, and a line of chat reaches the other seat (a count on the bubble until it’s read), in the waiting room (Chat a drawer) and in the game (a tab of the log); the spectator reads both with nothing to send; “Others’ emotes” Off hides them; Bowl has the smiley and no Chat. | --game, --size |
check:feedback:play (tools/house/feedback.mjs) |
Feedback offered nowhere to a guest, then sent by a throwaway account it signs up, from the hub’s footer, from Options on Star Battle (an SVG board) and a Contract Rummy table, and from Star Battle on a phone. Each must arrive with its screenshot (a JPEG under 1.5 MB) and show “Thanks — sent.” Then the “?” beside Your rating. The site must email to its console (no RESEND_API_KEY). |
SHOTS=dir saves each screenshot and sheet |
check:contract-rummy:play (tools/contract-rummy/play.mjs) |
Contract Rummy’s go-down tray at 1920×1080 and on a phone: Go down lays both sets out in view, Undo takes one back, Go down finishes it, Confirm lays it down; Cancel and Escape take it all back; at a draw the stock waits on buyers, neither button is filled, Draw from stock locks in and lets go, and the draw happens as buying closes. tools/contract-rummy/stage.py sets each moment in the site’s database. |
— |
check:tables:play (tools/room/sweep.mjs) |
Every table game but Auction House played to its end by bots, looked at the awkward way: your seat handed to a bot and back mid-game, your page reloaded, a watcher reloading too, the log open on both, then the result. Any error in either page fails it, uncaught or one a table update’s drawing logged, the null that one seat, a watcher or a late arrival has. tools/room/sweep.py pokes the seats and the bots’ pace in the site’s database. |
--only, --seconds (each game’s looks before it’s played out), --size |
check:dailies:play (tools/dailies/play.mjs) |
The Dailies page as a guest: fresh, a puzzle solved from it, its result sheet’s offer of the next daily followed, partly done, a chess puzzle solved, all done. | --size, SHOTS=dir |
check:fictionary:play (tools/fictionary/play.mjs, then tools/fictionary/over.mjs) |
Rounds against bots as a person plays them, then a whole game to 10 points with the browser watching: nothing says who won while the last reveal plays, and the result sheet opens by itself. | --size, --rounds, --shots, --reduced. It uses its own hostname, fictionary.localhost, so its seats don’t share storage and cookies with other pages. |
Most save screenshots with SHOTS=dir or --shots dir, the quickest way to see what a check saw.
Before a release: the design audit#
The /design-audit skill (.claude/skills/design-audit/SKILL.md) runs the browser checks over the whole site the same way each time, before a release that touches shared kits or many games. It runs check:details:self, check:layout, then check:details in Chrome (with every screen’s frame) and in WebKit, one browser at a time and inside a two-hour time box. It adds a look at contact sheets for the rules held by eye (Rules and checks), and reports every finding under the rule it breaks. The report sorts must-fix from later, with flaky tests and the checks’ own limits kept apart from the site’s problems; docs/audits/2026-10-30-alike-room-readable.md is the first. The checks aren’t changed mid-audit: a check that reads something wrong is fixed afterwards, in its own PR.
Release videos (not a check)#
The same drivers film the games for the family’s release videos (tools/release-video/render.mjs, docs/release-video.md). A take plays a scripted moment against the running site in slow motion: tools/release-video/lib/dilate.js slows every clock the page reads and DevTools slows its animations to match, so a screencast that only catches a few frames a second on a busy machine still gives smooth ones. Its tables are made through the table API with puppet players who act on cue (tools/release-video/lib/tables.mjs), and moments the camera needs are set with the rules’ own functions (tools/release-video/stage.py: Auction House, Contract Rummy, Vibe Check, Millbury Mansion and Quiz Whiz so far). A take presses, types and holds as a person does, and drags too (drag, a mouse held down or a finger moved along a path, as on Vibe Check’s dial), with the compositor’s cursor or touch following the logged path. A release sets its tempo (bpm, which scenes are counted in bars of) and can play every take slower (speed), so the same scenes can run longer without recording again. The pacing check (tools/release-video/pace.mjs, npm run pace:release-video -- <id>) sizes each scene’s bars from its words and its takes, and finds dead air in what was drawn. The render makes the 16:9 cut; the 9:16 one only with --cuts main,vertical. It fails on nothing; it’s output, never committed.
check:guide#
npm run check:guide runs uv run python manage.py guide check. It checks that every directive resolves, links between chapters land, every game in the catalog has a chapter, and every written chapter’s reviewed: stamp matches the code it covers. A failure names the chapter, the changed files and the git diff to read. Read it, update the chapter, then npm run guide:stamp <chapter>.
It’s also the first test in tests/test_guide.py, so manage.py test tests fails on a stale chapter too. How this guide stays true explains the stamps. While writing, uv run python manage.py guide render <chapter> draws one chapter and lists what doesn’t resolve, and guide status lists every chapter, fresh or stale.
Before a pull request#
There’s no CI (ADR 0035), so the gate is local. Before opening a pull request to main, run npm run check:pr (tools/check-pr.mjs). It runs the guide’s check first, since it’s quick and names the chapters to review. Then it runs everything else at once, in lanes, so the gate takes as long as its slowest lane rather than the sum of all:
tools/check-pr.mjs LANES · lines 16–23const LANES = [
{ name: 'Django suite', cmd: ['uv', ['run', 'python', 'manage.py', 'test', 'tests', '--parallel', '4']] },
{ name: 'check:chess', cmd: npm('check:chess') },
{ name: 'check:puzzles', cmd: npm('check:puzzles') },
{ name: 'check:register', cmd: npm('check:register') },
{ name: 'check:solitaire', cmd: npm('check:solitaire') },
{ name: 'quick checks', steps: ['check:tokens', 'check:house', 'check:room', 'check:fictionary', 'check:last-one-standing', 'check:millbury-mansion', 'check:quiz-whiz:library', 'check:hashi', 'check:release-video'].map(npm) },
];
Each lane alone, on David’s eight-core Mac in October 2026: the Django suite about 100s (220 in one process), check:chess 140s, check:puzzles 80s, check:register 50s, check:solitaire 30s, and the quick checks 10s. The suite runs four processes on SQLite (no DATABASE_URL), each with its own copy of the test database. It uses four, not every core, because the Mac’s 8 GB is shared with the browser checks’ Chromes and a dev server.
Each lane prints when it finishes: one line with its time if it passed, its full output if it failed. The gate fails if any lane does, but only after the rest finish, so one run names every failure. The lanes slow each other a little since they share cores. The chess check ends the gate at about three minutes, half the six to eight it took when the checks ran in turn.
A Claude Code hook (.claude/hooks/check-guide.sh) also blocks a git commit while the guide is stale, and reminds at the end of a turn.
The browser checks aren’t in the gate: they need a running site and its database, and check:details plays tables to their end in it. So whoever changes anything you can see runs check:layout and check:details against their own worktree’s server (AGENTS.md rule 4).
Merging to main deploys (AGENTS.md, “Deploying”), so these are the last checks before players see a change. No script covers these:
- Party games are verified on real phones (ADR 0016).
- The play checks and
check:layoutdrive Chrome only. Only fine details are also measured in Safari’s engine (check:details:webkit, locally). Firefox, and the rest of Safari, are checked by hand.