Unlisted case study

How Turbo Timer is built

One design runs on four Raspberry Pi clocks, in two renderers, built with AI coding sessions working in parallel. It holds together because its design decisions are written as contracts the build checks, and because every prototype is judged on the device.

Design engineeringPrototypingDesign systemHardwareAgentic workflow
Strokes face at night: a lattice of small two-hand dials, the centre ones lit orange to spell the time
Depletion face at night: an orange wedge of remaining waking time on a dark dial
Aperture face at night: large hour and minute numerals in boxes above the date
Daylight face at night: a 24-hour ring with the daylight arc in orange and a single hand
Strokes, Depletion, Aperture and Daylight in the night palette. Fresh 1080 by 1080 captures from the kiosk running main on 16 October 2026.

This page is the companion to the Turbo Timer case study. That one covers the product: a round smart display, its watchfaces and apps, and the admin that runs a fleet of them. This one covers how the design survives being built. The codebase is called SuperClock.

Every number here comes from the repository on 16 October 2026: the generated health report, the git log, and a full test run on a clean checkout of main. Every screen is a fresh capture, every wireframe is a plate from the working board, and every terminal is real output.

Year
2026, ongoing
Role
Sole designer and developer
Codebase
SuperClock, 374 commits since April
Fleet
4 Raspberry Pi clocks, 2 renderers
Proof
1,247 tests, 25 design rules, a build stamp per device
Stack
React 19, TypeScript, Vite, Express, C with LVGL
Admin
Phone first, at /admin. Every settings form is generated from a schema.
edits
fleet.json
The source of truth. Atomic writes; a corrupt file is quarantined.
pushed on save, retried every 60 s; each Chromium clock also polls every 5 s and keeps a last-good copy
Fast
Pi 5 · 1080 round · Chromium
Small
Pi 4 · 800 round · Chromium
Square
Pi 4 · 800 by 480 · Chromium
Slow
Pi Zero 2 W · 1080 round · C with LVGL, read-only
30 settings schemas generate the admin’s forms, validate the config on every clock, and are snapshotted so a breaking change cannot ship without a migration.
The system on one page. One admin, one fleet file, four clocks. Three run the React kiosk in Chromium; the Pi Zero runs one native face and is read-only in the admin.

Design drift is silent

A watchface that drifts off-design still renders. A setting that nothing reads still saves. On a clock that runs for weeks on a shelf, nobody is watching a console, so these failures look exactly like success. Four of them had already happened before I built anything to catch them.

  • A slider that did nothing. The admin’s day brightness slider had never worked. The display tool it called has no brightness flag in any released version, and the form still saved its default of 80 into every device’s settings.
  • Settings with no reader. Seven of the twelve faces with settings never read them. The admin drew a form for each one, and on glass the options changed nothing. Across the system, 23 of 66 settings fields had no reader.
  • The wrong twin. The project notes said the slowest clock ran a native copy of the Minimalismo face. It ran a copy of Analog, and the two renderers disagreed on the dial colour, the radius and every tick position.
  • Fixes that did not stick. A fix would merge, deploy and still be missing from the device, because the deploy script shipped my local checkout and the local checkout lagged behind the merged code.

Each one is a design decision that stopped being true without anyone noticing. This half of the project is built around that failure mode.

Write the decision down, then check it

The answer I kept reaching for was the same each time: write the decision in a form the build can read, then make the build check it.

Every face has a contract file beside its component. It states the face’s purpose, the one quantity allowed to use the accent colour, the night recipe and the colour roles it flips, an intent for each option, and the anti-patterns with the reason each one is banned. Faces drawn from hands and ticks also carry their geometry, and two of them now draw directly from those numbers. Tests hold every claim in the file against the source, so a face that stops reading its night roles fails the build.

Daylight face in the day palette: a 24-hour ring with the arc from 06:00 to 18:00 in orange, the rest in blue-grey, and one hand
DaylightClock.meta.json, abridged
Purpose
A 24-hour dial with noon at the top: one hand revolves per day and the ring shows sunrise to sunset in the accent, the rest in dusk.
Accent
the daylight arc
Night recipe
Dusk, ticks and ink flip with the palette; the daylight arc stays the accent.
Option intent, latitude
Leave 0,0 for the schematic 06:00 to 18:00 band; set both coordinates for real sun times.
Anti-pattern
Do not put midnight at the top. Why: noon at top is the solar convention (hand high means sun high), deliberately opposite to Depletion.
Daylight, captured from the kiosk, beside an abridged copy of its contract. The latitude intent is a decision written down, and the band in the capture is that decision on screen.

