The problem
A bookshelf with a cover hanging off it
On Tuesday morning I opened the lab and looked at the bookshelf model. A book is pulled halfway out of a shelf of coloured books. Its front cover was bigger than the book. It hung below the shelf like a loose flap and covered the books behind it.
I wrote:
bookshelf has broken, like the cover of the book is higher than the book
Tuesday 08:41
The cover was exactly 16 units too tall and too wide. Not a random amount. That is the width of its own border, counted twice. The model had been written inside the site, and the site's stylesheet quietly tells every box to include its border in its size. Pulled out of the site and dropped into a plain page, nothing told it that any more, so the border went on the outside and the cover grew.
Nobody had done anything wrong that morning. The model had been wrong for a while, in a way that only showed itself somewhere I happened to look.
That is the whole problem of this week, in one object. CSS 3D Lab has 135 models. Each one has to look right on a gallery card, in the viewer, on its own page, in the code editor, on the big stage, at full screen, and in the video or picture you download. Seven places. 945 chances to be wrong. I have two eyes and a week.
CSS 3D Lab itself was built in about a day, and that day has its own article. This is the six days after it, which took far longer than the day that made it. As with J.A.R.V.I.S. and Self-Aware Writing, every number here is read from the git history, the check records and the session logs, not from memory. My own messages are quoted with the typos fixed; the words are mine. Everything is counted at commit 5cc61a4, Friday evening, after the last run finished: the gate held over all 135 models at 16:56 and the release snapshot was retaken seven seconds later. The numbers moved all week while this was being written, and these are the ones that stopped moving.
- 798commits in six days, none of them pushed yet
- 135models, every one rewritten
- 125helper agents
- 11checks, nine per model and two per site
- 770reviews: a judgement, not a check
- 8.83Btokens spent
If you would rather run it than read about it, the repository has a page for exactly that:
docs/START-HERE.md says what you get, what you deliberately do not get on a fresh clone, and how
to start the gallery and the ledger in about four commands. docs/index.html reads the docs in a
browser, beside the ledger itself. Everything below is why any of it exists.
This is long, so it is in parts. The first half is what happened and what it cost — the week, the agents, the conversation, and what it all did to one laptop. The second half is how the thing actually works, where it breaks, and what I would do differently. You can start at How it actually works and lose nothing but the story.
The week, day by day
Sunday: eleven hours on one dialog
The week did not start with the models. It started with the video button.
Every model can be recorded: you open a dialog, pick a shape and a size, and it hands you an MP4 or a picture of exactly what is on your screen — your edits, your pose, your background. Getting "exactly what is on your screen" to survive the trip out of the browser is much harder than it sounds, and Sunday went entirely on it. Twenty-seven commits that day, eighteen of them on that one path, from 11:00 to 22:09.
The commit titles are the argument, in order: "Make the video in the visitor's browser", "Video, Image and Print in one dialog", "Catch hover, focus and active states in a capture", "Rewrite the framing: the stage IS the frame", "Keep the model in sight while it is being filmed", "Stop promising a see-through video, and print what the frame shows", "Draw the model on the GPU, the way the screen does", "Fit an edited model's scene inside its frame". Some of those undo the one before.
And all of it ran through me. I looked at a recording, described what was wrong, waited, looked again. There was no page to check and no record of what had already been tried — only my eyes and a very long conversation. That is a fine way to solve one problem and a terrible way to solve a hundred, and it is where the week's allowance actually went. By Monday afternoon I was rationing messages on a project that had barely started.
So the way of working changed before the code did. I stopped being the person who explains each bug to one worker, and became the person who talks to one session that splits the work, hands it out, and reports back. The agents needed somewhere to report that was not my inbox. That is the ledger, and this is the week it was needed.
Monday morning: the hacks come out
It started with sizes. I sent Claude a run of screenshots of gallery cards where the same kind of model came out at different sizes, and wrote: "this is also not constant size, what the hell man."
Then the sentence the week actually turned on:
yes, you need to focus on redesigning the models rather than some hacks around
Monday morning
Here is what the hacks were. The app used to measure each model after it appeared, decide it was too small or too big, and scale it into place from outside. That sounds reasonable and it is a trap. Every one of the seven places a model appears did its own measuring, at its own moment, at its own size. So a model could be correct on a card and wrong in the editor, and the model's own code was innocent both times. There was nothing to fix, because nothing was broken in any one place.
At 10:23 Claude wrote the rule down in a file, docs/VIEW-CONTRACT.md, and its first line is the decision: "Nothing outside a model adjusts it: there is no per-model size or offset anywhere in the app, no measuring run and no override file. A model that looks wrong is a model whose own code is wrong."
Thirty-eight minutes later the old way was gone and something new was in its place.
- Monday morning
- the contract is written
- hold a model's height, not its shape, and drop the second implementation
- retire the measuring run
- the frame is the canvas: take the placement machinery out
- add the check that judges models instead of adjusting them
That last line is the one I did not expect to matter as much as it did.
The app stopped correcting models. A program started grading them instead.
Monday afternoon: 253 commits in four and a half hours
The work went out in lettered batches, A through L: twelve batches of models, each with its own agent, plus separate agents on the control zone under the models, the export dialog, the cross-stage check and the ruler the checks measure with. From 11:31 the commits become one model at a time — "Write dice in canvas units", "Write the door in canvas units and centre its swing, not its frame", three and four in the same minute. Fifty-three helper agents ran that day, and the 15:00 hour alone holds 100 commits — the busiest hour of the whole week by some distance. No other hour in six days passes 57.
A laptop does not enjoy this. At 14:28: "maybe too many agents are running in parallel, my laptop started to lag and heat too much, let's reduce the number a bit." At 19:45, again: "seems like we are running a lot of agents and the pc is lagging." We settled on a number — "it can be up to 5, I think laptops can handle that many agents" — and mostly kept to it.
The reviewing came out of one message at 11:35: "yeah, so every time you change something go check visually; also maybe have another agent do a second QA across all stages." That became the shape of the whole week. One agent fixes. A different agent looks. By the evening I liked it enough to say so: "I also like this experiment with working with you separately and agents also doing their job in parallel and checking each other and reporting to you for your final approval."
And then, in the middle of that afternoon, I lost the ability to answer the simplest question about my own project.
14:42: "where can I see what is happening?"
The message, as I sent it:
ok, so where can I see the checkmark UI, app side, what is happening: like what was already checked, approved, what is in progress and is in queue
Monday 14:42
Twelve agents were working. Any of them could tell me it was done. I had no way to know that was true except to believe them, and I had 135 models to believe them about.
Ten minutes later the first version of the ledger was committed. By 15:25 it was live: one page listing every model as a row and every check as a column, with a watcher that rebuilds it whenever a model file, a check result, a review or the commit changes.

