go-bananas/docs/adr/0005-shared-token-contract.md
Daniel Noulet 951dd8d26b
Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled
initial commit
2026-10-07 15:11:35 +02:00

5.5 KiB
Raw Blame History

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:

  1. No shared vocabulary. --navy in go-guess and --surface in go-loose mean unrelated things, so nothing can be shared and no token can be changed in one place.
  2. 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.
  3. Two incompatible theme mechanisms. go-loose sets data-theme and data-mode as separate attributes; go-tell sets data-theme only, with the mode baked into each palette. An application can adopt one or the other, not both.
  4. Tailwind is a different language. A Tailwind application cannot consume a :root block; 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-doc needs a three-line Tailwind bridge, because @theme cannot 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 four data-theme values with their dark values as the dark mode, 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-mode unless they agree on a storage key; the shipped components use one key per origin and document it.