The latitude line exists because of a bug that looked plausible. The default location was 0,0, a point in the Gulf of Guinea that maps call Null Island. Real sun times for that point, shown in the clock’s own time zone four hours behind UTC, put sunrise at 02:03 and sunset at 14:10. The ring looked like data and was wrong everywhere on Earth. Now 0,0 means the location is not set, and the face draws a schematic 06:00 to 18:00 band until real coordinates arrive.

The same decision reaches the admin without anyone copying it. The settings form is generated from the face’s schema, so the hint under latitude is the schema’s own description, and a new face arrives with its form already built.

Admin fleet home at phone size: four clock cards, Fast online with the Daylight face rendered on its card, the other three offline
Admin screen config for Daylight Band on Fast: a live preview of the face, then latitude, longitude and show times fields generated from the schema
The admin at phone size, captured from a scratch copy of main. Left, the fleet, with the real Daylight component rendered on Fast’s card. Right, Daylight’s settings, generated from its schema.
Face contract
a .meta.json beside each face
contract tests
a claim about purpose, accent, night roles, options or geometry no longer matches the source
Colour roles
tokens.css, in three tiers
token gate, contrast and liveness tests
a face skips its night roles, a role holds a literal, an ink and surface pair drops below 4.5:1 in either mode
Settings schema
one file per app and face
snapshot gate, declared-vs-read report
a change would revert stored settings, or the list of fields nothing reads grows
Native twin
the C face for the Pi Zero
parity test, shrink-only ledger
a C constant drifts from the Analog contract outside the ledger
Registries
apps, faces, schemas
coherence and contract tests
an app or face is missing from a list it must be on
Design rules
25 records with a severity and a detector
the rule runner
a detector hits; rules that need a person print as unchecked
Every design decision on this page lives in one of these records, and each record has something that reads it.

Diffing the native twin

The slowest clock is a Pi Zero 2 W. It cannot run the browser face acceptably, so it runs one face written in C with LVGL, a small graphics library for embedded screens. Two renderers of one design drift apart, and the board plate for the clock app listed exactly that as an open risk: parity had no automated check.

Clock app wireframe plate: face cycling, the Minimalismo face, the Complications face and night mode, with numbered notes underneath
The clock app plate. Its notes state the parity rule and, in the last line, the open risk that nothing checks it. They also name the wrong face as the twin.

So now something checks it. A parser reads the geometry and colour constants out of the C file and compares them, number by number, with the Analog contract. Its first run found nine differences. React draws a black dial and C draws a white one, and C draws the face at a smaller radius, so every tick sits 40 units further in.

React, on the Chromium clocks
  • Black dial fills the disc, radius 500
  • Hour ticks 420 to 480
  • Minute ticks 435 to 460
C with LVGL, on the Pi Zero
  • White face of radius 460 on black
  • Hour ticks 380 to 440
  • Minute ticks 395 to 420
Both dials drawn here from the numbers in each renderer’s source, in the same 1000 unit space. Every tick on the C face sits 40 units further in.

They are still unfixed, on purpose. Which renderer is right is a design call, and a check should never make that call by turning green. The nine differences sit in a ledger, each with its reason, and the ledger may only shrink. Any new drift turns the build red.

The React Analog face: white ticks and hands on a black dial with a yellow second hand
  1. face.background
    React draws a black dial, the C a white one; the two renderers were never reconciled
  2. face.ink
    React ink is white on black, C ink is black on white; follows the background decision
  3. face.ink.tick
    React draws tick marks in ink (white), the C ticks are drawn black; follows the background decision
  4. face.ink.minute
    React draws the minute hand in ink (white), the C minute hand is drawn black; follows the background decision
  5. radius
    React fills the whole 1000 disc; the C draws a 460 face inside a black backdrop
  6. ticks.hour.outer, ticks.hour.inner, ticks.minute.outer, ticks.minute.inner
    the C ticks sit 40 units inside the React ones because its face radius is 460