The ledger's three gates, from the live page: one wave crosses the whole strip and only stands up where models have actually arrived. Flat reads as empty.
Alt text · 422 characters
The ledger's progress strip as three chevron panels running left to right: To check, 0, an automated check is not yet cleared, on violet; Awaiting review, 0, every check clear and reviews pending, on magenta; Approved, 135, checks clear and reviewed, on amber. One neon wave runs the length of all three, flat under the first two and standing up into peaks under Approved, over a perspective grid floor with a setting sun.
There was a second reason, and it was money.
Asking a chat "where are we?" is not free. The answer has to be rebuilt every time: an agent reads the files, counts what it finds, writes me a paragraph, and I pay for all of it in tokens. Ask it ten times a day across twelve agents and a real part of a weekly allowance goes on being told things that were already true. Sunday had already shown me the bill for that, on one dialog.
So the state moved out of the conversation and into a file. The agents write what they are doing into a small queue file instead of reporting to me in chat. The checks write their results as records instead of as messages. The page reads those files and draws them. Looking at it costs nothing, and looking at it again costs nothing. That turned out to matter as much as the trust did: by Tuesday, the thing rationing the week was not my patience, it was the limit.
That evening I gave it the rule it still runs on:
across all the converted, checked, approved, the numbers must match — it is like an accountant, you know
Monday 16:47
That sentence turned out to carry weight. An accountant's numbers have to add up, and when they don't, the accountant says so instead of rounding. Every model sits in exactly one stage. The parts of each stage add up to the stage. The total adds up to 135. When a build cannot make that balance, the page says so, rather than showing a tidier number.
Tuesday: the day more was deleted than written Tuesday is the day I would skip if I were selling this, and it is the one worth reading.
It produced 143 commits, and in them 10,643 lines added against 22,859 deleted. Twice as much went out as came in. No feature was cut. What happened is that a rule had been tightened, and a tightened rule expires every result judged under the older one — on purpose, and the whole point. So all 135 models came back through, and what came out the other side was smaller: hacks that had been holding one model in place, per-model nudges, the little exceptions that accumulate when you are fixing things one at a time instead of stating what "right" means.
That is the shape of the cost nobody warns you about. It is not that the checks find bugs. It is that the checks make you re-run everything every time you learn something, and learning something is what a good week is made of. Thirty-two agents ran that day, for 79.7 hours between them, and the net effect on the repository was that it got twelve thousand lines shorter.
It is also the day the subscription ran out, at the pace those rule changes were coming — which is its own story, under Working in conversation below.
The night the checklist ran, and what kept stopping it
The last item on the list was the gate: every check over every model, on an idle machine, hours of browser work. It had carried an admission in its own text for days — "NOT RUN FOR THIS PUSH" — because an early attempt opened 57 headless browsers and the laptop had to be restarted.
I ran it overnight. It failed three times. Not one of those failures was a model.
The first was memory. The export matrix draws every setting the dialog offers and decodes the videos frame by frame, and a 3200-pixel picture and a 1080p film are hundreds of megabytes while they are being made. Run as one process it climbed until it reached 0.18 GB free, froze the laptop, and was killed after three models in three hours. The fix is not clever: end the process after each model and it hands every byte back. Eight models in 35 minutes, with free memory rising as it went.
- 0.18 GBfree, before — the laptop frozen, 3 models in 3 hours
- 7.5 GBfree, after — 8 models in 35 minutes
- 67models failed by a locked folder, none of them broken
- 1word that saved it: "no verdict"
The second failure printed this at 04:20:
GATE FAILS: 67 models failed or had no verdict: rubik, solar, package, hovercards, dice…
Zero models had failed. One line of the log explains all of it:
[7/10] check-media shard 2/2 67 models in 0s: 0 held, 0 failed, 67 no verdict
A shard that died in 0 seconds. Its own log said EPERM: operation not permitted, rmdir 'node_modules\.vite\deps'. Two shards start a build server in the same project, so both manage the same cache folder, and on Windows the loser cannot delete a directory the winner is holding open. It exited before judging anything, and its 67 models were recorded as no verdict.
And that is the moment the whole design paid for itself. "No verdict" is never counted as a pass. A system that guessed would have called those 67 models fine and shipped them. This one failed loudly over a locked folder instead. The fix already existed in the one check that had never hit the problem — a cache of its own, with a comment describing exactly this hazard — and it took four lines to give the others the same thing.
The third failure was subtler and more interesting. stackbars failed a stage check: jump on "card → viewer", 4.8vmin. Three hours earlier, on the same commit, the failure had been funnel at 6.0vmin with stackbars clean. Two models swapping places between runs is a reading, not a fault.
Both are charts with a staggered entrance — each bar delayed by its position, so the whole thing lands over about a second — and they are the only two of 135 built that way. The check takes one reading the instant something happens ("what the eye would catch") and compares it against a settled reading from before. So a staggered model was being measured against a layout it had not reached yet, and whichever one the reading happened to catch mid-stagger failed. The guard meant to prevent this checks whether the pointer is over the model, and the pointer leaves instantly while the animation it started keeps running.
Then a night of smaller things, and this is the part I did not expect. A watchdog written to restart the run declared it dead thirty seconds after it started, because echo $$ inside a Git Bash script writes a process id that Windows cannot see at all. Three separate things — the watchdog, the memory guard and a waiter — all read the previous night's "finished" line from a log that is appended to across runs, and one of them reported an old GATE FAILS as if it were the new result. A check that compared two files printed parses ok for both of them without ever opening either, because the success message was unconditional.
That last one is the whole project in one line. A test that cannot fail is not a test.
Counting all of it — six days, twenty-three separate interruptions:
- 2were the app being wrong
- 3were a check being wrong
- 14were the machinery around the checks
- 4were the machine sleeping, or me
The app was the least broken thing in the building. The expensive bugs were all in the thing doing the watching, which is worth saying out loud, because a checklist is only ever as trustworthy as its plumbing — and the plumbing is where the week went.
At 09:24 on the fifth morning it printed GATE HOLDS: every model held every check. in 1 hour 34 minutes, and the item that had said "NOT RUN FOR THIS PUSH" for days could finally say what was run instead.
Friday: the last run The last day had no agents in it at all. Not one.
What it had instead was a five-step chain, written because I had said I would restart a check after changing its rule and then hadn't, and the machine had sat idle for several minutes with nobody watching. A script cannot forget the next step. It ran stages, then compare, then three browser looks, then the gate, then the release snapshot, recording each into a file as it went:
``
13:23 start at 8dac66d
14:14 stages: 135/135 models are the same everywhere
14:19 compare: 135 demos, 0 differ, 0 could not be captured
14:20 looks: 13 of 14 held
16:56 gate: GATE HOLDS: every model held every check.
16:57 finished -- approved 135
``
This is the gate's second full run, not the one from Thursday morning. It took 2 h 36 m against
that one's 1 h 34 m, and almost all of the difference is a single step — check-exports, which
renders real video and images, and which the machine section above explains. Three hours and
twenty-four minutes for the chain, one line of output per step, and the only thing a person had
to do was read it. That is what the week had been building toward, and it is a duller artefact than
the twelve-agent afternoon — which is rather the point.

