Engineering notes
Measurement

Theme updates: performance baseline

A radius edit went from 54 CSS-variable writes to 1. This note records the measurements, their limits, and the unit tests that now check which variables each operation returns.

PerformanceMeasurementRegression testing

Measured 2026-08-06, before any change to lib/theme/. Commit: ae009eb.

Recorded ahead of the Active Theme refactor (see CONTEXT.md, docs/adr/0002-clamp-invalid-theme-values-silently.md) so that "this must not get slower" is checkable rather than argued.

Method

Dev server (npm run dev), Chromium via Playwright, /themes/editor. CSSStyleDeclaration.prototype.setProperty wrapped to count calls and record variable names. Counter reset immediately before each interaction, read 300ms after — long enough for the React commit and effects, shorter than the 500ms persistence debounce, so localStorage and URL-encoding work is excluded.

Call counts are app logic and identical in dev and production. Wall-clock timings are dev-mode and indicative only.

Results

InteractionsetProperty callsUnique varsTime in setProperty
One single-field edit (radius, +1 step)54540.10 ms
Colour Mode toggle (light → dark)52520.10 ms
Colour Mode toggle (dark → light)5252—

Breakdown of the 52 on a Colour Mode toggle

GroupCountChanges between modes?
Colour vars32yes
Shadow vars (--shadow*)14no
Font vars3no
--radius, --spacing, --tracking-normal3no

20 of the 52 writes on a mode toggle are provably redundant — those values live on the Theme, not on either palette, so they are rewritten with identical values.

For a single-field edit the redundancy is worse: 53 of 54 writes are unchanged values.

After the Active Theme seam (measured 2026-08-08)

A single "single-field edit" number turned out not to exist. Different fields own different numbers of variables, so the table is per-operation. All fourteen --shadow* variables derive from the same five numbers plus a colour, so a shadow edit costs 14 and always will; layout is structural and costs 0.

OperationVariable mapBeforeMeasured after
editRadius, editColour, editFont1541 ✓
editFonts354—
editShadow1454—
editLayout054—
switchColourMode325231 ✓
applyPreset / loadTheme / resetToDefault525247 ✓
Any operation that changes nothing—540

Two numbers come in under their map size because writes are filtered against what is already on the document (changedVars): the mode toggle found one colour identical across both palettes, and the preset swap found five values shared between the outgoing and incoming Themes. Filtering applies to the write only — state and identity always update, so re-picking the Preset already applied still resets identity per ADR-0001.

The filter is also what keeps page load at one full apply. DarkModeToggle runs its own mount effect that settles the Colour Mode a second time; without filtering that would have added 32 writes to every page load, which the interaction-only measurements above would never have caught.

Where this is enforced

tests/active-theme.test.ts asserts the Variable map column directly — each operation returns a Record<string, string>, and the tests assert its size and contents. That runs in npm test with no browser.

The browser method below is now a once-per-refactor sanity check, not the enforcement mechanism. It is still the only thing that can catch a regression caused by something other than call volume — a write moved to a worse point in the frame, or a second caller re-applying behind the seam's back.

Re-running the browser check

Same method as the baseline. The instrument is ~20 lines injected into the page: wrap setProperty, count calls, reset before the interaction, read after 300ms. Filter out variables the theme system does not own — Base UI's popover positioning (--anchor-width, --transform-origin, and friends) writes a dozen of its own whenever a dropdown is open, and they are not ours.

A regression shows up as a count that went up, which needs no timing precision to detect.

Caveat

setProperty call count is a proxy for style-invalidation work, not a frame timing. It is the right proxy here because the change is purely "make fewer calls" — but it will not catch a regression caused by something other than call volume, such as a write moved to a worse point in the frame. If frame timing matters later, that needs a separate profile against a production build.

Source: docs/theme-perf-baseline.md in the repository that runs this site. Inspect the theme module and its tests.