The React Analog face, captured from the kiosk, and the nine ledgered differences from its C twin, quoted from the ledger.

Settings are a contract with every device

Each clock stores its own settings, validated against the same schemas the admin uses to generate its forms. Renaming a field or dropping an option looks harmless in a pull request. On the fleet, every stored value that stops validating falls back to its default, and a person’s choices revert on every clock at once.

So the schemas are snapshotted. Every change is classified as breaking, minor or patch, and the snapshot tool refuses a breaking change until the fleet schema version moves, which pulls the migration into the same change.

Terminal: npm run snapshot:schemas reports face.daylight.showTimes property removed as BREAKING and refuses because FLEET_SCHEMA_VERSION is still 2
One field removed from the Daylight schema in a scratch copy of main. The tool names the change, classifies it, and refuses until the migration exists. The change was thrown away.

The brightness fix shows why that matters. Dimming moved to a CSS filter on the page, which made the stored brightness values real for the first time. They read 80 on every device, saved by the inert form, so the next deploy would have dimmed every panel. Those values carried no intent, and version 2 of the fleet schema clears them.

A generated report answers the question from the other side: for every app and face, which settings fields its code reads. An unread field is a setting the admin writes and nothing reads. The report is committed, the test suite fails when it is stale, and the unread list may only shrink.

Measure5 Sep 202616 Oct 2026
Faces that read their own settings5 of 1212 of 12
Apps that read their settings12 of 1417 of 17
Settings fields nothing reads23 of 666 of 83
From the first committed report on 5 September 2026 to today.

What the hardware decided

Night mode inverts every face through eight shared colour roles. Each contract names the roles its face flips, and the build fails if the face stops reading them. Seven older faces predate the rule. They sit on an exemption list that may only shrink, so the debt stays visible.

Strokes face, day palette
Strokes, day.
Strokes face, night palette
Strokes, night.
Depletion face, day palette
Depletion, day.
Depletion face, night palette
Depletion, night.

Strokes, a lattice of 52 two-hand dials, is perfectly still between minutes. Only the hands that change move, once a minute. On a clock that runs for weeks, idle motion is constant GPU work and heat on a small board, so stillness is written into its contract as an anti-pattern to avoid. The same reasoning makes every app tick only while it is on screen, and that rule is a blocker in the catalogue.

Touch was decided on the panel too. The navigation plate drew an outer ring about 70 pixels wide on the 1080 pixel screen, split into arcs at twelve, six and nine. In code the ring is a fraction of the radius, 70 over 540, so it keeps its proportion on the 800 pixel clock.

Kiosk navigation interaction spec plate: zone anatomy, settings peek, quick settings, app grid, back gesture, and panic and idle return, with numbered notes
The navigation plate. Frame 0 is the zone anatomy that became the gesture constants; the notes underneath name what was still to be tuned on the device.

What stays human

Six of the 25 rules cannot be checked by a script: no fabricated data, one accent per face, nothing important outside the disc, every state designed, contrast on the surface the text actually sits on, and the admin at 375 pixels. The rule runner prints them as unchecked on every run, so the gap is stated each time.

Terminal: npm run check:rules finds one ICO-2 blocker in the Todo app, then lists the six unchecked rules and the four rules delegated to other gates
The rule runner on main. The one hit is an emoji icon in the Todo app, frozen as baseline debt so it cannot grow. The unchecked line is the list above.

State design is the clearest of the six. The board holds a shared kit of nine states, from loading to overflow, and on glass the rule is that an app that fetches never shows fallback data as live.

State patterns plate: nine round frames for loading, not configured, offline cached, offline with no cache, empty, capability absent, error retrying, night and overflow
The shared state kit: nine non-happy states drawn once, with one tell slot every app reuses.
Countdown app: No date yet, set a target date for this screen in the admin
Countdown, no date set.
Calendar app showing the date with a calendar not configured tell
Calendar, no feed configured.
GitHub app with a faint ring and a not connected label
GitHub, no token on the server.
Weather app showing only a weather offline label
Weather, offline.

