5.5 KiB
ADR 0005: One token contract for every application, with a legacy alias layer
- Status: accepted
- Date: 2026-10-05
- Revisit when: an application adopts Tailwind v4 as its only styling mechanism, which would let the alias layer be dropped
Context
Six applications consume design decisions today, each in its own dialect:
| Application | Mechanism | Palettes | Light/dark | Token indirection |
|---|---|---|---|---|
go-doc |
Tailwind v4 @theme |
1, dark hardcoded | none | --color-ink-* |
go-guess |
one 2800-line CSS file | 1, light hardcoded | none | 11 :root vars, mostly bypassed by ~120 hardcoded hexes |
go-loose |
plain CSS | 4 × 2 modes | works | [data-theme=X] + [data-mode=light] |
go-mad |
plain CSS, copied from go-loose |
1 × 2 modes | CSS present, JS dead | [data-mode=X] |
go-places |
JS-injected <style> |
2 objects | broken | inline custom properties |
go-tell |
plain CSS, no custom properties at all | 4, all dark | none | 340 hardcoded hexes |
Four problems follow from that, and only one of them is aesthetic:
- No shared vocabulary.
--navyingo-guessand--surfaceingo-loosemean unrelated things, so nothing can be shared and no token can be changed in one place. - No light mode in three of six. A theme is the wrong level of abstraction to fix this: the missing axis is appearance, and a theme that is dark-only cannot express it.
- Two incompatible theme mechanisms.
go-loosesetsdata-themeanddata-modeas separate attributes;go-tellsetsdata-themeonly, with the mode baked into each palette. An application can adopt one or the other, not both. - Tailwind is a different language. A Tailwind application cannot consume a
:rootblock; it needs@theme.
The brief was to consolidate, find the middle ground, and make that styling available to every application.
Decision
Adopt go-loose's two-attribute model as the contract, because it is the only
mechanism that is both working and already copied by another application:
<html data-theme="tokyo-night" data-mode="dark">
Two orthogonal axes. data-theme is the palette, data-mode is the appearance. Every
theme therefore has both a light and a dark rendering, which fixes problem 2 without
inventing a second concept.
Token names are namespaced with --bn-, and the rendered stylesheet emits a legacy
alias block underneath:
:root[data-theme='tokyo-night'][data-mode='dark'] {
--bn-bg: #1a1b26;
--bn-surface: #20222f;
/* ... */
}
/* Compatibility with applications that already use these names. */
:root[data-theme='tokyo-night'][data-mode='dark'] {
--bg: var(--bn-bg);
--surface: var(--bn-surface);
--text: var(--bn-text);
--muted: var(--bn-muted);
--line: var(--bn-border);
--accent: var(--bn-accent);
--good: var(--bn-good);
--danger: var(--bn-danger);
--radius: var(--bn-radius);
}
The alias block is the middle ground made literal. go-loose and go-mad keep working
unchanged, and the other four applications can migrate by deleting their own :root
block and renaming nothing.
Rationale
Namespaced names, because the stylesheet is injected into someone else's page. An
unprefixed --surface on a host page is a collision waiting to happen, and the host's
value wins or loses depending on order. --bn-bg cannot collide with anything.
Aliases, because a rename is a coordinated six-application change. Emitting both
means an application adopts the contract by adding one <link>, with no call sites
edited. That is the difference between a migration that happens and one that is
proposed.
Two axes rather than N themes. Six applications currently have 13 palettes between them, several of which are the same palette twice. Splitting palette from appearance turns 13 one-dimensional choices into 7 palettes × 2 modes, and makes "give me a light version" a solvable problem instead of a request for a new theme.
Attributes, not classes. [data-theme=X] beats .theme-x because an attribute is
one axis for two features, and because a consumer can set it in the <head> before
first paint. A class list forces the two axes into an ordering the cascade then has to
be told about.
color-mix, not extra tokens. go-loose and go-mad already derive hover and
subtle states with color-mix(in srgb, var(--bn-accent) 12%, var(--bn-surface)). That
is kept: it means one accent serves every interaction state, instead of a hand-tuned
ladder that drifts between themes.
--bn-font-* and --bn-radius join the set. Both applications that have a scale
disagree (go-loose uses 16px cards, go-mad 8px; go-doc uses Tailwind defaults).
Consolidation without them would leave typography and geometry app-local.
Consequences
- The rendered stylesheet grows by the alias block. It is small, it is compressible, and it is removed entirely once an application has renamed its own references.
go-docneeds a three-line Tailwind bridge, because@themecannot read a runtime custom property. It becomes@theme inline { --color-bn-bg: var(--bn-bg) }.go-tell's four themes are palettes with the appearance baked in. They become fourdata-themevalues with their dark values as thedarkmode, and their light counterparts have to be authored. That work is the honest cost of the migration.- Appearance preference is per-application. Two applications on the same browser do not
share a
data-modeunless they agree on a storage key; the shipped components use one key per origin and document it.