Site redesign: build spec
Goal (Dennis, 2026-10-13): one simple, intuitive site. One nav, one page per machine, one machines table. Old URLs may break. The CLI commands keep their arguments. No information is lost: it moves.
Everything stays static HTML, written by stdlib Python with atomic writes, using relative links and no external requests. tools/serve.py stays exactly as it is.
0. Facts the spec is built on (2026-10-13)
- 64 machine directories: 31 kills, 1 active, 1 waiting, 31 parked (8 of them cryptid candidates). 678 machines on the list are untouched.
- 54 directories have an observe.json, so 54 have a card.html. The other 10 are delta machines with a status.json
parent: 8 kills (#24, #25, #11b, #12b, W4-3b, W4-3c, W5-8b, W5-8c) and 2 that are not killed (#32, a cryptid; #23, stuck). - There are 30 kill.html pages. W5-4 has none, because
rebuild_pages.shnever runs killreview. The new rebuild closes this gap. - PRs: #14, #15 and #16 are merged; #17 and #18 are open.
dashboard.build_records()already groups kills per PR inmeta["prs"]. - Package folders:
submission/family/(#15, #16, #17),submission/pr/(#18), and for #14 the older formsubmission/discord.md+submission/discord/. Each package holdspr_title.txt,pr_body.md,discord.txt(with placeholders),discord_dennis.txt(links filled in),attachments/*_writeup.md,images/*_spacetime_{10k,1e9}.png, and optionallyforum/*.txtandnames.txt. - All 18 decision.json files are answered, so the nav count is 0 today.
- Cost:
build_records()takes 3.0 s, of which 2.8 s isguess_class. With a 5-line list it takes 0.4 s.killreview.Ctx()takes 0.4 s, onekillreview.build0.13 s, onecard.render_card0.02 s. - Test baseline: test_card 26/26, test_dashboard 26/26 and test_decisions 18/18 are OK. test_killreview has 1 failure,
test_automatic_checks: W4-3c has "coqchk finished" not met. That is a data fact, not a page bug, and it stays out of scope.
1. Pages and URLs
| Page | File (URL under /) | Anchors |
|---|---|---|
| Home | dashboard/index.html. / keeps redirecting there through root index.html |
#needs, #recent, #counts |
| Machines | dashboard/machines.html |
#b-<bucket> per group, #untouched, #fam-<lead tm> |
| Machine | machines/<tm>/index.html (always linked with the explicit index.html, so file:// works too) |
#overview, #mechanism, #proof, #files. Kept inside: #how, #fig-N, #q-N, #same, #s1..#s7 |
| Submissions | dashboard/submissions.html |
#pr-<n>, #ready |
| Decisions | dashboard/decisions.html (same file, gains the nav) |
#d-N (existing), plus new #tm-<tm> |
| Reports | dashboard/reports.html |
none |
| Explorer | tools/explorer/ (not generated; nav link only) |
#tm=<tm> |
Unchanged outputs: dashboard/data.json, dashboard/kills.json, dashboard/thumbs/, dashboard/screens/.
Removed outputs: every machines/<tm>/card.html, every machines/<tm>/kill.html, machines/index.html and machines/kills.html (§6).
The machine page sits in the same folder as card.html and kill.html did. Every relative link those fragments produce (proof.md, img/x.png, submission/…, <tm>.v) therefore stays valid as it is.
2. Shared module tools/site.py
site.py has no top-level imports of card, dashboard or killreview. Any loader that needs them imports them inside the function, so card.py can import site.py without a cycle.
| Piece | API | Content / rule |
|---|---|---|
| Paths | ROOT, MACHINES, OUT = ROOT/"dashboard", PAGES = {"home": "index.html", "machines": "machines.html", "submissions": "submissions.html", "decisions": "decisions.html", "reports": "reports.html"} |
|
| Theme tokens | TOKENS (CSS string) |
The :root light block, the dark block for prefers-color-scheme and the [data-theme=dark] block are moved verbatim from the top of card.CSS, together with dashboard's --good-strong/--good-soft/--good-weak. Then card.CSS = site.TOKENS + <the rest of card.CSS> and DASH_CSS drops its token lines. ROOT_INDEX uses TOKENS too. serve.py's own palette is left alone. |
| Page shell | page(title, body, *, active, prefix, css="", js="", built_ts) |
Doctype, viewport meta, <title>, <style>{TOKENS}{BASE}{NAV_CSS}{css}</style>, the nav, the stale banner (#stale, filled by TIME_JS), <div class="wrap">{body}</div>, and <script>{TIME_JS}{js}</script>. prefix is the path from the page to the repo root: "../" for dashboard/*, "../../" for machine pages. card.page() and the hand-made shells in decisions/dashboard/killreview are all replaced by this. |
| Nav | nav_html(active, prefix, open_n) |
<nav class="sn"> holds the brand "BB(6) hunt" (links to Home) and six links in this order: Home · Machines · Submissions · Decisions <span class="sn-n">N</span> · Reports · Explorer. The current page gets aria-current="page". Above 760 px the links are inline. At 760 px and below a <details class="sn-menu"><summary>Menu</summary>…</details> holds the same six links, and the inline row is hidden by a media query. It works without JS. The links appear twice in the markup; tests check that both lists are identical. When N is 0 the badge is muted (.sn-n.zero). The nav is not sticky: the decisions footer and the Machines filter bar keep their own stickiness. |
| Open decisions | open_decisions(machines) -> list[(tm, question or None)] |
Every machines/*/decision.json without answer. Unreadable files count as open (dashboard._open_decision). nav_html gets len() of this list, so every page, and the Decisions header, shows the same number. |
| Display name | Names(machines) with .label(tm) -> (tag, tip) and name_html(tm, href=None, cls="") |
Tag, first match wins: (1) the pool rank from reports/targeting.md: #3, R4; (2) the status.json label: W4-3, #3b, C2-552; (3) twin of #N when the parent has a rank; (4) line N on the 740 list; (5) 778 line N; (6) off the list. This is dashboard.member_name's order. The tip is the full string, plus "also called W4-1" when (2) differs from (1); #31 is the only case today. HTML: <span class="nm"><code class="tmn"> + the string with <wbr> after each _ (dashboard._tm_html) + </code><span class="tag">#3</span></span>. It is cheap: only targeting.md, the status.json files and the list are read, never build_records. dashboard.member_name, killreview.Ctx.name and decisions.rank_of all delegate to it. Ctx switches its line N fallback from the 778 list to the 740 one, with 778 as the fallback. |
| Repo (data) | Repo(machines=MACHINES, list_path=D.LIST, prev_path=D.PREV_LIST, out=OUT, now=None), every property memoized |
.recs, .meta = dashboard.build_records(...). .by_tm. .names. .ctx = killreview.Ctx(machines=…), after Ctx gains machines/dash_data parameters in place of the module globals. .prs = meta["prs"] plus the package found for each PR (§3.4). .kill_summaries = {tm: summary}, filled while machine pages are built, otherwise computed with killreview.build_parts(…, vsmode="cache"). .open_decisions. .reports (§3.6). Tests build a Repo on a temp tree with a 3-line list, which takes about 0.4 s. Optional: build_records(guess_for={tm}) lets a single-machine build skip the 2.8 s guess pass. |
| Buckets | BUCKETS7 = accepted, submitted, claimed, active, waiting, parked, cryptid. bucket7(rec): "cryptid" when the dashboard bucket is parked and the status is CRYPTID_CANDIDATE, else the dashboard bucket. |
Today: 9/4/18/1/1/23/8, plus 678 untouched. The overnight report says "cryptid 10": the other two cryptids sit in waiting (W5-2) and active (R4, funded). |
| Hrefs | machine_href(tm, from_dir), page_href(key, prefix) |
Always …/machines/<quote(tm)>/index.html. |
| JS | TIME_JS, moved from dashboard.py unchanged |
Ages, the "working now" badges and the stale banner, on every page. |
| Orchestrator | build_all(repo, *, thumbs=True, force_thumbs=False, vsmode="fill", pages=ALL) and main() |
Order: thumbnails (D.ensure_thumbs), every machine page (which collects the kill summaries), Machines, Home, Submissions, Reports, Decisions, then data.json, kills.json and the root index.html. python3 -B tools/site.py [--no-thumbs] [--force-thumbs] [--force] [--jobs N] builds everything. |
3. Each page: sections and their sources
3.1 Machine page: tools/machinepage.py (new)
render(repo, tm, vsmode="cache") -> (html, info) and build(repo, tm, vsmode) -> Path. The page works for every machine directory, with or without observe.json.
There are two refactors, and both keep the old functions' output byte-for-byte identical:
card.card_parts(obs, mdir, prefix="")returns{rank_txt, class_html, state, state_html, howp, intro, look, folds, warns, plan}. It is the body ofrender_cardcut at its existing comments (header, intro, look, folds, footer warnings).prefixis threaded throughresolve(),figure_html,other_viewers,stats_htmland the discordsrc, so a delta page can show the parent's figures with../<parent>/img/….render_cardis deleted.killreview.build_parts(ctx, tm, vsmode, ns="p")returns{secs: [(sid, title, check, body)], verdict_html, summary, hp}. It is the body ofbuild()before# ---- page.figure()takesns, which gives idspfig-Nand labelsFig P1, P2…, so they never clash with the card'sfig-N. Section 5 ("Mechanism pictures") is dropped fromsecs: its standard picture moves to the Overview and its card figures to Mechanism. Its check line moves under the Mechanism heading. In-body links change:../<o>/kill.htmlbecomes../<o>/index.html#proof,../<o>/kill.html#samebecomes../<o>/index.html#same,../<o>/card.htmlbecomes../<o>/index.html#mechanism, andcard.html#q-becomes#q-.card_ref()is replaced bymechanism_ref(), which returns the own page, else the firstparentthat has an observe.json.build(ctx, tm, vsmode) -> (proof_fragment_html, summary)keeps its name for the tests.
Page layout: the nav, then <header class="mh">, then four <section class="mp" id=…> blocks, then the footer. A jump row under the header links Overview · Mechanism · Proof & checks · Files; it is not sticky, so the card's sticky checkpoint column is unaffected.
| Section | Block | Source (reuse) |
|---|---|---|
| Header | <h1> = names.name_html(tm) with the copy button (id="tmstr", card JS); "kill N"; the class; links: bbchallenge ↗ · explorer (#tm=) · parent's page |
card_parts.class_html, else rec["class"]/guess (D.class_html(rec)); rec["kill_no"]; C.tm_url |
Overview #overview |
State line | C.state_html(C.card_state(obs or {}, st, qs, mdir)), which works with obs={} |
| "Needs you" box, only when there is something | rec["ask"] / rec["questions"] through D.ask_html (links now point to #mechanism / #q-N); rec["ask_via"] through D.via_html; an open decision.json links to decisions.html#tm-<tm> |
|
How we proved it (#how) |
C.how_proved_html(C.load_how_proved(mdir), title=C.how_title(status)) when C.shows_how_proved. This is the machine's own file; a derived kill never shows the parent's. It appears exactly once on the page. |
|
| In one sentence, plus folded mechanism notes | card_parts.intro. With no obs: rec["summary"] (the Mechanism line of submission/README.md) or status.json relation + parent_note. |
|
| Upstream / PR | K.sub_chip(st) + D.pr_line(rec) + a link to submissions.html#pr-N |
|
| Kill facts (kills only) | New D.kill_facts_html(rec), extracted from tile_big (lines, KB, compile, coqchk, axioms, first PASS, re-run, "from #3"), plus build_parts.verdict_html (n/7, Checkpoint B, template reuse, vs parent), which links to #proof |
|
| Progress (non-kills) | New D.progress_html(rec, now), extracted from tile_big: pipe_html, the first sentences of the reason, last change / idle since with the ledger tooltip, and live_html |
|
| Relations | D.table_html(rec), D.eq_chip(rec), D.off_chip(rec), the family list (ctx.family(tm), each member as a name_html link with its status chip) |
|
| Facts | D.meta_html(rec, rank=True): eqv, sim, flags, sheet status, the misattribution strike; the list line (D.line_label) |
|
| Calls on file (folded) | status.json priority_call_answered, parked, funded, next; machines/<tm>/decisions/*.json (date, option, question); the current decision.json |
|
| Standard space-time picture (right column on wide screens) | dashboard/thumbs/<tm>.png shown at 1:1 (400×500, image-rendering:pixelated, max-width:100% on phones), linked to bbchallenge.org, with the label C.label_html(D.THUMB_LABEL). A missing thumbnail gets a placeholder. |
|
Mechanism #mechanism |
Has observe.json | card_parts.look (primary figures + Checkpoint A + Discord draft), then card_parts.folds (More figures, Rules, Compressed history, Template comparison, Proof strategy & risks, Stats & provenance), unchanged. Figure ids stay fig-N in card numbering, so card.html#fig-2 hrefs in decision.json map 1:1. |
No observe.json, has a parent with one |
A banner: "No observation of its own: card_parts(parent_obs, parent_mdir, prefix="../<parent>/") with only look-figures (no checkpoint), More figures, Rules, Compressed history and Template. The parent's questions are linked (../<parent>/index.html#mechanism), not shown. A non-killed delta machine also gets K.same_section(…, vsmode="cache") here; for kills that section lives in Proof & checks. |
|
| Neither | "No observation yet", with a link to notes.md | |
Proof & checks #proof |
Killed | build_parts.verdict_html, then K.section(k, …) for each of secs: What is proved · [Same machine as X? #same] · Where it came from · Proof outline · What a maintainer reads · Family · Checklist. Then the review pages that exist (D.REVIEW_GLOBS minus kill.html: review.html, compare.html, review*.md). The footer line "nothing on this page is hand-written except the How box" stays. |
| Not killed | Links to proof.md, check_proof*, rocq/*.v; Checkpoint B lines (K.checkpoint_b(mdir)); status.json open_lemma(s). With none of these: "No proof work yet." |
|
Files #files |
<details>, closed |
Every top-level entry of machines/<tm>/, with size and mtime: key files first (notes.md, proof.md, how_proved.md, status.json, observe.json, deepen.json, decision.json, .v, check_kill), then folders (submission/, rocq/, scripts/, img/, decisions/, work/, …: their own listings still work, since only the machine folder gets an index.html), then the rest. status.json is printed in full in a nested <details><pre>. Card contract warnings (card_parts.warns) come last. This replaces the folder listing that machines/<tm>/ used to serve. |
| Footer | "rendered … by tools/card.py / killreview.py", plus the re-run commands |
CSS is card.CSS + K.CSS_EXTRA + a small mp- block (header, two-column overview, jump row). JS is card.JS; killreview's "no JS" rule still holds for the Proof & checks fragment itself.
3.2 Home: dashboard.render_home(repo), replacing render_page
| Block | Content | Source |
|---|---|---|
Counts #counts |
7 tiles (accepted / submitted / claimed / active / waiting / parked / cryptid) plus untouched, each with icon, count and short meaning, and a "● n working now" badge. Each tile links to machines.html#b-<bucket>. One list line: "740 holdouts (current list) · +2 off the list · previous list 778 (38 gone) · 1,526 with equivalents · sim pending 130/35". |
site.bucket7 over repo.recs; D.BUCKETS text (adds cryptid ⚠ "cryptid candidate, parked"); meta (n, n_prev, prev_gone, eqv_total, pending, off_list) |
Needs you #needs |
(1) Open decisions: question + recommended option, linked to decisions.html#tm-<tm>. (2) Waiting machines: D.ask_html(rec) with the link changed to the machine page. (3) PRs to answer: an open PR whose comments > 0. (4) Discord to post: an open PR where no machine's status.json has posted, with "copy it on Submissions" → #pr-N. (5) Ready to submit: claimed kills grouped by family, linking submissions.html#ready, with the claimed key line ("They wait for the response to the open PR #17 and #18"). (6) Unanswered Checkpoint A questions: count on how many machines. (7) A folded block "Discord question drafts, not posted (n)": each distinct message once, with copy buttons, as at the bottom of the old cards index. When everything is empty: "Nothing needs you right now." |
repo.open_decisions + decisions.analyze (recommended); rec["bucket"]=="waiting"; repo.prs; D.kill_key("claimed", …); C.norm_questions / card_state(...)["open"] (add open_q to machine_state); C.discord_message over observe.json discord_check |
Recent #recent |
(a) Events from the last 4 days, newest first, at most 12, each with a machine link: kill verified (kill.first_ts), PR merged (sub.merged_at), PR opened (sub.opened), status changed (rec["updated"] + status chip + first sentence), call answered or filed (decision.json answer.date / updated). (b) The last 6 rows of reports/overnight-ledger.md (time, action cut at 220 chars, machine tokens linked), plus a link to the file. (c) A link to the latest report. |
New site.recent_events(repo); D._LEDGER_ROW (extend the token map with status labels like W4-3); repo.reports[0] |
| Footer | rebuilt time and the rebuild command |
3.3 Machines: dashboard.render_machines(repo)
Header: "64 machine folders", the status chips per live status (from the old cards index header), the thumbnail label, and a folded "How to read this table" with the kills index's "What to check here" text and its colour key.
Filter bar: dashboard's #bar, reused. Search, bucket chips (the 7 + untouched), the class menu with counts, sort, and "n of N shown". DASH_JS is generalised: items are [data-tm], groups tbody[data-b] / .minis, families tbody.fam. With a sort other than the default, rows render flat and the group headers hide; switching back restores data-o. A #b-X hash on load selects bucket X. Without JS the whole table shows, grouped.
The table has one <tbody data-b=…> per bucket, in the order of §2. Kill buckets have one <tbody class="fam"> per family. The family header comes from ctx.family(): "Family of #3 · class · 4 killed, 1 not; not killed: line 604 (untouched), …". Today's grouping ran within buckets on the dashboard and across buckets in kills.html. Now members that sit in another bucket are named as "also #3b in Accepted". Rows carry D._data_attrs(rec, order).
| Column | Kills | Others | Source |
|---|---|---|---|
| Picture | 64×80 thumbnail → machine page | same | D.thumb_html (thumb_target now always returns the machine page for folders) |
| Machine | name_html, class, Approach tag (→ #how) |
name_html, class/guess/confidence |
site.Names, D.class_html, C.how_proved_tag |
| State | kill N, K.sub_chip |
status chip, stage, live_html, off_chip |
C.status_chip, rec |
| Now | mechanism line | ask, or card_state.short, or the first sentence of the reason (2 lines, full text in the title) |
rec["summary"], rec["ask"], C.card_state |
| Proof | n/7 + K.auto_why, Checkpoint B mark, template kind + K.bar + "% identical · new/items", "same machine as X?" + K.vs_short, lines · items · compile |
pipe_html (mini) |
repo.kill_summaries[tm] |
| Related | parent / table / eq-of | same | D.table_chip, D.eq_chip |
| Changed | ago_html(last_work) · status.json date |
same | rec |
| Flags | eqv, sim, sheet, UBRRBA, longit. | the same + n open Q, Discord, n unlabelled, observe.json unreadable |
D.meta_html + card lint (C.label_issues) |
At 760 px and below each row becomes a card (CSS grid on tr/td): picture, then machine, state and now. Proof, related, changed and flags shrink to one small line. Nothing scrolls sideways.
Below the table: <details id="untouched">, "Not started: 678 holdouts on the list with no folder". Inside is the old Untouched grid unchanged (D.tile_mini, the tier ordering and its key line, content-visibility:auto). The filters apply to it, and pressing the untouched chip opens it.
The footer keeps the dashboard's data-sources list (list files, sheet, buckets, PR sync, kill numbers, same table, flags, off-list reasons) word for word.
Dropped on purpose: the "no card" and "card stale" flags (every page is rebuilt together now), and the cards index's Fig 1 thumbnails (Fig 1 is the top of Mechanism; the table uses the uniform standard picture).
3.4 Submissions: tools/submissions.py (new), render(repo)
#pr-<n>cards, newest PR first, one per entry inrepo.prs:- State chip (
D.pr_chip), opened, merged at/by, comments (highlighted when > 0), title (pr_title.txt). - Machines:
name_html+ kill N + upstream file (names.txtcolumn 2, else status.jsonpr_file) + links to.v/proof.mdwith view +?download. - Discord: posted / not posted (status.json
posted); the text in a<pre>with a copy button. Text priority:discord_dennis.txt, thendiscord.txt, then the```textblocks ofsubmission/discord.md(+submission/discord/part2.txt), with a link to the placeholder version. - Forum posts (
forum/*.txt, folded, copy). - PR body (
pr_body.mdview +?download). - Write-ups (
attachments/*_writeup.md, elsesubmission/discord/*_writeup.md: view +?download). - Pictures (
images/*.png: a small preview +?download). - Checks: n/7, and the write-up lint from
kill_summaries. - Package lookup
find_package(pr): for each machine of the PR, the first ofsubmission/pr/,submission/family/that haspr_title.txtordiscord*.txt. Failing that, the legacy pairsubmission/discord.md+submission/discord/. Read-only. #ready, "Ready to submit": claimed kills by family (as on Machines), each withsubmission/README.md, thediscord.mddraft (copy), the write-up (submission/proof.md+?download),.v, n/7 and the write-up lint. PlusD.kill_key("claimed").- A PR closed unmerged (if one ever exists) gets its own card, in red.
3.5 Decisions: decisions.py, changes only
render_pageusessite.page(active="decisions"); the old header links (dashboard · cards index · kills) go away.- The
open_nin the header comes fromsite.open_decisions, with unreadable files counted and said so. - Rank comes from
site.Names.label. - The thumbnail and the "card or folder" links point to
machines/<tm>/index.html. figure_ev_html/link_href:card.html#fig-Nand#fig-Nresolve to…/index.html#fig-N(has_cardbecomes "has observe.json").- Each article gets an inner
<span id="tm-<tm>"></span>. - The form, the sticky footer,
STORE_KEYand the copied text stay unchanged.
3.6 Reports: tools/reports.py (new), render(repo)
REPORTSregistry: 1. "Latest report": the newestreports/overnight-20*.md(not -plan, not -ledger). 2. "How we proved them":reports/how-proved.md. 3. "Wave rankings":reports/wave*/ranking.md, newest first. 4. "Landscape":reports/landscape.md. 5. "Targeting":reports/targeting.md. 6. "Project status":STATUS.md,PLAN.md.- Each entry: the first
#heading as its title, the first paragraph (≤ 200 chars) as its description, mtime and size, a link to the.md(serve.py renders it), and?raw. - Folded "More notes": every other
reports/**/*.md(checkpoint-A-batch1, family-sweep, holdouts-740-impact, kill-forensics, discord-drafts, overnight-plan-, phase0_compile, forensics/.md, site-redesign.md). - Never listed:
*.log,*.tsv,overnight-ledger.md(Home shows its tail), the screenshot folders. - serve.py's rendered
.mdpages have no site nav. The browser's Back button covers that, and serve.py stays untouched.
4. CLI mapping (same arguments, new outputs)
| Command | Writes now |
|---|---|
python3 -B tools/card.py <tm> (machine string, dir or observe.json) |
machines/<tm>/index.html (full page: kill sections too when killed; vsmode="cache"). Works without observe.json. Prints path + URL + error:/warn: lines as before. A missing folder gives exit 1. |
card.py --lint <tm> |
unchanged (observe.json contract; a missing observe.json is still ERROR) |
card.py --index |
dashboard/machines.html |
card.py --all |
every machine page, then dashboard/machines.html |
card.py --machines DIR [--out DIR] |
the same, into temp trees (tests). --out is new and defaults to dashboard/. |
python3 -B tools/killreview.py <tm> |
machines/<tm>/index.html with vsmode="fill". Prints the same summary line (auto n/7 template … vs …); a machine that is not killed prints "not killed". A missing folder gives exit 2. |
killreview.py --all [--force] |
every killed machine's page (fill / force), then dashboard/machines.html |
python3 -B tools/dashboard.py [--no-thumbs] [--force-thumbs] [--screens] [--list] [--prev-list] [--out] [--jobs] [--no-root-index] |
site.build_all: thumbnails, all machine pages, Home, Machines, Submissions, Reports, Decisions, data.json, kills.json, root index.html. --no-cards-index is kept as an alias that skips the machine pages. --screens shoots every top-level page plus 3 machine pages (kill with card, delta kill, cryptid) at 1920/1280/390 into dashboard/screens/. |
python3 -B tools/decisions.py / --lint [TM…] |
dashboard/decisions.html / unchanged |
python3 -B tools/site.py […] |
the same as dashboard.py (the canonical entry point) |
tools/pr_sync.py |
unchanged; its docstring "then rebuild" becomes python3 -B tools/dashboard.py |
tools/rebuild_pages.sh |
see below |
New tools/rebuild_pages.sh, which rebuilds everything when any input changed:
#!/bin/bash
# tools/rebuild_pages.sh: rebuild the whole site when anything it is built from changed (bb6-pages timer, every 5 min).
cd [local working file] || exit 1
exec 9>dashboard/.rebuild.lock; flock -n 9 || exit 0 # one build at a time
stamp=dashboard/.built # dot-file: serve.py never serves it
changed() {
find machines -maxdepth 5 -type f -newer "$stamp" ! -name index.html ! -name 'vs-parent*' ! -path '*/__pycache__/*' -print -quit
find reports -maxdepth 2 -name '*.md' -newer "$stamp" -print -quit
find data/holdouts tools -maxdepth 1 -type f \( -name '*.py' -o -name '*.txt' \) -newer "$stamp" -print -quit
}
if [ ! -f "$stamp" ] || [ ! -f dashboard/index.html ] || [ -n "$(changed)" ]; then
touch dashboard/.built.new # changes made during the build trigger the next run
nice -n 10 python3 -B tools/site.py >/dev/null && mv dashboard/.built.new "$stamp"
fi
The excluded names are what the build itself writes; without the exclusions every run would trigger the next. The rebuild costs about 15 s niced: build_records 3 s, 64 pages ≈ 5 s, then the rest. vsmode="fill" computes a new derived kill's comparison once (≈ 7 s) and caches it.
5. Code removed with the old pages
- card.py:
render_card,card_path,build_card,render_index,build_index,INDEX_CSS,_rank_key,GROUP_ORDER/GROUP_HEAD, pluspage()(now site.page). - killreview.py:
build_index, the page assembly inbuild(),card_ref. - dashboard.py:
- Removed:
render_page,_cards_index_link,tile_big, the kill-section and family block code,kill.htmlinREVIEW_GLOBS, and thecard/parent_cardflags. - Kept:
kill_keyandfam_blocks, only if used. machine_stategainsopen_q(it already hashas_observe, which replacescard);WORK_SKIPkeeps index.html, card.html and kill.html.- Everything that is reused stays: chips,
class_html,meta_html,table_*,pr_*,ask_html,via_html,tile_mini,thumb_*,TIME_JS,DASH_JS(generalised),DASH_CSS.
6. Deletion: what and when
- After the machine pages are built for all 64 folders and the link check passes (§7, test_site 3), run a one-time inventory script. It is a scratchpad script, not shipped. It pulls the visible facts from today's pages: chips,
kr-sub,howp-tag,kr-verdict, figcaptions,summary,kr-tabcells,tsum/kf/kprlines. It checks that each fact appears in the new page that took over that content, and it prints any misses for a fix round. Old copies go to the scratchpad for the headless review comparison. - Then delete, in the integration step:
bash rm machines/*/card.html machines/*/kill.html machines/index.html machines/kills.htmlThat is 54 + 30 + 2 files. Nothing else in machines/ is touched: review.html, compare.html, the img/vs-parent caches and all data stay. - Rewrite links once in human reports (not machine data), using sed on paths only:
-
reports/how-proved.md(18 links),reports/overnight-2026-10-10.md(Links section) andSTATUS.md:machines/<tm>/card.htmlbecomesmachines/<tm>/index.html#mechanism,kill.htmlbecomesindex.html#proof, andmachines/kills.html/machines/index.htmlbecomedashboard/machines.html. -overnight-ledger.mdis history and is not rewritten. - Thecard.htmllinks inmachines/*/notes.mdand decision.json files stay as they are (data files). decisions.py maps the figure ones. - Update the docs in the same step: README-card.md, README-killreview.md, README-dashboard.md, README-decisions.md (their page sections now point to §3 of the new pages), a new short README-site.md (nav, names, Repo, CLI table, rebuild), and the root
ROOT_INDEXlinks.
7. Tests
Run with python3 -B tools/test_<x>.py. The headless parts use CLAUDE.md's playwright-core + chromium + .syslibs, never Dennis's desktop.
test_card.py (26). Every fragment test stays as it is. They render through machinepage.render(Repo(tmp, list_path=tmp_list), tm), or through card.card_parts for the purely fragment-level ones.
test_full_card: the same assertions, split across the two sections. The header line, state line, How box, one sentence and folded notes are in#overview. Primary figures, label cells, Q backlinks, checkpoint tags,see Fig 2, "n open" and the six closed folds with their summaries are in#mechanism. The order is Overview, then Mechanism.test_how_proved: the box sits under the state line and above "In one sentence", appears exactly once on the page, and only for KILLED_* or apr. The "card stale" assertion is replaced by a check thatrebuild_pages.sh'schanged()lists a touched how_proved.md (a shell-level test with a temp stamp).test_index/test_index_emptybecome Machines page tests: one row per folder; a broken observe.json becomes a row with the flag "observe.json unreadable"; question objects are counted; the empty tree says "no machine folders".test_cli:card.py <tm>writes only<tm>/index.html(no card.html);--lintstill exits 1 on a missing label field while rendering succeeds;--indexwrites<out>/machines.html; a folder without observe.json builds with exit 0.test_no_js_static,test_links_resolveand the anchor checks (check_anchors: every#link has a target, ids unique) run on the full page, including a killed fixture with Proof & checks (pfig-Nvsfig-N).test_browser_no_js: the same checks on the machine page (images load, no external requests, no console errors, no overflow at 390 px,Fig 4opens "More figures", the zoom view).RealCards: every real folder, all 64 including the 10 without observe.json, renders in memory.
test_dashboard.py (26). Inputs, Guess, Tables, Machines, Submission and Ledger are unchanged (build_records logic).
test_counts_and_thumbs: the Home tiles, with 7 buckets plus untouched; cryptid equals parked-with-CRYPTID_CANDIDATE.test_static_page: Home + Machines without JS. Every folder has a row, the untouched grid is present, and the filter bar is hidden.test_hostile_stringsandRealPage: run on Home + Machines.- The
how-proved.mdheader link assertion (line 483) moves to the Reports test. Browser: the Machines filters (search#3,line 604, a bucket chip, the class menu,#b-parkedon load); Home at 390 px with no overflow; the nav menu collapsed at 390 px.
test_killreview.py (27). Parse, Compare, Markdown and SameMachine are unchanged.
Pages.test_sections: on the fragment fromK.build(), "What to check here:" appears 7 times if derived, else 6 (s5 dropped).id="same"only on derived kills. There is no<scriptin the fragment, no externalsrc, and no absolute links.test_index*become the Machines page family groups: onetbody.famper family, non-killed members named,sub_chipper kill row, the header tally in Home's tiles.test_how_proved_box: on the machine page Overview; a derived kill shows its own file, never the parent's.test_parent_links_same_section: the family table links../<child>/index.html#same.test_automatic_checks: unchanged. W4-3c's coqchk miss is the known data failure.
test_decisions.py (18). Unchanged except:
- Links go to machines/<tm>/index.html (#fig-N for figures).
- The nav is present, with a count equal to unanswered + unreadable decision.json in the fixture tree.
- #tm-<tm> anchors exist and are unique.
New tools/test_site.py.
- Nav: Home, Machines, Submissions, Decisions, Reports and three machine pages each have one
nav.snwith the six links in order,aria-currenton the right one, both link lists identical, and N = the fixture's open decisions (2 open + 1 unreadable + 1 answered → 3). - Names: the precedence table of §2 (rank > label > twin > 740 line > 778 line > off-list) and the alias tooltip. On real data
Names.label == D.member_name == Ctx.namefor every folder. A fixture machine's tag reads the same on all six page types. - Link check: crawl the temp build (and, in integration, the real one). Every relative
href/srcresolves on disk, with?download/?rawstripped and#fragchecked against the target's ids. Nothing links tocard.html,kill.html,kills.htmlormachines/index.html. - Machine page kinds: a kill with observe.json; a delta kill without (Mechanism images are
../<parent>/img/…and exist;#samesits in Proof & checks; the How box is its own); a cryptid (state "Parked", no kill sections, "Calls on file" showsparked); a broken observe.json (the page renders, with a banner). - Submissions: fixture packages in family/, pr/ and legacy discord.md give one card per PR, highest number first, with
discord_dennis.txtpreferred. Download links carry?downloadand resolve. "Not posted" shows whenpostedis missing.#readylists the claimed kills by family. - Reports: the curated order, with the newest overnight report first; titles come from
#headings; no.log/.tsv/ledger links. - Home: the tiles sum to the folder count; needs-you lists the open decisions, the waiting ask, a PR with comments > 0 and an unposted open PR; recent events are sorted newest first, all within 4 days.
- CLI: on temp trees each command of §4 writes exactly its files, and none writes card.html or kill.html.
- Headless, over every page type at 1280 and 390 px, light and dark: no horizontal overflow, an explicit body background, no console errors, no external requests, all images load, and the nav menu opens at 390 px.
8. Build order (file ownership, so steps can run in parallel)
| Step | Owner files | Needs |
|---|---|---|
| A. Shared + machine page | site.py, machinepage.py, card.py (card_parts, prefix), killreview.py (Ctx params, build_parts, ns, links), test_card.py, test_killreview.py, test_site.py §1, 2, 4 | this spec |
| B1. Home + Machines | dashboard.py (render_home, render_machines, kill_facts_html, progress_html, DASH_JS), test_dashboard.py | A |
| B2. Decisions | decisions.py, test_decisions.py | A (site.page, Names) |
| B3. Submissions + Reports | submissions.py, reports.py, test_site.py §5, 6 | A |
| C. Integration | site.build_all + CLI wiring in all four, rebuild_pages.sh, inventory → delete (§6), report link rewrite, READMEs, test_site.py §3, 7, 8 + full suite | B1–B3 |
| D. Headless review | screenshots at 1280/390 (dashboard.py --screens), with up to 2 fix rounds |
C |
Done means: all suites green apart from the known W4-3c data failure; the link check is clean on the real build; the inventory script reports 0 misses; rebuild_pages.sh run twice in a row rebuilds once, then exits as a no-op.