Those four come from a clean checkout where nothing is configured yet, which is exactly when a quieter product would show sample numbers.

From plate to glass

The faces in this page started as plates. An archetype study on the board distilled 18 structures: nine pulled out of reference faces with the styling removed, and nine more from horology, platform docs and industrial clock design. Four of them were built.

Archetype study sheet 02: nine round wireframe plates, numbered 10 to 18, each with a family and a one-line description
Sheet 02 of the archetype study. Plates 11, 12, 13 and 17 became shipped faces.
Board plate 11 Aperture plate
Aperture face as shipped
11 Aperture plate, then Aperture on the kiosk.
Board plate 12 Daylight band
Daylight face as shipped
12 Daylight band, then Daylight on the kiosk.
Board plate 13 Depletion disc
Depletion face as shipped
13 Depletion disc, then Depletion on the kiosk.
Board plate 17 Composed strokes
Strokes face as shipped
17 Composed strokes, then Strokes on the kiosk.
Plate above, shipped face below, captured from the kiosk today.
  1. 01 · frames and notes

    Board plate. Each app and face starts as a plate on the design board, with its data contract, settings schema and open risks written next to the frames.

  2. 02 · a dated spec

    Approved spec. A written spec records the direction I approved and the decisions behind it.

  3. 03 · registry entries, a red test

    Scaffold. A scaffolder adds a new app or face to every registry it must appear in, with a failing test that stays red until the component is real.

  4. 04 · a green run

    Gates. Lint, the token gate, 1,247 tests and the build, in that order, locally and in CI.

  5. 05 · a build stamp on the device

    Stamped deploy. Every device reports the commit it runs, and the deploy script checks that commit after it ships.

  6. 06 · the clock itself

    On glass. The last check is the device: the face renders, the hands sweep, the touch zones answer.

The working loop, with what each step leaves behind.

Step three is easiest to see by doing it. Scaffolding a throwaway face turns 15 tests red across 9 files, and they stay red until the face has a component, a schema, a contract and preview art.

Terminal: npm run new:face scaffolds moonphase and prints seven next steps; the following test run fails 15 tests in 9 files
A face called moonphase, scaffolded in a scratch copy of main and thrown away afterwards. Every red line names the step still owed.

Most of the code is written by Claude Code sessions working in parallel git worktrees, handing work to each other through a shared agent log. 296 of the 374 commits on main carry a Claude co-author line. I design the system, make the calls the gates cannot, and decide what merges. The contracts and gates make that division of labour safe: a session that drifts off-design hits a red build long before it reaches a clock.

The deploy check had a bug of its own. Its first version could never pass over the local network, because resolving a .local name took longer than its three second budget, so a perfect deploy reported failure. A check that cries wolf is worse than no check, because it teaches you to ignore it. It was fixed the day it was caught.

Where it stands

Apps
17, every one reads its settings
Faces
13 in the swipe cycle, 12 with settings, all 12 read them
Settings schemas
30, each read by the component that owns it
Design rules
25 records; 6 need a person or a browser and print as unchecked on every run
Tests
1,247 in 102 files, passing on a clean checkout
Native parity
9 known differences, ledgered, may only shrink
History
374 commits since 24 April 2026
Kiosk app grid: a cluster of round app and face previews
The app grid.
Timer app: a one minute dial with 1, 3, 5 and 10 minute presets
Timer, new in October.
Habits app: six habit bubbles around the date
Habits.
Todo app: an empty list with an add button
Todo, empty.

Reflection

What I would do differently. Two parallel sessions each built a catalogue of design rules, and both survived the merge because neither was wrong. Consolidating them is still an open decision. Parallel sessions need a shared list of what is being built, alongside the log of what is done.

What I would keep. Ledgers instead of silent fixes. Every exemption list here may only shrink, and every entry carries its reason, so debt stays visible and nothing turns green by redefining the rule.

A trade I paid for. The first token check counted a token named in a code comment as a real use. That was accepted on purpose to ship the check sooner, and it cost time four times in one sub-project before the whole class was closed by stripping comments first. A check that can be satisfied by describing the thing ends up checking the description.

Unlisted. This case study is reachable only by URL and is not indexed. The product story is in the Turbo Timer case study.