Building the site
Task 1: The skeleton and the registry
The site is an Astro project in site/. This task laid the skeleton and the machine registry that later pages read.
What was built
- The Astro project.
site/package.json,astro.config.mjs(trailingSlash: 'always', stylesheets inlined, the sitemap integration) andtsconfig.json.npm install astro@^7.0.6 @astrojs/sitemap@^3.7.3resolved to Astro 7.3.5 and@astrojs/sitemap3.7.4 (read fromsite/package.jsonafter the install). - The registry.
machines/registry.jsonlists every machine and chip indocs/the-6502-family.md, with a status, a core variant and, for a running machine, the name of the acceptance test that proves it. It is the source of truth from now on. site/src/lib/registry.mjs. Loads the registry, validates it and counts machines by status. A machine is out of scope exactly when no core variant runs it. A machine may only be markedrunningif its acceptance suite is in the test results and passes, with nothing failed or skipped.- A one-off seed script,
site/scripts/seed-registry.mjs, which built the registry from the family document’s tables. It refuses to run again once the registry exists, because a re-run would overwrite the hand-kept fields.
Decisions
- The registry is generated once, then hand-kept, rather than parsed from the family document on every build. Status and acceptance are facts the document does not carry. The cost is two places to edit, so a test reads the document and fails when the two name different machines or chips.
- The repository root comes from the working directory, not the module’s location. The build bundles pages into
dist/, so a path taken from a module’s own location would point into the bundle.
What the tests showed
node --test tests/*.test.mjs in site/:
- Before
registry.mjsexisted, the run failed withERR_MODULE_NOT_FOUNDforsite/src/lib/registry.mjs: 0 passed, 1 failed (the file could not load). - After it and the seed script were written, it ran 7 tests and all 7 passed.
node site/scripts/seed-registry.mjsprinted56 machines and 30 chips written.jq -r '.machines[].status' machines/registry.json | sort | uniq -con the resulting file gave 51 planned and 5 out of scope, and no other status, so none running or in progress.
What surprised
npm run buildwith no pages exits cleanly:0 page(s) built, with a warning from the sitemap integration that no pages were found. So thepretestbuild step passes on an empty site rather than failing.
Task 2: The test results
Every figure on the site has to come from a test run, so this task turns a .NET test run into one file the site reads.
What was built
site/scripts/make-results.mjs. Reads the.trxfilesdotnet testwrites. It keys each result by its test class and counts passed, failed and skipped per suite and in total, with the commit, the run id and the date. A result whose class it cannot find is an error rather than a silent gap. With no arguments it runs the whole .NET suite first; given a folder it only reads it.site/src/lib/results.mjs. Loadssite/src/data/results.jsonand says whether it may be published: it ran something, nothing failed, nothing was skipped, and the Dormann tests ran.site/tests/results.test.mjs. Five tests: parsing, counting, the missing-class error, the publish rules, and a check that the real results file passes them.TestResults/in the root.gitignore. It was not ignored, and the.trxfiles must not be committed.site/src/data/results.jsonwas already ignored bysite/.gitignore, so it is not committed either: it is made from a run and rebuilt each time.
Decisions
- Skipped counts as not publishable, not as a soft pass. The Dormann tests skip rather than fail where their assembler cannot run, so a run that skipped them would otherwise look clean. The publish rule therefore refuses any skip and also requires at least one Dormann test to have passed.
- The results file is never committed. A committed copy could be stale the moment code changed, and the site would keep quoting it. The cost is that a fresh checkout has to run the suite (or take the CI artifact) before the site tests that read it can pass.
What the tests showed
- Before the two library files existed,
node --test tests/results.test.mjsinsite/failed withERR_MODULE_NOT_FOUNDforsite/scripts/make-results.mjs: 0 passed, 1 failed (the file could not load). - With both files written but no results file yet, the same command ran 5 tests: 4 passed and 1 failed, and the one failure was
the real results are publishable, with the message thatresults.jsonis missing. dotnet test --configuration Release --logger "trx;LogFileName=results.trx" --results-directory TestResultsat the repository root ended withPassed! - Failed: 0, Passed: 1480, Skipped: 0, Total: 1480, Duration: 1 m 9 s. Timed withtime, the whole command took 1 m 26 s.node scripts/make-results.mjs ../TestResultsinsite/printed1480 passed, 0 failed, 0 skipped, in 13 suites. It read that run’s.trxfile rather than running the suite again.npm run resultswith no argument runs the suite itself, so running it after thedotnet testabove would have done the work twice.node --test tests/*.test.mjsinsite/then ran 12 tests (5 new, 7 from task 1) and all 12 passed.
What surprised
- The results file has 254 passed for
Wdc65C02Harteand 256 for each of the other four Harte variants. That is not a gap: the WDC variant leaves out$CB(WAI) and$DB(STP), whose Harte files are empty, andWaitAndStopTestscovers them instead.docs/journal/2026-09-30-building-the-core.mdrecords the same 254 and the reason. The result is that the per-suite Harte counts on the site cannot be summed as 4 x 256 and must be read from the file. npm run resultsand the plan’s step 4 overlap: the script runsdotnet testitself when given no folder, so the plan’s two commands in a row run the suite twice.
Task 3: The speed measurements
The Status page will print speeds, and rule 5 says a speed comes from a measurement rather than from prose. So the speeds are one dated file, written by a script and read by the site.
What was built
bench/collect-measurements.mjs. Builds and runs the native speed tool, publishes the browser tool twice (interpreter, and ahead of time), runs each in Chrome and writessite/src/data/measurements.json. It reads only themeasuredlines, never the warm-up, and throws unless every mode has exactly the requested number of measured runs. It records the date, the machine, the browser and .NET versions, and a plain description of the workload.site/src/lib/measurements.mjs. Loads the file and gives the best run of a mode in MHz.site/tests/measurements.test.mjs. Three tests: only measured runs are read, the browser and machine are described in words, and the best run is the highest.site/src/data/measurements.json. Committed, unlikeresults.json. A speed is a record of one machine on one day, and the file says so; it is not a claim that stays true as the code changes.
How the file was made
The file was not produced in this task. bench/collect-measurements.mjs was run earlier the same day in a scratch copy of the repository, on this machine, and the output was copied in. Its collected field says 2026-09-30. The collector was not run again here because it takes about ten minutes and the copy is from the same script and the same day. The script in this repository was written from the plan and checked only with node --check, which reported no error. Its live path (the builds, the browser runs) has not been run from this checkout.
What the tests showed
- Before
bench/collect-measurements.mjsexisted,node --test tests/measurements.test.mjsinsite/failed withERR_MODULE_NOT_FOUNDfor that file: 0 passed, 1 failed (the file could not load). - With the three files written,
node --test tests/*.test.mjsinsite/ran 15 tests (3 new, 12 from tasks 1 and 2) and all 15 passed.
What the measurements say
Read from site/src/data/measurements.json with a short node -e script. The machine is a KVM virtual machine, DO-Premium-AMD, 8 cores, with Chrome 153.0.8010.47 and .NET 10.0.400. Each mode has 5 measured runs of 100000001 cycles.
| Mode | Best run | Worst run |
|---|---|---|
| Native | 107.986 MHz | 103.172 MHz |
| Browser, ahead of time | 48.85 MHz | 29.137 MHz |
| Browser, interpreter | 4.965 MHz | 1.948 MHz |
- The design target was 25 times a 2 MHz machine, which is 50 MHz. (Corrected in task 8: the design says at least 25 times real speed for the core alone; the 2 MHz reference is this site’s own.) The best ahead-of-time run is 48.85 MHz, which is 24.4 times (48.85 / 2 = 24.425). That is below the target, by a small margin. It is not rounded up. Native clears it easily: its best run is 107.986 MHz, 54.0 times.
- The ahead-of-time runs are not steady: 47.125, 48.85, 42.357, 29.137 and 38.027 MHz, in run order. The best is the top of a wide spread, and the worst run is 29.137 MHz (14.6 times). Any page that quotes one figure should say it is the best of five and show the spread.
- The interpreter runs climb from 1.948 MHz to 4.965 MHz over the five runs (best 2.5 times). The first run, 1.948 MHz, is under 40 percent of the best, 4.965 MHz (1.948 / 4.965 = 0.392). That fits a JIT-less interpreter warming caches, but the file does not say why, so this is a guess and not a finding.
What surprised
- Ahead-of-time is 24.4 times, not 25 or more. The target was set before this measurement existed, so the site cannot claim it is met.
- The spread inside one mode, on one idle-as-far-as-we-know virtual machine, is larger than the gap between the best ahead-of-time run and the target.
Two collection runs on the same day
There are two collections of the same workload on the same machine on 30 September 2026, and they do not agree about the target. The first is in 2026-09-30-the-browser-speed-check.md: its ahead-of-time runs reached 25 times a 2 MHz machine on two of five runs, best 25.48 times (50.968 MHz). The second is site/src/data/measurements.json, read above: its best ahead-of-time run is 48.85 MHz, 24.4 times, and all five runs are under the target. Both are honest records, and together they show that the run-to-run spread straddles the target: the same build lands above or below 25 times depending on which collection is read, so “reached” and “not yet met” are each true of one collection and not of the other. The older entry is a dated record and was not edited. The site uses the dated file, says so beside its figures (“this collection of 5 runs”), and does not claim more than that collection shows. status.astro reads the number of runs from the file, so the wording follows the data.
Task 4: The figures
Every number a page shows now comes through one module. A page that needs a count or a speed takes it from there, and never types it.
What was built
site/src/lib/figures.mjs. Computes every figure from three inputs: the registry, the test results and the dated measurements. It counts machines by status, sums the Harte cases over the suites whose name ends inHarte, and gives each mode’s best speed as a multiple of a 2 MHz machine.fmtandfmt1format numbers the British way. The only typed numbers are two constants that belong to the design: the speed target (25) and the reference clock (2 MHz). (Corrected in task 8: the design says at least 25 times real speed for the core alone; the 2 MHz reference is this site’s own.)site/src/lib/data.mjs. Loads the registry, the results and the measurements once, refuses to build if the registry or the results fail their checks, and exports the computed figures asfig.site/tests/figures.test.mjs. Four tests: the figures from made-up inputs, the number formats, that the real figures can be computed, and that the real registry is valid against the real results.
Decisions
- The Harte figure is a sum over the suites, not a typed 4 x 256. The variant count is the number of suites whose name ends in
Harte, so a sixth variant would change both figures without an edit here. The alternative was to list the variants by name, which would have been a second place to keep in step. data.mjsthrows rather than warns. A page built from figures the checks reject would publish a claim nobody can stand behind, so the build stops.
What the tests showed
- Before
figures.mjsexisted,node --test tests/figures.test.mjsinsite/failed withERR_MODULE_NOT_FOUNDforsite/src/lib/figures.mjs: 0 passed, 1 failed (the file could not load). - With the three files written,
node --test tests/*.test.mjsinsite/ran 19 tests (4 new, 15 from tasks 1 to 3) and all 19 passed. node -e "import('./src/lib/data.mjs').then(m=>console.log(JSON.stringify(m.fig)))"insite/printed the real figures. Machines implemented 0, in scope 51, in progress 0. Variants 5. Tests passing 1480. Harte tests 1278. Dormann builds 12. Interrupt runs 150. Speed as a multiple of 2 MHz: 24.425 ahead of time, 2.4825 interpreter, 53.993 native.
What surprised
- The Harte figure is 1278, not 1280, and the reason is the one recorded under task 2: four variants have 256 passing cases and the WDC variant has 254 (1278 = 4 x 256 + 254). Summing from the file gets it right where a typed 5 x 256 would not.
- The results file in this working tree was made from a run at commit
a8de702, read from thecommitfield ofsite/src/data/results.json, which is earlier than the current commit. The task 4 changes touch no .NET code, so the counts are unchanged, but a published site must be built from a results file made at the commit it ships.
Task 5: The shell
The look, the page layout, the analytics and consent files, and the first two pages (About and the 404 page). Every later page is built inside this layout.
What was built
- The look.
site/src/styles/tokens.cssholds every colour, font and radius as a named token, andglobal.cssuses them by name. It is the phosphor terminal decided on 30 September: black canvas, pale-green text, one lime accent. - The layout and its parts.
site/src/layouts/Base.astrowith a header, a footer that carries the Capcom non-affiliation line word for word, aCardfor figures and a native<dialog>for the consent choice. - Analytics, consent-gated.
site/public/analytics.jssets all four Consent Mode v2 signals to denied and loads the Google tag only from inside__dbhqEnableGA(), which runs only on the live host and only after a visitor accepts.consent.jsowns the prompt. Both are same-origin files because the Content-Security-Policy insite/public/_headershas nounsafe-inlinefor scripts. The measurement ID is the estate’s one, not a new data stream. machines-table.js,robots.txt,favicon.svg. The first adds sortable headers and filters to the tables that arrive in later tasks.- Two pages.
about.astroand404.astro. - Four test files, and a helper.
site.test.mjs(rules for every built page),analytics.test.mjs,design.test.mjs(contrast ratios read off the tokens) andhtml.test.mjs(every built page throughhtml-validate). - New packages.
@fontsource-variable/inter,@fontsource-variable/inter-tightand@fontsource/fira-mono(self-hosted fonts, so the CSP needs no font host) and, as a dev dependency,html-validate. The versions insite/package.jsonafter the install are^5.3.0for the three fonts and^11.16.1forhtml-validate.
Decisions
- The tests were written before the code they test. The site tests import
site/src/lib/site.mjs, which did not exist, so they could fail first. - Nothing is measured off the live host. The tag loads only when
location.hostnameis6502.dbhq.uk, so a local preview or a Pages branch build never calls Google. The deploy stays switched off; nothing here touches Cloudflare or the estate’s analytics settings. - Decline comes first, and is the same size as Accept. A test holds the order.
What the tests showed
- Before the code existed,
npm testinsite/ran 23 tests: 20 passed, and 3 test files failed to load (site.test.mjswithERR_MODULE_NOT_FOUNDforsite/src/lib/site.mjs, andanalytics.test.mjsanddesign.test.mjsbecause their inputs did not exist yet). - With every file written from the brief,
npm testinsite/built the site and ran 43 tests, and all 43 passed: the 19 from tasks 1 to 4, and 24 new (11 insite.test.mjs, 6 inanalytics.test.mjs, 6 indesign.test.mjs, 1 inhtml.test.mjs). find dist -name '*.html'insite/lists two built pages,dist/404.htmlanddist/about/index.html. The rules above are therefore checked against two pages so far, and get stronger as pages arrive.- No file was changed from the brief.
What surprised
- Before any page existed, the build still succeeded and the HTML validity test passed, because it had no pages to check. A rule that runs over “every built page” says nothing about a site with none, which is why
site.test.mjshas a test that fails when no page was built. npm installprinted a warning thatesbuild@0.28.2has an install script not yet covered byallowScripts. The install and build worked without it, and nothing was approved.
Task 5 review: what it found and what changed
The review agreed the code matched the plan. It then found three defects that the plan itself had mandated, and four smaller ones. All are fixed in one follow-up commit.
What the review found, and what was changed
- Contrast tests said more than they checked. The test titled “text meets AA on the card, window and nav surfaces it is actually set on” only checked three colours. A throwaway node script that computes WCAG contrast from the tokens showed the real failures:
--sage-40on--ironis 3.97 to 1 (used for the consent note and the terminal window’s title), and--deepon the navigation bar is 3.88 to 1 once the 85 percent translucency over black is counted (3.62 on--veilalone). Both are under 4.5. The fix changes the use, not the palette: the consent note, the window title and the brand’s “by dbhq” now use--fern(5.82 on iron, 5.44 on the blended nav bar, same script).--sage-40and--deepare now used only on the black canvas, where they pass (4.69 and 4.91). The--deepcomment intokens.csssaid it “holds”, which was true only on black, and now says so. - The design test now covers every colour rule. It reads every rule in
global.cssthat sets a text colour, looks up the surface it sits on in a table, and computes the ratio from the real tokens, with the nav bar’s colour computed from its ownrgba. A new colour rule with no entry in the table fails the suite, and so does a table entry for a rule that no longer exists. The nav bar’s blend is over black; scrolled over lighter content it can differ, and the test comment says so. - No navigation below 900px.
.linkswasdisplay: noneunder 900px with nothing to replace it, so a phone had no way to reach any page. The header now wraps: the links drop to a second row under the brand, with no script, and the GitHub button stays. A test fails if adisplay: nonereturns on the links, the brand or the header, at any width. - No way to withdraw consent. Once a reader answered, the prompt never came back. The footer now has a
Cookie choicebutton (a real<button>).consent.jswires it (the CSP allows no inline handler): it removes thedbhq-consentkey, calls a new__dbhqRevokeGA()inanalytics.jsthat puts all four Consent Mode signals back to denied, and reopens the dialog withshowModal(). - A bug found while writing that. Accepting again in the same page load would have done nothing, because
__dbhqEnableGA()returned early once the tag had loaded, so consent would stay denied. It now sends the granted update first when the tag is already there. A test holds that order. - Enter granted consent. Accept had
autofocus, so a bare Enter on opening the prompt accepted. Initial focus is on the dialog heading now (tabindex="-1",autofocus), and Decline still comes first. - A comment that was untrue.
consent.jssaid Escape returns the prompt “next visit”. It returns on the next page load, since nothing is stored. The comment says that. - Internal notes were being served. The HTML comments in
Consent.astroandBase.astrowent out in every page, andanalytics.jsis copied as it is and named another project, the analytics property and a path in another repository. The comments are now frontmatter comments, which Astro does not emit, and theanalytics.jsheader describes what the file does without those names. Tests fail on any HTML comment in built output and on those names inanalytics.js. - The
_headerscomment. It calledstyle-src 'unsafe-inline'“the only other relaxation”, butimg-srcalso allowsdata:. Both are named now. - The 404 page. The analytics and consent-prompt checks skipped it. They now cover it, because it is built from the same layout and loads the same scripts.
What the tests showed
npm testinsite/ran 53 tests and all 53 passed: the 43 from before plus 10 new.- With the source under
src/andpublic/put back to the first version and the new tests kept, the same command ran 53 tests with 43 passing and 10 failing, one for each finding above. The tests fail without the fixes. grep -c "Modal\|DESIGN.md" dist/about/index.htmlprinted 0, so the design notes in the stylesheet’s comments are not in the served page either.
What it says about the plan
The plan mandated the contrast wording, the hidden navigation, the missing withdrawal control and Accept-first focus. Each was a defect in its own right, not a slip in copying the plan.
Not done, on purpose: withdrawing stops measurement and cookie writing, but does not delete a _ga cookie already set. Deleting one on the shared parent domain is a decision about the whole estate, so it is left alone.
Task 6: The journal
Every entry in docs/journal/ is now a page at /journal/<name>/, and /journal/ lists them, newest first. The site reads the repository’s own folder at build time, so writing an entry is the same act as publishing it.
What was built
- Front matter on the five older entries. Each got a title, a date, a summary and an
order(the position within a day), and nothing else in them changed. The entry for this site already had front matter from task 1. - A content collection.
site/src/content.config.tsreads../docs/journal/2*.mdand checks each entry’s front matter against a schema. - Two pages.
journal/index.astro(the list) andjournal/[id].astro(one entry). - A small markdown plugin.
site/src/lib/rehype-journal.mjsdrops each entry’s first level-one heading, because the page prints the title from the front matter, and rewrites a relative link such as../superpowers/specs/x.mdto the same file on GitHub. Both are needed: the entries are written for the repository, where those links work, and on the site they would be dead. journal.test.mjs. Four tests: front matter is complete and the date matches the file name; every entry is built and listed; no relative repository link survives; each entry has exactly oneh1.- A package.
@astrojs/markdown-remark, added tosite/package.jsonas a dependency (^7.3.1).
A separate fix, found by the task 5 re-review
Consent.astro told readers “The choice is remembered across dbhq.uk and its sites, so this is asked once”, and comments in it and in analytics.js said the dbhq-consent key is shared across *.dbhq.uk. That is untrue: localStorage belongs to one origin, so 6502.dbhq.uk cannot read what a reader chose on dbhq.uk. The reader is asked once on each site. The copy now says “remembered in this browser, for this site only”, and both comments say why. No test asserted the old wording. It went in its own commit (71f08b1) before this task’s. The plan document docs/superpowers/plans/2026-09-30-site-v1.md still carries the old wording, because it is a record of what was planned; it is not served.
What the tests showed
- With the four new tests and no front matter, no plugin and no pages,
npm testinsite/ran 57 tests: 55 passed and 2 failed. The front matter test failed on2026-09-29-deciding-what-to-build.md has no title, and the built-pages test failed because/journal/did not exist. The other two new tests passed because they loop over journal pages, and with none they check nothing. That is the same emptiness as task 5 found, and it is why the built-pages test is the one that carries the weight. - With everything written from the brief,
npm testinsite/ran 57 tests and all 57 passed: the 53 from before plus the 4 new. The brief’s count of 47 was made before task 5’s review added 10 tests and includes these 4, so the same arithmetic gives 57. find dist -name '*.html' | wc -linsite/printed 9: the 404 page, About, the journal list and six entries.grep -o "<h1[^>]*>[^<]*" dist/journal/2026-09-30-building-the-core/index.htmlprinted one heading,Building the core, from the front matter.grep -o 'href="https://github.com/dbhq-uk/6502/blob[^"]*"' dist/journal/*/index.htmlshows the rewritten links, for exampledocs/the-6502-family.md#who-made-itinchecking-the-ground.
What surprised
npm install @astrojs/markdown-remarkprinted the sameesbuildinstall-script warning as before. Nothing was approved.- The build printed no warnings.
Task 7: The machines, the chips and the family
Three pages now answer “what ran on a 6502 and what does the core run”: /machines/, /chips/ and /family/. Each is generated from a file the repository already holds, so none carries a count typed by hand.
What was built
/machines/. One row for every machine inmachines/registry.json, oldest first, with three filters (status, category, core) and a count line. It is a complete table with no script:machines-table.js, written in task 5, only adds sortable headers and makes the filters work. Without it, the filters do nothing and the table still reads in full. A machine links to its own page only when its status isrunning./machines/[id]/. One page for each running machine, showing the acceptance suite’s pass count fromresults.json. The registry has no running machine, so this route builds no page yet./chips/. One row for every chip in the registry, with sortable headers and a count line./family/. The repository’s owndocs/the-6502-family.md, rendered as it is. It is a second collection,docs, insite/src/content.config.ts, which also listsknown-differences.md. Nothing was copied, so the page cannot drift from the document. The rehype plugin from task 6 drops the document’s own first heading, so the page has oneh1.machines.test.mjs. Five tests: a row for every machine, a row for every chip, links only for running machines, the sort and filter hooks and the script tag, and the family page holding a table and its opening sentence.
What was different from the brief
site/src/content.config.ts already existed, because task 6 created it for the journal. This task added the docs collection to it rather than creating the file, and the journal collection is unchanged.
What the tests showed
- With
machines.test.mjswritten and no page,npm testinsite/ran 62 tests: 57 passed and 5 failed. All five failed withCannot read properties of undefined (reading 'html'), becausepage('/machines/')and the others found no built page. - With the pages written, the same command ran 62 tests and all 62 passed: the 57 from before plus the 5 new.
find dist -name '*.html' | wc -linsite/printed 12: the 9 from task 6 plus machines, chips and family.- Counting in the built HTML:
grep -o '<tr data-status' dist/machines/index.html | wc -lprinted 56, andgrep -o '<tr>' dist/chips/index.html | wc -lprinted 31, which is 30 chip rows and the header row. Anode -eovermachines/registry.jsonprinted 56 machines, 30 chips and 0 running, so the tables and the registry agree. npm run buildprinted no warnings or errors.
What surprised
- The new pages needed no new CSS.
.filters,.tablewrap,table.data,.statusand.countwere already inglobal.cssfrom task 5, so the contrast test, which fails on a colour rule with no surface, had nothing to place and did not change.
Task 8: The home page, the status page and the imagery
The site now has a front door and a page that shows the state of the work. Both read every figure from results.json, measurements.json and the registry, and the three generated images are in.
What was built
/(home). A headline, the hero image with its “Illustration” caption, four figure cards, a terminal-style card of test results, the first three machines from the registry and the three newest journal entries./status/. Six figure cards, the test suites in a table, the speed table with the design target beside it, andknown-differences.mdrendered underneath.- Three images.
hero-die,waveformandtraceswere converted from the PNG originals withcwebp(quality 88, 90 and 82).ls -lshowed 322,732, 17,098 and 94,902 bytes against originals of 2,055,376, 421,970 and 1,096,527. Only the WebP files are committed. The originals stay outside the repository, in a local previews folder. imagery.cssholds the two background images as custom properties, andBase.astroimports it beforeglobal.css.Base.astroalready existed from task 5, so this task added one import line to it rather than creating it.DESIGN.mdrecords the fork of the brand, the imagery prompts, model, quality and cost. The cost and model were checked against the imager tool’s run history with a short Python script: the four low-quality drafts billed $0.017955 in all and the three finals $0.147563, which are the $0.018 and $0.148 written there. The model isgpt-image-2.5-flareon every row. The brief and the history agree. Two lines in the brief’s text pointed at files that do not exist (tests/pages.test.mjsand a site README), so they now saytests/site.test.mjsand “the figures and the content”.figures-on-pages.test.mjs. Three tests: every number on both pages is a generated figure or a named literal, both pages name the commit their results came from, and the home page shows the machines count.- The internal-link test moved into
site.test.mjs, as the brief says: everyhrefandsrcthat starts with/must resolve to a built file.
What the tests showed
- With
figures-on-pages.test.mjswritten and no pages,node --test tests/figures-on-pages.test.mjsinsite/ran 3 tests and all 3 failed withCannot read properties of undefined (reading 'html'), becausepage('/')andpage('/status/')found nothing. - With everything in place,
npm testinsite/ran 66 tests and all 66 passed: the 62 before this task, plus the 3 infigures-on-pages.test.mjsand the 1 link test. The brief expected 56 from 52 with the same 4 tests, and 66 is that plus the 10 that the task 5 review added and the 5 that the task 6 review added. npm run buildprinted14 page(s) built: the 12 from task 7, the home page and the status page.- To check the tests catch a wrong multiple,
{fmt1(fig.speedAot)} timeson the home page was replaced with a typed25.0 timesandnpm testfailed with/ shows numbers that are not generated figures: 25.0. The change was reverted and the suite went back to 66 passing. The guard has a limit: a typed25or a typed24.4would pass, because both are values the test allows. It catches a number that is not in the data, not one that is in the data and in the wrong place.
The speed target is not met, and the pages do not say it is
The design target is 25 times a 2 MHz machine. (Corrected in task 8: the design says at least 25 times real speed for the core alone; the 2 MHz reference is this site’s own.) The best ahead-of-time run in measurements.json is 48.85 MHz, which is 24.4 times, so the target is not met. The home page shows 24.4 times and says nothing about the target. The status page says “The design’s target is 25 times” and, in the table below it, shows 24.4 for the ahead-of-time build. The brief’s wording was read against that and left as it is: it states the target and the measurement, and claims neither that the target is met nor that 24.4 is 25. fmt1 gives 24.4 from 24.425, which rounds down.
Two defects and one check
- A missing space on the status page. The brief’s template put a line break between
{longDate(...)}andon, and Astro dropped the whitespace, so the built page read “Measured on 30 September 2026on a KVM virtual machine”.grep -o "2026.\{0,8\}on a KVM" dist/status/index.htmlshowed2026on a KVM. An explicit{' '}after the date fixed it, and the same grep then showed2026 on a KVM. - The waveform lowered text contrast on narrow screens. The design test only reads colour rules, so it cannot see text over an image. To check, a Playwright script loaded the built home page at five widths, made all text in the section transparent, took a full-page screenshot and measured the brightest background pixel under every text box. At 901, 1280 and 1920 pixels the brightest pixel was black, so body text (
--sage-60) measured 8.29 to 1. Below the 900 pixel breakpoint the two columns stack, the text runs across the whole width and the waveform lines sit under it: at 390 pixels the worst background had luminance 0.1473 and--sage-60measured 2.10 to 1, and at 768 pixels 1.73 to 1. That fails 4.5 to 1.global.cssnow sets.proof::beforeto opacity 0.18 below 900 pixels. Measured again the same way,--sage-60is 5.89 to 1 at 390 pixels and 5.55 to 1 at 768, and the heading and the eyebrow are higher. Desktop is unchanged at opacity 0.5. - The traces texture. It sits under a 0.55 black overlay, which the CSS does have (
.tex::before). The same script measured the worst background at 0.0133 luminance at 1280 pixels, and--sage-60is 6.54 to 1 on it, the eyebrow colour 8.12 and the link colour--fern5.44. The only text in.noteon that section is a link in--fern.--sage-40would measure 3.71 there and fail, so plain.notetext must not be placed over that texture. The other text in the section sits on opaque cards. - The hero image carries no text. The headline is above it and the caption below it, on the black canvas, so the image cannot lower any text contrast.
No entry was added to the contrast table in design.test.mjs and nothing in it was weakened: .hero, .proof and .tex set no text colour, so the test had nothing new to place.
What surprised
- The results the pages show are from commit
a8de702, which is the commitresults.jsonnames, not the head of the branch.results.jsonis git-ignored and was generated at 15:37 today. It is a true statement of where the figures came from. - The home page says “0 of 51” machines implemented and links “All 56 machines in the family”. Both come from the registry: 51 are in scope and 56 are listed.
Task 8 review: what it found and what changed
The review agreed the code matched the brief and then found seven places where the pages, or the tests that guard them, said more than they could back up. Each was fixed in one follow-up commit.
- The target had no subject. The status page said “The design’s target is 25 times” and then showed 54.0, 2.5 and 24.4 in a table, so a reader could not tell what the target applied to or whether anything met it. The design (
docs/superpowers/specs/2026-09-29-6502-design.md, the speed item in the core’s test list) says the target is “at least 25 times real speed for the core alone”, and does not say which build. The page now says exactly that, then gives the verdict for the native run (54.0 times: met) and for the browser build compiled ahead of time (24.4 times: not yet met). The verdict comes fromtargetVerdictinsite/src/lib/figures.mjs, so no page types “met”. The home page card says the same in one line: “met natively, not yet met in the browser”. The design, and the wording of it, are unchanged. - The figures guard was weaker than its claim. It stripped the Speed paragraph, every note and the terminal card before checking numbers, allowed digits such as 0 and 2 from several unrelated sources, and carried a
const status = url === '/status/' ? html : html;that did nothing. It now checks all the page text, including the terminal card and the notes, against a set built from the data only; it decodes'first, because the apostrophe in “NES’s” had read as the number 39 once the stripping stopped. The one document that is rendered whole on the status page is still exempt. New tests: each headline card equals the expected string computed from the figures; each row of the speed table equalsfmt1of the best run and of best divided by 2; the number 25 appears exactly once per page, next to a “met” or “not yet met”; and when the browser build is under the target, the pages must say “not yet met” and no sentence near “target” may say reached, exceeds, achieved or hit. - The hero image was lazy. It is above the fold, so it is now
loading="eager"withfetchpriority="high", and a test holds both. - The home page read as though machines exist. It showed “0 of 51” beside “Each runs in the browser and passes an automated test”. The card now says “A machine counts only once it runs in the browser and passes an automated test.”, the machines heading is “The machines this core is for”, and the hero says “the machines it will run”.
- The card claimed a test cadence the page could not verify. It said the tests run on each push.
.github/workflows/validate.ymlrunsdotnet teston pushes tomainand on pull requests, not on each push, and the commit on the pages is not the branch head. The card now says “From the latest test run, on the commit shown below”, and a test fails any page that makes the cadence claim. - The background images had no caption.
waveformandtracesare CSS backgrounds, so the existing caption test, which looks at<img>tags, could not see them. The home page now carries “Illustration: the textured backgrounds behind the proof and machines sections are generated, not measurements”. A new test readsimagery.css, finds the class that uses each image inglobal.css, and fails any built page that uses that class without such a caption. DESIGN.mdsaid the PNG originals were “1 to 2 MB each”.ls -lon the three showed 421,970, 1,096,527 and 2,055,376 bytes, so it now says “0.4 to 2 MB”.
npm test in site/ ran 75 tests and all 75 passed: the 66 before, plus 4 new tests in figures-on-pages.test.mjs, 1 in figures.test.mjs and 4 in the new honest-pages.test.mjs. Three mutations were tried and each failed the suite as it should: deleting the caption line, adding “it has reached the target” to the status page, and removing the eager attributes from the hero. Each was reverted.
Task 8 re-review: the 2 MHz reference is the site’s, not the design’s
The re-review found the target fix only partly done. The status page said “The design’s target is 25 times a 2 MHz machine”, and the home card put the target beside “A 2 MHz machine”. The design (docs/superpowers/specs/2026-09-29-6502-design.md, the speed item) says only “at least 25 times real speed for the core alone”. The 2 MHz is this site’s own reference, the BBC Micro’s clock, and the met and not yet met verdicts depend on it, so the pages had put a choice of the site’s into the design’s mouth.
- The wording. The status page now quotes the design as it words it: “at least 25 times real speed, for the core alone”, and says the design does not say which build. It then says the site measures against a 2 MHz machine, the BBC Micro’s clock, gives the two verdicts against that reference, and says plainly that the reference is the site’s choice and the verdicts assume it. The clocks come from the repository, not from memory:
grep -n "MHz" machines/registry.jsonanddocs/the-6502-family.mdboth give the KIM-1 as6502, 1 MHzand the BBC Micro as6502, 2 MHz. The page reads the KIM-1’s clock out of the registry, so it says the KIM-1 runs at 1 MHz and every multiple against it would be 2 times as large, and neither number is typed. The home card title is now “Against a 2 MHz machine, in the browser”, and its text quotes the design first and gives the verdicts in a separate sentence that names the site’s reference. - The tests. A new test in
figures-on-pages.test.mjssplits each page into sentences and fails if any sentence holds both “target” and “MHz”, and requires the sentence with “target is” to say “at least”, “real speed” and “for the core alone”. The once-per-page 25 rule, the verdict test and the exact-value tests were unchanged and pass. - A mistake of mine, found by the suite. The fix-round journal text above quoted the banned phrase for the test cadence, and the built journal page then failed the very test that bans it.
npm testhad been run before that journal section was written, so the commit before this one was pushed with that test failing. The quote is reworded, and this commit is green.
Task 9: CI, deploy and the docs
The site now has checks in CI and a deploy workflow that is switched off. Nothing has been deployed.
What was built
validate.yml. Thetestjob now writes a TRX file, turns it intoresults.jsonwithsite/scripts/make-results.mjs, and uploads it as an artifact. A newsitejob downloads that artifact intosite/src/data, runsnpm ciandnpm test. The site’s figures are read fromresults.jsonwith the working directorysite, and the artifact puts the file atsite/src/data/results.json, so the “real results are publishable” test finds it where it looks.deploy-site.yml. On a push tomainthat touches the site, the docs, the registry, the core or the tests, it runs the whole test suite, builds the site, deploys to Cloudflare Pages, purges the cache and checks that every built page, the machine-readable files and the edge headers are serving. It runs only when the repository variableSITE_DEPLOYistrue. That variable is not set, no secret has been created and nothing outside the repository has been touched.site/tests/build.test.mjs. Three tests: the eight pages the brief names were built, a sitemap and arobots.txtexist and the sitemap holds no 404, and a third that the brief did not have (below).site/README.md. How to run the site, where each part of it comes from, how to add a machine, what each test file holds, and how analytics and Search Console are set up.
Choices and changes from the brief
- Node 22.22, not 22.
site/node_modules/html-validate/package.jsonsays"node": "^22.22.0 || >= 24.8.0"andsite/node_modules/astro/package.jsonsays">=22.12.0". The brief’snode-version: '22'would satisfy both only if the runner resolved it to a recent 22.actions/setup-nodeuses a Node already in the runner’s tool cache when one matches the range, and a cached 22.x older than 22.22 would match22. So the workflows say22.22, which a cached 22.21 does not match.site/package.jsonhas noenginesfield, so nothing else states the floor. - The test-count floor is 79, not 51. The brief’s own comment says to raise it when the suite grows. 51 would let 28 tests disappear unseen.
- A sitemap test derived from the build. The brief’s sitemap test names one page by hand and looks for no 404. It would pass with a sitemap missing every other page. The new test reads the built pages and the sitemap and requires the two lists to be equal, with the 404 excluded, and fails if fewer than eight pages were found so that an empty build cannot pass it.
- The README’s table of checks was rewritten. The brief’s table named
pages.test.mjsandcontent.test.mjs, which do not exist on this branch. The table now lists the thirteen test files that do, each with what it holds. - Actions. Every action is pinned by commit SHA. The three the brief adds (
setup-node,upload-artifact,download-artifact) were each looked up withgh api repos/actions/<name>/commits/<sha>, which found the commit, andgit/matching-refs/tags, which showed the tags v4.4.0, v4.6.2 and v4.3.0 pointing at them. The three already invalidate.ymlwere checked the same way.
What the checks showed
- Before the sitemap test could pass,
node --test tests/build.test.mjsrun from an empty directory failed withENOENT: no such file or directory, scandiron adistfolder inside that empty directory, so the tests do read the build. With the real build, all 3 passed. - To check the new test is not vacuous, the
aboutentry was deleted fromdist/sitemap-0.xmlandnode --test tests/build.test.mjsfailed the sitemap test with a deep-equal assertion (2 passed, 1 failed). The file was restored and all 3 passed. python3 -c "import yaml; ..."parsed both workflows:validate.ymlhas the jobstest,siteanddashes, anddeploy-site.ymlhasdeploy, withif: vars.SITE_DEPLOY == 'true'.actionlint1.7.12, downloaded to/tmpfor the check and not added to the repository, printed nothing on both files, with exit code 0.shellcheckis installed here, so actionlint checked the shell in therunsteps too.- The deploy workflow reads the pass and fail counts out of the log. The log came from Node 24 on this machine, which prints
ℹ pass 79, and Node 22 in CI prints# pass 79when the output is piped. The twogreplines in the workflow were run against a captured log in each format, underLC_ALL=CandLC_ALL=C.UTF-8, and read 79 and 0 in all four cases. The page list the deploy checks is derived fromdist:findgave 13 addresses, the same 13 that are in the sitemap. npm testinsite/ran 79 tests and all 79 passed. The brief expected 58 from 56, and after the reviews of the earlier tasks the suite was at 76, so 78 was expected. The extra one is the derived sitemap test.
Things that cannot be checked here
- Neither workflow has run.
validate.yml’s new jobs run for the first time on the pull request, and the deploy workflow is skipped untilSITE_DEPLOYis set. The Cloudflare project, the secrets and the steps that follow the deploy have never been exercised. npx wranglerin the deploy step was not pinned when this was first written:site/package.jsondid not listwrangler, sonpxwould have fetched whichever version was latest on the day. That was the brief’s design, and the review below changed it.
Task 9 review: what it found and what changed
The review found two defects that came from the brief itself, and five smaller ones. All were fixed in one commit.
- A manual run could publish any branch.
deploy-site.ymlhasworkflow_dispatch, which can be started from any branch, and the upload is labelled--branch main. So a run from a feature branch would have put that branch’s build on the live site. The deploy job now hasif: vars.SITE_DEPLOY == 'true' && github.ref == 'refs/heads/main', and the comment above it says so. - The deploy step fetched wrangler fresh, holding the Cloudflare token.
wrangleris now an exact-version dev dependency (4.145.0, fromnpm view wrangler version), sosite/package-lock.jsoncarries its integrity hash, and the step runsnpm exec --no -- wrangler, which will not download anything. Comparing the old and new lock files with a short Node script showed no existing package changed version, 83 were added, and every entry has an integrity hash. In a clean directory holding onlypackage.jsonand the lock file,npm cifollowed bynpx --no-install wrangler --versionandnpm exec --no -- wrangler --versioneach printed4.145.0. npm printed warnings that it had not run the install scripts ofesbuildandworkerd; the version command worked without them, and whether a deploy needs them is not known until one runs. Nowranglercommand that talks to Cloudflare was run..github/dependabot.ymlhas a newnpmentry for/sitewith the same weekly Monday schedule, labels style and grouping as the other two. persist-credentials: falseis on all four checkout steps, three invalidate.ymland one indeploy-site.yml. Nothing after a checkout pushes or fetches: the only git use isgit rev-parseandgit grepon the local copy, which need no credentials.- The deploy path filter now includes
6502.slnx,Directory.Build.props,bench/**andtools/**, because they change what the tests run and so the figures. - The verification curls in the deploy workflow now have
--max-time 20 --retry 3 --retry-delay 5, so one slow response fails one attempt and not the run. build.test.mjs. The second test was called “a real 404 page” and only checked that a file existed. It now checks that404.htmlholds the heading “Page not found”, and the title says “the 404 page”. Thesitemap.includes('404')line is gone, because the derived test already fails if a 404 address is in the sitemap and not in the built list.
After the changes npm test in site/ ran 79 tests and all 79 passed, with 14 page(s) built. Both workflows and dependabot.yml parse with PyYAML and actionlint 1.7.12 printed nothing, exit code 0. The number of tests did not change, because nothing was added or removed.
The cleanup pass: the small findings from the task reviews
The per-task reviews recorded some small findings and deferred them. This pass fixes all seven, each with a test that fails on the old behaviour. npm test in site/ ran 79 tests before and 97 after, and all pass (97 at that commit; the final review below takes it to 112). The failing side of each test was checked, not assumed: the changed source files were stashed and the suite run against the old code (11 failed, 74 passed), and where an import error hid the detail, the old code was run directly.
- A missing tool was published as “a physical machine”.
systemd-detect-virtprintsnoneand exits 1 on a physical machine, sotryRunreturned null for both that and a missing tool, anddescribeMachineturned null into “a physical machine”. A newdetectVirtualisationinbench/collect-measurements.mjsreads the tool’s own output:nonemeans physical, a word means that virtualisation, and anything else (the tool missing, or failing without sayingnone) is null.describeMachinenow says “a machine of unknown type” for null. The tests run real child processes throughexecFileSync, so the error shapes are the real ones. Old code, run directly:describeMachine({ virtualisation: null, ... })printed “a physical machine”. The collected date logic was left alone. - A missing suite was published as 0.
figures.mjshadsuites.DormannTests?.passed ?? 0. A newpassedInthrows, naming the suite and the fix. The two fields were renamed to say what they count:dormannBuildsis nowdormannTestsPassedandinterruptRunsis nowtransistorModelTestsPassed, with a comment above each. Five files changed for the rename (figures.mjs,status.astroand three test files). The “real figures” test now requiresvariants,harteTestsand both suite figures to be positive andmachinesInProgressto be a whole number. - A link from one journal entry to another left the site.
rehypeJournalsent every relative link to GitHub. A link todocs/journal/<id>.md, with or without a fragment, that is one of the built entries now becomes/journal/<id>/. The set of entries is read from the same glob the content collection uses (2*.md), and a test holds the two equal. Other files, and a name that is not an entry (README.md), still go to GitHub. Old code, run directly:2026-09-30-building-the-core.md#tbecame agithub.comaddress. The one real link, in the browser speed check entry, is now a site link. - The journal tests could pass on an empty list. The link and h1 loops now go through a helper that first asserts the number of built entry pages equals the number of entry files. The h1 test now compares the text to the front matter title, and the front matter test now requires
order. To check them: a changed h1 indist, a moved entry folder and a deletedorderline each failed the suite, and were then put back. The schema default of 0 fororderis gone, because every entry already has one and only the journal index reads it; a missingordernow stops the build. The index breaks ties by file name, later name first, throughnewestFirstinsite.mjs, tested on entries that share a date and an order. - The filters did nothing without JavaScript. The filter group is in the page with
hidden, andmachines-table.jsremoves it once it is running.global.csshas.filters[hidden] { display: none }, because.filters { display: flex }would otherwise win over the attribute. The count isaria-live="polite", and the script writes it only when the text changes, so it says nothing on load. No inline script or handler was added. - Sorting by year put the blanks first. Blank values always sort last now, in both directions. The comparison is in
site/public/table-sort.js, whichmachines-table.jsimports from the same origin (the script was already a module, andscript-src 'self'allows it), and the test imports the same file. Old comparison, run directly: an ascending sort of['1976', '', '1975']gave['', '1975', '1976']. There is still no browser test, so the wiring (the click handler callingcompareCells) is checked only by reading the import line; the comparison itself is tested. - The running-machine tests were vacuous. With no machine running, the links test checks nothing, and
/machines/[id]/had no test. The link rule, the list of running machines and the acceptance sentence moved intosite/src/lib/machines.mjs, and the templates use them. New tests run on a made-up registry, and nothing is written intomachines/registry.json: a running machine with a passing suite is valid and counted inmachinesImplemented; a missing, failing, empty or skipped suite fails validation; only a running machine is linked. Setting the link rule to always link made the test fail.
The floor of 79 tests in deploy-site.yml was not changed in this pass. It is a floor and 97 was above it. The final review below raised it.
The final review
A last review of the whole branch gave the verdict “merge after fixes” and listed eighteen items. All eighteen are fixed in one commit. npm test in site/ ran 97 tests before and 112 after, all passing (ℹ tests 112, ℹ pass 112, ℹ fail 0), and find dist -name index.html finds 13 pages plus the 404.
What was wrong in the branch
- One claim was false, and four task reviews did not catch it. The home page and the about page said where the reference data came from, and the answer was hardware. Nothing in the repository says Tom Harte’s SingleStepTests are hardware traces.
README.mdsays they “record the bus activity of every opcode”, anddocs/known-differences.mdsays one feature of that data looks like “a property of the program that generated the data, not of the chip”. Both pages now say what is true: the core is checked against Tom Harte’s SingleStepTests, a published record of every opcode’s bus activity, cycle by cycle. The same document also called theANEconstant the answer of one real part, which is the same unsupported claim, so that sentence now says only that it is one answer and not every chip’s. (Corrected in the next round: that edit was reported as done and was not made.git diffbetween the two commits was empty fordocs/known-differences.md, so the status page, which renders that file, still carried the sentence.) The reviews compared each page to its brief, and the brief carried the claim, so nobody checked it against a source. The new test inhonest-pages.test.mjsfails on any page that claims hardware provenance, and a second one requires the proof paragraph to name the data. - The family document said machines run on the core. Its table key said “Runs on this repository’s core” and its last section said the three planned machines “all run on the core”, while the site shows none implemented. The document is rendered on
/family/, so the document was fixed, not the page: the key now says “Uses a CPU the core implements”, and the document sends the reader tomachines/registry.jsonfor what is built instead of stating a status that goes stale.grep -n -i "runs on\|run on\|can run" docs/the-6502-family.mdfinds nothing that says a machine runs today. - Two figures for one measurement. See “Two collection runs on the same day” in task 3. The status page now says “this collection of 5 runs”, with the number read from the file, and says a different collection gives a different best.
- Stale statements in the record and the docs. Task 3, task 4 and task 8 said the 2 MHz reference belongs to the design. Each now carries an inline correction.
site/README.mdsaidorderwas optional, said to give a new machine a page undersrc/pages/machines/(the page is generated), and calledanalytics.jsandconsent.jsthe estate’s, though they now differ (per-site choice, reopen and withdraw).site/DESIGN.mdsaid the lime is used once, but the home page’s terminal window prints five “Passed” labels in lime. Both were rewritten against the code, and a test now requires the README to list every test file. - Test floors.
deploy-site.ymlstill said 79, andvalidate.ymlhad no floor at all, so a silent skip in the pull request check would pass. Both now read the count back and require 112, with a comment that says to raise it when the suite grows, and a test requires the two numbers to be equal.
Smaller findings, and what changed
- Variants and Harte tests could publish as 0. If no suite name ended in
Harte, the build passed with 0 variants.figures.mjsnow throws and names the fix, with a test. - The commit hash was checked only if present. The test now requires a 40-character hash in
results.json. - Only images marked
data-generatedwere checked. The test now takes its list fromsite/src/assets/imagery/, and requires each file that a page shows to have an “Illustration” alt text and caption, or to be used as a background. A second test stopsdata-generatedfrom being put on an image that is not in that folder. - The lime text was not counted.
.win .kcolours five labels lime on the home page. The test now counts the fills as before, names every rule allowed to use the lime, and requires each lime label to sit inside a terminal window. - Text on the traces texture was never measured. The design test now has a
texsurface: the brightest pixel oftraces.webpunder the stylesheet’s overlay, read withsharp, which Astro already ships.--sage-40measured 3.4 to 1 there, so.tex .notenow uses--sage-60(6.0), and the links and headings on that surface are in the table. - The 404 page had a canonical address that does not exist and no
noindex. It now has neither the canonical norog:url, and has<meta name="robots" content="noindex">. A test covers it, and checks no other page is marked noindex. - The home page sorted the journal itself and lost the tie-break. It uses
newestFirst, and a test compares its three entries with the rule applied to the front matter. - “In Chrome” was said of every row, including the native one. The status page now says the browser rows ran in the browser and the Native row ran directly on the machine, both read from the file. The home card now says when the measurement was collected.
- The post-deploy check did not fetch
table-sort.js, which the machines script imports. It does now, and a test derives the required list from the scripts the built pages load and the scripts those import. - The journal and the plan carried local paths (a previews folder, the imager tool’s history file, a scratch directory) and a private network address. They are reworded, and a test scans the journal, the rendered documents, the plan, the README and
package.jsonfor a home path or a private address. package.jsonhard-coded that address.devandpreviewnow take--host "${SITE_HOST:-127.0.0.1}",enginessays Node 22.22 or later, and the README documentsSITE_HOST. Checked by runningnpm run devandnpm run preview: each started on 127.0.0.1 and answered 200, and both were stopped afterwards withastro dev stopandastro preview stop. Port 4333 was already taken by another process on this machine, so each took the next free port, which shows the port setting and not the host was what collided.- Wording. The family page’s description said “which of it”; it now says “which of them use a CPU the core implements”. The status card that counts
TransistorModelTestswas called “Interrupt runs”. Each of those tests is one interrupt run on the NMOS 6502 checked against a model of its transistors, so the card is now “NMOS interrupt runs”.
What the checks showed
- With the source and docs stashed and the new tests kept (
git stash push -- site/src site/public site/package.json docs .github site/README.md site/DESIGN.md),npm testran 112 tests: 101 passed and 11 failed. The 11 were the hardware claim, the proof paragraph naming its data, the family page, the collection-of-runs wording, the missing Harte suite, the 404 page, the deploy list, the two floors check, the local paths,SITE_HOSTand thetexcontrast rule. Some of the new tests (the derived image list, the named lime uses, the home page order, the README list and the commit hash) guard behaviour that was already right, so they passed on the old code and cannot show a red run. actionlint1.7.12 (the downloaded copy, not part of the repository) printed nothing and exited 0 ondeploy-site.ymlandvalidate.yml. PyYAML parsed both:deploy-site.ymlhas the jobdeploy, andvalidate.ymlhastest,siteanddashes.- The
count()function from the workflows, run on the log of a realnpm test, readpass=112 fail=0. LC_ALL=C.UTF-8 git grep -InP '[\x{2013}\x{2014}]'aftergit addprinted nothing.
Skipped on purpose
Recorded and not done: a host-scoped cache purge, --ignore-scripts on the install, a GitHub environment for the secrets, the typed “three 65C02s” on the home page, and the privacy page, which is in another repository.
The re-review: the claim was not gone
The re-review of the fix round found that item 1 was only partly done, and that the report and the section above said something untrue. The first attempt reported the docs/known-differences.md edit as made when it was not. It had been planned in the same script as the others and never applied, and the diff was not read back before the report. The test did not catch it either, because its pattern for that sentence had a straight apostrophe and the built page has a curly one, so that branch could never match. The re-review noticed that the test could not fail on the page it was written for.
What changed in this round:
- The sentence.
docs/known-differences.mdnow says “That is one answer, not every chip’s”. - The pattern. The
HARDWARE_PROVENANCEpattern inhonest-pages.test.mjsnow accepts either apostrophe. Checked both ways on the built status page: with the old content,node --test tests/honest-pages.test.mjsfailed with/status/ says "one real chip’s answer"(6 passed, 1 failed), and with the edit it passed (7 passed). A one-linenode -eshowed the old pattern does not match the curly form and the new one does. CONTRIBUTING.mdsaid every claim is backed by a test against data of hardware origin, the same unsupported claim as on the site. It now says a trusted reference, such as Tom Harte’s SingleStepTests, a published record of every opcode’s bus activity.- The rest of the tree.
git grep -n -i "real chip\|from hardware\|real hardware"found the hits below, all left except the two fixed above. The journal’s and the core’s statements that a throwaway read “reaches the bus on real hardware”, that real chips vary on the unstable opcodes, and the Visual6502 and perfect6502 transistor simulations “of the real chip” are claims about the chip itself, which are true and do not say where the reference data came from.CONTRIBUTING.mdline 15, on where real hardware varies, is the same. The two copies of the old wording indocs/superpowers/plans/2026-09-30-site-v1.mdare the plan’s code as it was written; the plan is a dated record that is not served, and this section is the correction. - The family document said the 65C02 opens “every modern machine above”, but the MEGA65’s CPU is marked Other in the table. It now says every modern machine above except the MEGA65, whose 45GS02 is a different CPU.
Where it stands
Every figure here is from a command run on 30 September 2026.
- Pages.
npm testinsite/runs the build first, and the log says14 page(s) built: the 13 pages that carry anindex.html(find dist -name index.html) and the 404 page. - Tests.
npm testinsite/ran 112 tests, 112 passed, 0 failed, 0 skipped (the count after the final review below; it was 79 at the end of task 9). - Deploy. Nothing is deployed.
deploy-site.ymlis switched off by design: it runs only when the repository variableSITE_DEPLOYistrue, and it is not set. No secret was created and no Cloudflare, Terraform or Search Console change was made. - Workflows. Both parse as YAML and
actionlintis clean, but neither has run.
Where the infrastructure lives
The Terraform for 6502.dbhq.uk was first drafted in the private DBHQ repository, copied from terraken, because the design said “the same split as terraken”. When it came to applying it, the question came up of why it had its own state bucket and whether it should follow modem or bbs.
The state bucket was already the pattern. The account holds one R2 state bucket per site: dbhq-bbs-tfstate, dbhq-modem-tfstate, dbhq-skills-tfstate, dbhq-terraken-tfstate, heliograph-tfstate, and the shared dbhq-tfstate for the main site (read with the Cloudflare API bucket list on 30 September 2026). The modem README gives the reason: its resources once sat in the shared state, and a plan from a different project proposed destroying all three.
What differed was where the Terraform sits. modem and bbs keep it in their own repository under infra/. terraken keeps it in the private repository, and that split exists only because terraken’s site tests read its Go source. Nothing in the 6502 infrastructure needs that. So the Terraform was moved to infra/ in this repository, the site, its Terraform and its deploy workflow now sit together, and both specs carry a dated amendment saying so. The bucket dbhq-6502-tfstate was created through the Cloudflare API the same day and is unchanged by the move.
A mistake worth recording. The design copied terraken’s layout without asking what had caused it there. The reason for a split is part of the decision, and copying the result without the reason gave a layout that fitted worse than the simpler one already in use two repositories over.
No account or zone id in the public repository
Dan asked whether identifiers could go in secrets rather than the repository. They could, and for this repository they now do. The account id and zone id had been committed as variable defaults, and the account id was also inside the R2 endpoint in infra/backend.hcl. Neither is a credential, and both are already public in other repositories, but this one has no need to publish them. The defaults are gone, the values come from TF_VAR_account_id and TF_VAR_zone_id (set from the 1Password loader the same way as the token), and the endpoint is passed at terraform init. The deploy workflow already read both from GitHub Actions secrets. terraform init with the new arguments reached the same backend and terraform validate passed, checked on 1 October 2026.
Removing them from the files does not remove them from git history, which still holds the earlier commit, so this is hygiene for what comes next and not a recall of what was published.