Six days, counted from git and the session logs. The busiest day was Monday. The quietest, by every measure except commits, was the last one.
Alt text · 456 characters
A table of six days, each row carrying four bars: commits, lines changed split into added and deleted, helper agents, and tokens. Sunday 20: 27 commits, 7 agents, 1.61B tokens. Monday 21: 391 commits, 53 agents, 2.26B. Tuesday 22: 143 commits, 32 agents, 1.67B, and more lines deleted than added, 22.9 thousand out against 10.6 thousand in. Wednesday 23: 46 commits, 15 agents. Thursday 24: 33 commits, 5 agents. Friday 25: 50 commits and no agents at all.
Working with agents
When two agents opened the same file
Twice, two agents worked on the same file at the same time, and one of them lost.
The first was Wednesday at 18:30, and the recovery commit says it without softening: "My three commits before this one were spliced from the working tree against the wrong base: they carried my hunks but dropped the stage-arrow work that had landed in 1877c11, and the last of them picked up a few lines of that agent's next, uncommitted change." What went in afterwards was the honest state — the other agent's work with only the lead's own changes on top. Eleven minutes later, another commit took the opposite precaution and said so: "The strip belongs to another agent, so it is not edited here."
The second, two hours later, I watched happen live. 20:37: "can you explain what is happening with the ledger UI? It seems it gets updated and then somehow reverted to the old UI." 20:39: "now the table changes are old also — something is not in sync with our approach with agents, they should be aware of each other, and you should also." 20:46: "no, the ledger is not back to the old version, what is going on, we need to stop this."
And then the rule:
make sure that two agents don't overlap each other when it comes to the design, this is important
Wednesday 20:49
The release checklist now opens with a group called Nothing is lost, and one of its items exists only because of that evening.
The helper that idled for 25.9 hours Agents do not tell you when they are finished. They tell you when they are asked.
On Tuesday one helper completed its work and then sat there, holding its place, for 25.9 hours. It was found on Wednesday evening, and the message when it was found was not a bug report: "these are like running for 25 hours, man." Nothing had crashed. Nothing was wrong, in the sense that no error was ever raised. A shell that has finished and a shell that is thinking look identical from the outside, and I had no way to tell them apart because I had never needed one before.
That single agent is most of why Tuesday reads as 79.7 agent-hours against Monday's 36 from half again as many helpers. Monday's fifty-three did four hours of work each and stopped. Tuesday's thirty-two averaged two and a half hours, and one of them was a ghost.
The fix was not a better agent. It was learning to ask what is running, in a way that could not be answered by something that had already stopped — which is the same lesson, arriving early, that four different watchdogs would teach again on the last day.
Why there were none left by the end The clearest number in the whole week is the one that goes to zero.
| | Sunday | Monday | Tuesday | Wednesday | Thursday | Friday | |---|---|---|---|---|---|---| | agents | 7 | 53 | 32 | 15 | 5 | 0 |
Nothing was taken away. No decision was made to stop using them. What happened is that the work they were doing got written down — first as a contract that said what a model had to satisfy, then as checks that could answer it without being asked nicely, then as a list whose every item named its own proof.
An agent is what you reach for when a job needs judgement and you cannot yet say what the judgement is. The moment you can say it, the job becomes a script, and a script does not need supervising, cannot idle for 25.9 hours, and costs nothing to run twice. Every hour spent turning the first into the second showed up as a smaller number in this row.
Which is the honest summary of what agents were for here: not to do the work, but to help me find out what the work actually was.
A word on where that number comes from, since it is the one in this article that is easiest to get wrong. Each helper writes its own log, and the count is the number of those logs: 125 files, with no id appearing twice, all under a single session — the project's two other session folders hold no agent logs at all. "Started on Monday" means its first line was written on Monday. What the method cannot see is an agent that was asked for and died before writing anything, and it cannot see agents spawned by a workflow, which keeps its script and not their logs — one of those ran on this project, but on the 18th, before this week, and the session that ran it holds no agent logs at all. For the six days counted here the main session has no workflow folder, so nothing is hiding in that gap; but the honest way to put it is that 125 is a floor rather than a ceiling. It is not an estimate and it is not a memory: it is a count of files, and anyone with the folder gets the same answer.

