Engineering notes
Design writeup

One owner for every write

How one module now owns theme updates from the editor, assistant, shared URLs, and storage, including validation, identity, and targeted DOM writes.

ArchitectureDeep modulesState designTesting

The theme system on this site lets visitors change 52 CSS variables for colours, fonts, radius, shadows, spacing, and tracking, along with the layout. Changes can come from the Theme Editor, a share link, local storage, or the AI assistant. All four update the same Active Theme and apply it to the page.

The first version used React context to hold a ThemeConfig, setters to update it, and effects to write values to document.documentElement. As I added ways to change the theme, validation and DOM updates became harder to trace.

What actually went wrong

I found three problems with how the code handled updates.

Validation differed by entry point. The editor clamped colours one way, the URL decoder another, and the assistant's tool calls did not clamp at all. An out-of-gamut colour from the assistant reached setProperty, and the browser clamped it at paint. State then held a value that differed from the rendered colour. Share links and CSS exports carried that uncorrected value forward.

DOM writes were scattered. Different setters applied different subsets of variables. To find out which variables an edit changed, I had to trace the effects it triggered. Measuring writes required instrumenting setProperty in a browser.

Identity required repeated comparisons. On every edit, the code compared the theme's values against every preset to decide whether it was still a named preset or had become custom. That included every update during a colour drag.

These defects shared an architectural cause: no single module owned the rules for a theme update.

The seam

lib/theme/active-theme.ts now owns every write to the Active Theme and the mapping from a change to the CSS variables it affects. It is pure: no React, no DOM, no clock, no storage.

Its interface is a set of operations rather than a reducer:

editColour(state, mode, key, value) → { state, vars }
switchColourMode(state, mode)       → { state, vars }
applyPreset(state, presetId)        → { state, vars }
resetToDefault(state)               → { state, vars }

Each returns the next state and exactly the variables that changed. There is no exported dispatch, no action union, and nothing else in the codebase may construct a ThemeConfig and hand it to the DOM.

This changes how the code handles each problem:

  • Validation runs before state changes. clampColour and the other validators run at the operation boundary. Share links, CSS exports, and JSON exports receive values that have already passed those checks. This is ADR-0002.
  • The DOM writer applies the returned map. apply.ts writes vars and reports which properties the document accepted. A value the browser rejected stays eligible for rewriting.
  • Identity records the edit history. A theme edited away from a preset stays custom, even if every value is later dialled back to match. This is ADR-0001.

Results and limits

The returned variable maps let tests check the cost of an update. Before the refactor, a single-field edit wrote 54 variables and a Colour Mode toggle wrote 52. I measured those counts by wrapping setProperty in a browser.

After the refactor, the measured radius edit wrote 1 variable and the mode toggle wrote 31. An operation that changes nothing writes 0.

Every operation returns a Record<string, string>, so tests/active-theme.test.ts can assert each map's size and contents without a browser. Those checks run in npm test. They check the variables an operation returns, not browser frame timing. The measurements, method, and caveats are in the performance baseline.

The pure module also provides a way to address a deferred problem. The site still flashes the default theme on first paint. An inline <script> in <head> could set variables before hydration, but a hand-written script would duplicate the storage key, 32 colour variable names, shadow derivations, and clamping rules. If the two implementations drifted, visitors could see the wrong theme for a frame.

Because the module imports no React and needs no browser, it can run at build time to generate that script. ADR-0003 records this requirement before the fix is implemented.

What I would reuse

The design rule I would use again is:

Every write to this piece of state goes through one module, and that module returns what changed.

Having one module own updates keeps callers from implementing their own validation and identity rules. Returning the changed variables makes the expected writes visible to tests.

I could have stopped at returning the next state. Returning the variable map as well made it possible to test which writes each operation requires.

Source: docs/engineering/one-owner-for-every-write.md in the repository that runs this site. Inspect the theme module and its tests.