Skip to content
← All decisions

A theme is a compiled config, not a colour

accepted

Context

The first version of the theme playground on this site took a brand hex, generated a colour ramp, and re-skinned a storefront. It was technically the hardest thing on the page — perceptually even OKLCH ramps, gamut mapping, WCAG contrast correction — and it read as a colour picker.

That reaction was correct, and it is worth being precise about why. One input, one output class. A reviewer sees a colour change and reasonably concludes the system changes colours. Nothing about that demonstrates the thing I actually claim: that a framework can generate a different store, not a recoloured one.

A storefront is defined by its density, its rhythm, its shape language, its type hierarchy, and above all its structure. Colour is the last item on that list.

Options considered

More colour controls. Secondary colour, accent pairs, gradient stops. This is the trap — it increases the number of inputs without changing what class of thing the output is.

A visual theme builder with free numeric inputs. Sliders for radius, spacing, font size. More expressive, but a config of arbitrary numbers cannot be validated, cannot be migrated when the schema changes, and cannot be reasoned about by whoever inherits it.

A closed config schema, compiled. Every axis is a small union of named options. The engine is a pure function from that config to a complete token system plus structural decisions.

Decision

The input is a file:

{
  "seed": "#e0541f", "scheme": "dark",
  "radius": "soft", "density": "comfortable", "elevation": "bordered",
  "typeScale": "default",
  "columns": 4, "imageRatio": "4/3", "cardStyle": "stacked"
}

compileTheme(config) returns colour roles, spacing derived from a single density step, a modular type scale, elevation strategy, and structure — which the storefront branches on. cardStyle: 'stacked' | 'overlay' | 'horizontal' produces three genuinely different components: different DOM order, different layout axis, different image-to-text relationship. That is the line between theming and generation.

Every field is a closed union rather than a free value. This is the decision I would defend hardest. Add a variant to CardStyle and every consumer stops compiling until it is handled. A theme system whose inputs are arbitrary numbers has no such property.

The playground exposes the config as editable JSON alongside the controls, both driving the same object. That is deliberate: a client theme is something you commit and review in a pull request, not a state someone clicked their way into.

Tradeoffs accepted

  • Closed unions limit expressiveness. A client who wants a 17px radius cannot have one. I think that is the right trade for a system meant to produce many coherent stores rather than one exact one, but it is a real constraint and it will eventually annoy somebody.
  • Nine axes is more than a reviewer will read. Four whole-personality presets exist because the interesting claim is not "nine things are configurable", it is "nine things move together coherently" — and that lands in two clicks instead of nine.
  • Structural variants multiply the testing surface. Three card layouts times four presets is twelve combinations that must all work, and only a gate makes that tractable.

Result

Four presets produce four measurably different stores from one engine — 1 to 4 columns, 0px to 28px radii, 116px to 360px card heights, flat to raised, light and dark.

The accessibility guarantee survived the expansion: 144 colour role pairs across every preset and both schemes are contrast-checked at build time, and auto-corrected where they fail.

What I would do differently

Two things, both found by looking at the running page rather than by reading the code.

I had --color-surface contrast-corrected to 3:1 against the page ground. That is wrong — a raised surface is a background, not a UI component, so WCAG 1.4.11 does not apply to it. Forcing it dragged a #373937 card up to #6c6e6b and every product tile rendered mid-grey. The thing that must be perceivable at a card's edge is its border, which is checked. I added a third role state: reported, never corrected. I would design that state in from the start rather than discovering it on screen.

My first overlay card had no Add button because it composed better without one. A layout variant may restyle an affordance; it may not delete one. That rule now lives in a comment next to the code that nearly broke it, and the gate counts primary actions per card across every variant.