125 helper agents across the week, and none on the last day. An agent’s hours run from its first log line to its last, so a helper that finished and sat waiting still counts as alive.
Alt text · 337 characters
Six bars, one per day, showing how many helper agents ran and for how many hours. Tuesday 22: 32 agents, 79.7 hours, one of which idled for 25.9 of them after finishing its work. Monday 21: 53 agents, 36 hours. Wednesday 23: 15 agents, 13.6 hours. Sunday 20: 7 agents, 3.6 hours. Thursday 24: 5 agents, 2.8 hours. Friday 25: none at all.
Working in conversation
What it cost
The subscription, and the machine's patience.
From Sunday to Friday the agents and I used about 8.83 billion tokens, across 31,327 replies that carry a token count, from the lead session and 125 helper-agent logs. Almost none of that is new writing. It is the same context read again on every turn, by everyone.
The interesting part is not the total. It is which hours it went to. These six windows are counted the same way, out of the same logs:

Six windows of the week, counted the same way. Rewriting every model in the gallery cost less than the day spent describing one dialog, and only a third more than one evening spent on how a progress bar looks. The last two days — the gate refusing to hold, then the run that finally held — cost less than either.
Alt text · 441 characters
A bar chart headed: Deciding how it looks cost more than building it. Six bars in tokens: Sunday, one recording dialog, 1.61B; Monday 11:00 to 15:59, all 135 models rewritten, 1.02B; Friday to 17:20, the last run and the writing, 901M; Thursday all day, the gate refusing to hold, 886M; Wednesday 17:00 to 23:59, how one strip looks, 776M; Wednesday night, the machine working alone, 214M. All six counted the same way from the session logs.
Deciding how something looks cost more than building it. Twelve agents at once rewriting every model in the gallery — 253 commits in four and a half hours, the largest piece of work in the week, and part of the 53 helpers Monday used in all — came to about a billion tokens, and 830 million of that was the agents doing the work rather than me describing it. One evening of moving the ledger's own progress strip around came to three quarters of that, for a strip. And a single day on the recording dialog, before any of this existed, cost more than either: Sunday was 1.61 billion, of which only 238 million was an agent. The rest was conversation.
The pattern is consistent enough to plan around. Work that is described message by message is the most expensive thing you can do. Work that is written down once and handed to agents costs less, even at twelve agents. Work that runs in the background with nobody watching costs almost nothing: the night I left the laptop to it, the machine ran for hours and the whole thing came to 214 million tokens — a quarter of what one evening of deciding how a progress bar looked had cost: 214 million against 776.
That is also why the allowance ran out on Tuesday. 08:32: "I am running out of the weekly limits and it is only Tuesday." 13:14: "we are already 93 percent limit reached this week." 18:14, tacked onto the end of a message about where a panel should sit: "please don't use too much credit for this now, I am at 95% of weekly." 19:48: "I am at 96% of limit now." By Wednesday it was a thing to be managed rather than spent: "the weekly limit is rising quick, let's make sure it is under control." Tuesday ended on 143 commits against Monday's 391, not because there was less to do but because I was counting every message. It was not the models that spent the week. It was the small steady stream of taste: one more feature, the panels made to fold, the arrows made to point the other way, the tag recoloured. Each of those is a cheap-sounding message that carries the whole project with it. I used my one free reset of the month on Wednesday morning to buy the week back: "we are back in business, my friend — there was one free reset per month."
The subscription was only half the bill, though, and the other half is not measured in tokens at all. It is measured in gigabytes, and it is the next part.
The machine
57 browsers, and two restarts Everything above happens on a desk. This is the part that gets left out of write-ups about agents, and it decided more of this week than any prompt did.
The checks are not calls to an API. Each one opens a real browser, loads a real page and takes a real screenshot, because that is the only way to judge a picture rather than an intention. Multiply that by 135 models and several checks and you are running a small browser farm on a laptop.
The first version of the gate had no ceiling on how many. It opened a browser per shard per check, and the render service opened another per request on top, and one run reached 57 headless browsers — the run mentioned above, the one that kept the gate marked "NOT RUN FOR THIS PUSH" for days. The laptop had to be restarted. Twice.
I had to restart laptop, it was getting crazy, we need a better approach for checking the list
Thursday morning
The worst single moment was quieter and more instructive: free memory fell to 0.4 GB of 15, a screenshot failed outright with "Unable to capture screenshot", and then the next model timed out at 30 seconds even after memory had come back. One bad moment poisons the run that follows it, so a memory problem does not present as a memory problem — it presents as a flaky check, which is the most expensive kind of bug to chase.
What fixed it was not tuning. It was one number, C3D_MAX_BROWSERS, default 2, in one file that
both the gate and the render service read — so the two cannot multiply past it — plus a fresh
browser every twenty models so memory cannot creep, and a floor: before each model the guard reads
free memory, and under 1.5 GB it waits up to three minutes rather than starting. If it is still low
it runs anyway and says so, and the result reads "ran under memory pressure". A number that
might be wrong says that it might be wrong.
What this costs you in practice The machine is an ASUS TUF Gaming A14: 8 cores, 16 threads, 15 GB. It is a gaming laptop, and that turns out to be the relevant fact.
During the last full gate, two Chromium GPU processes sat at 778% and 690% of one core — 14.7 of the machine's 16 threads — for the best part of two hours. Headless Chromium has no GPU, so every 3D transform of every exported frame is rasterised in software, on the CPU. The laptop got hot enough to be noticed from across the room, and thermal throttling then slows the very work causing the heat.
So, plainly: on 15 GB you can check 135 models, or you can do something else. Not both. Not a game, not a video call, not a second browser with thirty tabs. The guards exist so that the run degrades honestly instead of failing mysteriously, but they cannot give you headroom you do not have.
If you are planning to work this way, the specification that matters is not the one people discuss. It is memory, and then cores, and the reason is that agents which drive browsers do not run in a data centre. They run on your desk, next to everything else you were going to do today.
At one point in the week I asked whether the gate could run while I played GTA 5. It could not. I turned the game off, and that is the most honest summary of the trade this way of working asks for.

What checking 135 models does to the machine you are sitting at. Agents that drive browsers do not run in a data centre — they run on your desk.
Alt text · 360 characters
Six bars about one laptop, an ASUS TUF Gaming A14 with 8 cores, 16 threads and 15 GB of memory. Headless browsers open at once before the cap: 57. After the cap: 2. Free memory at the worst moment: 0.4 GB of 15. The floor the guard waits at now: 1.5 GB free. CPU during the last gate: 14.7 of 16 threads. Memory during the last gate: 2.0 GB across 19 browsers.
How it actually works
What the contract actually says
The contract is written for Claude, because Claude writes the models. So it fixes what a visitor can see, and says nothing about how to get there.
A model has to sit in the middle of its canvas, no more than a hair off centre either way. It has to be between 40 and 70 units tall and no wider than 92% of the canvas. Its text has to be readable against whatever pixels are behind the letters, on the dark background and the light one. It has to be finished when it is standing still, not caught mid-move. It has to look the same in all seven places. And it must not lean on any CSS from the page around it.
The unit is vmin: one hundredth of the shorter side of the window. Forty of them is about two fifths of the screen. It is used because it means the same thing on a phone and on a monitor.
Every one of those sentences names the check that measures it. None of them says what a model should look like. That line is in the file in as many words: "Claude, who writes the models, must stay free to invent new ones."
That freedom was not generosity. It was a repair. The first version of the rules bent good designs until they broke. A flip clock was restacked onto two lines to reach the height floor, which is not what a flip clock looks like. A dropdown menu was grown to full height while it was still closed, which no dropdown does.
So the contract grew a heading: when a good design does not fit, add or adjust a class; do not bend the model. There are three classes. wide gives a model a width floor instead of a height floor, for things meant to be long and flat — lit, waveletters, clock. expands lets a model rest small and only asks for full size when it is open — dropdown, radial. And full-canvas is for a scene meant to fill everything.
A class costs five things in the same commit: the mark on the model, the rule written down with its reason, the check taught to read the mark, a deliberately broken copy that the check must fail, and the documentation updated. It is never guessed from the code. Every exception is something a person decided, on purpose, in writing.
All 135 models were rewritten to that contract on Monday between 11:25 and 15:50, in 253 commits — 205 of them touching the models themselves.
One model, all the way through
It is easier to see what that means by following a single model. Take the bookshelf, from the broken cover to the green tick.
It fails. The box-sizing check loads the bookshelf twice: once in a page that sets the border rule the site sets, once in a page that sets nothing. It compares the two pictures. The cover is 16 units bigger in the second. The check writes one line naming the model, the face and the number.
An agent fixes it in the model's own code. Not in the app. Every face of the book now states what its own size means, so it draws the same in both pages. Eighteen models needed that. The bookshelf was the one I happened to see with my eyes.
A second agent looks at it. The reviews are written down, one entry per model per pass, and they are close-up rather than general. A typical one, for the phone model, reads: "Phone stands centred at about 60vmin tall on card and editor canvas, clear of the corners; the paused pose is a clean three-quarter view." There are 770 of these: 414 looking at models, 356 reading each model's words against its own code. One of those text passes found twelve models describing something the code no longer did.
It goes green, and then something changes and it goes grey again.
also note, when we make edits to the models they have to go through the checking process again
Tuesday 08:22
That is obviously right, and the obvious version of it is useless: if every commit makes every result stale, nothing is ever proven.
So the ledger fingerprints what each check actually looked at. For the contract check that is the model's finished code, the way it is played with, and the handful of shared files that draw it. For a text review it is the model's words. Change the bookshelf's description and its text review goes stale while its contract tick stands. Change the book's geometry and the contract tick goes while the description stands. The page names what changed and which commit changed it, so a grey tick always comes with a reason.
The rule itself can go stale. Each check carries a version number. When the meaning of a rule changes — a limit tightened, a new thing judged, a surface added — the version goes up, and every result recorded under the old version stops counting. A pass under version 1 is not a pass under version 2. The contract check is on version 3 today. The export check is on 7.
This is expensive on purpose. Each time we tightened a rule, all 135 models went through again.
"Didn't run" is never "passed." A check that crashed, timed out, or was never run on a model is its own bucket on the page: named, counted apart, and blocking. This is the one I would put on a wall. The most comfortable lie a build system can tell you is silence.
Only when every check is green, on the model's current code, under the current rules, with a person's review recorded beside it, does the row get the word Approved.

The bookshelf, filmed before and after the box-sizing fix: its covers had been written without counting their own padding, so they only looked right inside the site's own stylesheet.
Alt text · 446 characters
Six close-ups of the same 3D bookshelf model, before on the top row and after on the bottom. In each one a book is pulled out of a shelf of coloured books. Before, the pulled-out book's front cover is taller and wider than its own spine and pages, hanging below the shelf and covering the books behind it. After, the cover matches the book, the shelf's edge is clear underneath, and the spines beside it read PERSPECTIVE, FACES, ORIGIN and DEPTH.
The checks arrived one bug at a time
There are eleven now: nine that grade a model and two that grade the site. None of them were planned. Each appeared on the day something got past us.
- when each one was born
- the contract: centred, in the size band, finished at rest
- the same on every surface
- motion, filmed as a strip of frames
- the gate, and recordings against the screen
- search engines: tags, headings, sitemap
- share previews
- pause, names and the keyboard
- box sizing, the morning of the bookshelf
- text contrast
- how much a model draws, and how fast
- the whole gallery, scrolled end to end

What a green row means, and what takes it away again. A check that crashed or never ran is neither a pass nor a fail: it is named, counted apart, and it blocks.
Alt text · 428 characters
A diagram headed: Nine checks, one person, and a tick that expires. Four boxes joined by arrows: A model changes; Nine checks run, each judging a picture; A person looks; then a bright APPROVED stamp. A dashed panel below reads: back to the start whenever what a check judged changes. Nine pills name the checks: contract, every surface, motion, recordings, share preview, pause and keys, box sizing, text contrast, performance.
Checks go wrong too, and it is worse when they do, because a model that is wrong looks wrong and a check that is wrong looks like good news.
This morning one told me two models were broken: the flip card and the starfield. I opened both. They were fine.
The check was asking the wrong question. To decide whether a page had anything on it, it counted the elements bigger than four pixels, and called a page empty if it found fewer than two. The flip card draws its faces in a way that leaves nothing for that count to find. The starfield is hundreds of two-pixel stars, and not one of them is four pixels wide. Both look exactly like what they are, and both had been failing since the check was written.
So it asks a different question now: how much of this page has any paint on it at all. The starfield, the faintest thing in the gallery, covers 1.1 per cent of its page. The rule fails under 0.2.
Then I made a page with nothing on it, ran the check, and watched it fail.
That last step is the one that matters, and it is the rule every check here is held to: something nobody has ever watched fail is not a check. So each one ships with a copy that is broken on purpose — text repainted too pale to read, a model that litters a few hundred leftover pieces across thirty clicks — and it has to catch each of them, and say which limit it caught them on.
The limits themselves are measured, not argued about. The busiest model everyone agrees looks right draws 158 pieces standing still, so the limit is 250, and the difference is room to move.
What the checks caught that I never would have
Text I could read and a stranger could not. The contrast check's first run over all 135: 100 passed, 35 failed. Not vaguely — measured against the actual pixels behind each letter, on both backgrounds. The signature on a greeting card came out at 1.7 to 1 against the dark stage, where the accessibility standard asks for 4.5. White words on a bookshelf's teal and amber spines: 1.6 to 1. All 35 were recoloured in their own code within the hour.
Videos that came out slightly green. Exported MP4s looked a touch dark and cold, and it was nobody's mistake exactly. The browser's encoder converts the picture one way and writes that fact on the outside of the file, but leaves it out of the video stream itself. Players read the stream, guess the older standard, and get it wrong. A test pattern came back 3.8 shades off on average and 25 at worst. The fix rewrites six bytes at the head of the stream to state what the encoder actually did. After: 0.37 off on average, 2 at worst.
A click that froze the browser. The confetti model makes 36 pieces per click, and each piece removed itself when its own animation ended. On a paused stage, an animation never ends. So clicking quickly piled up thousands of pieces that were never coming back, and the page stopped. The pieces are now made once and thrown again: at most 108 exist, and a click gives the next 36 new numbers.
A badge showing through a closed box. Thursday morning, the contrast check held the package model at 3.72 to 1 on a white "NEW" label. I filmed it: the box is shut and there is no label anywhere on screen. What the check had measured was 13 faint pixels — the hidden card inside losing its place in the stack and printing a ghost of the word through the front wall. Where the badge is actually seen, it reads 5.81 to 1. The bug was the leak, not the colour, and the check found a hole in a wall by reading the colour of thirteen pixels.
A slowdown that existed in only three of the seven places. The same confetti froze the model page and the export dialog while staying smooth on a gallery card. That made no sense until it did: cards have no zoom limiter and those stages do. Every animation starting or ending ran a full measuring sweep of the whole scene, and one confetti click is 36 starts, then 36 landings. Measured: the model's own work was 2.8 milliseconds per burst while the page's worst frame was between 2.6 and 3.8 seconds. After the fix, the worst frame is 83 to 133 milliseconds and the usual one is 8.3. Per keystroke in the code editor, it went from 926 measurements to 151.
Where it breaks
What no check can judge
The contract says it plainly: whether the 3D is right, and whether a movement reads as the thing it is supposed to be, is a person's call. Mine, mostly. And my eye found things every check had passed.
also one of the planet rings is not wrapping the planet
Tuesday 07:43
It was drawn as a single ellipse laid over the ball with its top edge hidden, so the far half vanished even where it should have shown beside the planet, and the near sides crossed in front of it. It is now the same tilted ellipse drawn twice and cut down its long axis: the far half behind the ball, the near half in front.
Three minutes later: "also the synthwave seems broken." The retro sun was placed 14% down the canvas but sized in a unit tied to the shorter side, while the horizon sits at half height. So on a wide card, two of its striped bands dropped below the horizon and floated over the grid. It is anchored to the horizon now, and only the part above it is drawn.
Then the rocket, which the work queue records in my words: "the user says these animations look weird now; they were fine before." The launch read as a rocket shrinking, not climbing. It was flying 5,000 units into the distance toward a vanishing point sitting on its own shoulder, so the nose never rose — it just got smaller beside the gantry and grew again coming back. The frame is a window onto the sky now. The ground drops out of the bottom and the rocket leaves through the top.
And one that no rule will ever have an opinion about, at 08:43 on Tuesday: "for unboxing would like the initial state of box be completely closed."
Where the ledger stops
The ledger was finished on Monday afternoon. The four hardest days came after it.
That is worth sitting with, because it is the opposite of what a page full of green bars implies. Having a thing that counts honestly did not make the week easy. It made it finishable — a different and smaller claim. Here is what it could not do.
It cannot tell you whether something is good. The synthwave strip at the top of the page went through five complete rebuilds and the light-mode wave through six attempts, none of which any check could have settled: "got even darker", "nothign hcanged", "to muc hshadow", and finally "yes finally perfect". Nine checks pass on a model whose colours are wrong. An hour went on whether one word should be pink or green.
It cannot run the slow things for you. Twenty of the items it cannot re-prove on the spot are simply slow — the gate was an hour and a half when this was written and took 2 h 36 m on its last run, and the export check at its default settings is seventy minutes over 135 models. The ledger will tell you they have not been run. It will not run them, and nothing makes the laptop faster.
It only knows what it is told about work in progress. The lead's record says REPORTED, NOT MEASURED in yellow, and it means it: a run that dies between "starting" and "done" sits there claiming to be running for ever. One did, for twenty hours, while its process had been dead for fourteen. The page was honest about being unable to know — it printed a note saying the process had ended — but "honest about not knowing" is not the same as knowing, and for twenty hours the board was wrong.
And it does not watch itself. The eleven checks judge 135 models. Nothing judges the tools that
report on the checks, and on the last day every one of them was wrong at least once. A watchdog
knocked on the dev server and called it dead, because it asked 127.0.0.1 and Vite binds ::1
first on Windows. Its replacement used pgrep, which in Git Bash sees only MSYS processes and never
a Windows node.exe, so it reported "idle" while a check was eight minutes into a run. The third
watched the logs grow, which is right — growth cannot be faked — and still cried wolf three times on
the gate, because it assumed lines arrive at a steady rate. They don't: check-stages writes a line
per model, one every twenty-two seconds, while the gate writes a line per shard, and its slowest
shard is twenty-five minutes of saying nothing while working perfectly.
The measured status tool, built specifically because the reported one could lie, then did the same thing in its own way. At 13:38 it showed a run "7 of 135 models · ends about 3:35:54 AM" — in the afternoon, off a file last written three hours earlier — while the process actually on the machine sat four lines above it in the same screen, unmentioned. The staleness was printed. It was in grey, underneath, in the position a footnote occupies, while the extrapolation sat above it in the present tense, where the answer goes.
The pattern is identical every time, and it is not incompetence: a quantity that is easy to read gets assumed to stand for a quantity that matters, and the relationship between them is never tested. Ports for liveness. Process tables for liveness. Log growth for liveness — correct. Log cadence for liveness — never checked. The instruments are not inherently less trustworthy than the app. They are less trustworthy because nobody wrote checks for them. The app has eleven checks over 135 models and a gate that holds them all. The watchers had my confidence and nothing else.
What came out of it is a rule the tools now follow: an instrument that cannot answer says which kind of silence it is. "I could not look at the process list" and "nothing is running" are opposite facts and must never share a sentence. There is a single function answering "is work happening", it prefers direct evidence to inference, it exits with a third code for could not tell, and every time one of these tools fails it appends the failure to a log the ledger page now shows. An instrument's failures are findings.
And the thing it catches still needs someone to look. On the last morning the button read proven 31 of 55, 22 not evaluated, 2 conflicts (the list was 55 items that morning; it is 63 now). A conflict is an item ticked in the file whose proof disagrees with the tick — the most valuable thing this system computes. One of those two was the release snapshot: the file a stranger cloning the repository reads instead of my machine's workings, describing code that had been replaced two days earlier. It would have shipped.
It was caught because Edgaras asked what the number meant.
The ledger had done its whole job. It ran the proof, found the contradiction, and put the count on a button in the main view. And it still took a person reading a number and saying "what is that?" for anything to happen. Whatever you build, the last step is somebody caring enough to ask.
What we got wrong
- Rules that bent good designs before anyone noticed. "Outcomes, not designs" is the fix, and it had to be written after the damage.
- A check that passed because the thing it tested no longer existed. On the last day the page's View zoom was removed — a percentage a visitor could set while editing, which changed what they saw without changing what they would get, the one thing the whole contract exists to prevent. Its checklist item was deleted with it, but the browser look that tested it was not. One of its three assertions correctly went red. The other two went green, against the export dialog's Model size slider, which shares the class name
.maker__zoom: once the dialog had been opened, the selector found it, and the two assertions solemnly reported that zooming to the maximum kept the model centred ({"rest":{"cx":50,"cy":50},"max":{"cx":50,"cy":50}}) and left the CSS tab unchanged (null -> null). Both passed because nothing they measured could move. Two greens produced by absence, which is worse than a red, and which the replacement item now guards against by requiring both halves: the control gone from the page and alive inside the dialog, because the first alone would also pass if the feature had been lost altogether. - Item numbers counted by position. The same script labelled its results
[40] [41] [58]while the items were 23, 24 and — for the deleted one — nothing at all. Every edit to the list had silently shifted them, and the evidence was about to be written into the wrong lines. Looks now find their item by matching its words, and an item they cannot find is a failure rather than a silent skip. - A gate with no ceiling on browsers, which cost a restart twice.
- Helper shells that finished their work and then sat there waiting, for 25 hours. Wednesday 18:14: "these are like running for 25 hours, man." Then, when they were cleared: "funny that they went and just gone, like quit the job without saying." Nothing was harmed except the one thing the ledger exists for: the board said work was happening when none was. Anything that waits now has to say what it is waiting for, and give up at a stated time.
- Reruns we caused ourselves. Every tightened rule made every older result stale on purpose, and all 135 models went through again. That is the system working, and it is not cheap.
- Designing the ledger's own look on the live page. Its progress strip was built five different ways in one day: three tanks of liquid, then a sea under three skies with a whale and a dolphin crossing on cycles that never line up twice, then flat colour under a single wave, then one line whose height eases through the gates. The mistake was not that I changed my mind — I asked for the sea animals, and I asked for them to go. It is that each version was built in full, in the one file the page is made from, while other agents were working in that same file. Five real builds, five reviews, and the collision that cost an evening — 776 million tokens, three quarters of what it cost to rewrite every model in the gallery. A look is nearly free to decide on a throwaway preview and expensive to decide on the real thing.
- An hour on the colour of one word. Approved went pink, then green, then pink again: "why is approved a red colour? It should be green, no?" and then "or actually it should be pink like the design." Both of those are reasonable; the problem is that neither was written down anywhere. The models had a contract to settle arguments like that in advance. The page that reports on the models had nothing, so every question about it was re-argued from scratch, in chat, in the week where messages were the scarce thing.
The end
Where it ended
The ledger says 135 of 135 approved, and the snapshot committed next to the code says the same: for every model, nine checks passing on the code as it stands today, plus a visual review and a text review with nobody's problem left open. (Eleven checks exist; nine of them judge a model, and the other two judge the site as a whole — 1,215 verdicts over 135 models is nine each, and the count has to add up here too.) The motion check passes 108 models outright; the other 27 raised things it cannot rule on by itself, and each one carries a named person's ruling with a reason. The raw run records stay out of the repository on purpose, so anyone who clones it reads that snapshot or runs the checks themselves rather than trusting my machine.
The release checklist has grown to 63 items, and 53 are done. Of those, 33 were re-proved by the ledger on the last afternoon; the other 20 are ticked with evidence written into their own line, for things no script can cheaply re-run. Ten are left: two that wait for a person, and eight that cannot be answered until the site is live.
On the last day the gate ran again, from a five-step chain written for one reason — I had said I would restart a check after changing its rule, then didn't, and the machine sat idle with nobody watching. A script cannot forget the next step. It printed:
``
13:23 start at 8dac66d
14:14 stages: 135/135 models are the same everywhere
14:19 compare: 135 demos, 0 differ, 0 could not be captured
16:56 gate: GATE HOLDS: every model held every check.
16:57 finished -- approved 135
``
Two hours thirty-six for the gate, against 1 h 34 the time before, almost all of it in one place:
check-exports renders real video and images from CSS 3D scenes, and headless Chromium has no GPU,
so every transform of every frame is rasterised in software. Eight models took 1 h 54 and 1 h 55
across two shards. Two Chromium GPU processes sat at 778% and 690% of one core — 14.7 of the
machine's 16 — and the laptop got hot enough to be noticed from across the room.
Then the list was split once, along the only line that matters: who answers it. Two items wait
for a person. The rest are answered by a script, or by an agent running one and writing what it
printed into the item's own line. The two are has the article been read and is it ready, and has
he said to push — a judgement about whether writing is good, and the decision to make 690 commits
public. Neither is a thing a machine has any business answering, and the push item says so in its
own text: not a green ledger, not GATE HOLDS, not 53 of 63 may be read as that permission.
The one open finding that was nobody's judgement and nobody's decision is gone, and how it went is
the better story. compare had said a single model, tunnel, drew 11.3% of its pixels differently
in an export than on screen — an infinite tunnel of thin bright lines flying at the camera, where
catching it a fraction of a second out lights up every edge. Measured three times, stable to a tenth
of a percent, unexplained for two days. The cause was four lines above the measurement: the code
that froze every animation before taking the picture ran on the top document, and every model lives
in an iframe. It had never frozen anything, for any model, ever. The other 134 passed because
nothing in them was moving fast enough to catch. Now the freeze runs in every frame of every
document, and the run reads 135 demos, 0 differ — with 81 of the 135 reporting animations found
and held, and the other 54 reporting that they have none.
798 commits are sitting on a local branch, 691 of them ahead of what the world can see.
And the part worth taking away is not the code. It is four sentences. A count must add up. A result must expire when the thing it judged changes, including the rule itself. "Didn't run" must never read as "passed." And a check nobody has seen fail is decoration. The eleven checks, the contract, the queue and the page that draws them are only what those four sentences look like once you have 135 of something and more than one worker.
What shipping looked like
<!-- WRITTEN AFTER THE PUSH, NOT BEFORE. Everything else in this article is counted from the git
history, the checks and the session logs; this section has to be counted from the deploy and
the live site, and neither exists yet. To fill in once git push origin main has run:
- the commit pushed, and how many commits went up at once
- gh run list --workflow deploy.yml — the run, and how long the build took on ubuntu-24.04
(this is the first time 691 commits' worth of this code has been built on Linux)
- whether the 135 models came up on the live site, and whether recording works there
- the eight checklist items that can only be answered after the push, ticked or not:
five about the served site, three tidy-ups deliberately deferred
- and the honest version of whatever went wrong, if it did
Do not write this section optimistically in advance. The whole article argues that a claim
has to be checkable, and a success story written before the success is exactly the thing it
argues against. -->
This section is written after the push, from the deploy and the live site. At the time of writing the branch is still local.
What I would tell someone starting
- Fix the thing, not the machinery around it. Deleting the measuring run cost a day and ended a whole family of bugs.
- Ask where you can see the state, early. The ledger existed ten minutes after I asked, and it changed how the rest of the week was run.
- Stop describing, start assigning. One day of explaining a single dialog message by message cost more than an afternoon of twelve agents rewriting 135 models against a written rule.
- Put the state in a file, not in the chat. Every "where are we?" is paid for twice: once to work it out, once to read it. A page you can look at for free is also a page that does not spend your week.
- Decide how it looks on something you can throw away. Rewriting all 135 models cost a billion tokens. One evening moving a progress strip around cost three quarters of that, and it is a progress strip.
- Let the machine work while nobody is talking. Hours of browser runs overnight came to a quarter of that evening: 214 million tokens against 776, and three points of a weekly allowance.
- Write the rule down before the agents start. Every batch read the contract first, so a decision made once stayed made.
- Break a copy on purpose. A check you have never watched fail is decoration.
- Set budgets by measuring. Every performance limit came from running all 135 models first.
- Give two agents two files. Where that is impossible, make one refuse to touch the other's, and say so in the commit.
- Let the rule bend before the design does. A rule that makes a good thing worse is a missing exception, not a bad design.
- Keep your own eyes in it. The ring that did not wrap its planet, the sun below the horizon and the rocket that shrank all passed every check that existed.
- Make "didn't run" impossible to mistake for "passed." Of twenty-three interruptions in six days, two were the app. Fourteen were the machinery doing the watching — a locked folder, a process id in the wrong namespace, a stale line in a log, a port left open. Every one of them produced a crash, an empty read or a stale answer, and every time the ledger refused to score it as a pass. That is the only reason a green page at the end of it means anything.
Six days, 798 commits, 135 models, 125 helper agents and eleven checks, spent almost entirely on making one gallery behave the same everywhere and on being able to prove it. The contract said what a model must satisfy and left the how alone. The ledger counted, expired its own answers honestly, and refused to read a crash as a pass. The checks caught a video's missing colour, a click that froze the browser and a bookshelf whose cover hung below the shelf. I caught a ring that did not wrap its planet and a rocket that shrank instead of launching. The agents made the work fast, the ledger made it finishable, and the last word on whether a thing looks right is still a person looking at it.
The lab is at css3dlab.edgarasneverdauskas.com and the code is open source at github.com/Evirtual/css-3d-lab. If you clone it, start at docs/START-HERE.md — what it is, what you deliberately do not get on a fresh clone, how to run the gallery and the ledger, and four paths depending on whether you want to add a model, change the layout rules, work on the ledger itself or ship. docs/index.html reads all of it in a browser and links to the ledger, which links back. The three files this article is really about are docs/VIEW-CONTRACT.md, docs/RELEASE-CHECKLIST.md and scripts/checks-registry.mjs. Built with Claude Code and Claude Opus 5.
