125 lines
5.8 KiB
Markdown
125 lines
5.8 KiB
Markdown
<!-- go-doc:generated:begin source=go-bananas:docs/adr/0005-shared-token-contract.md signature=7d8b696ba971c1cbd2e19854d26de304 generated=2026-10-05 18:14 UTC -->
|
||
Imported from docs/adr/0005-shared-token-contract.md in the go-bananas repository.
|
||
|
||
---
|
||
|
||
# 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
|
||
<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:
|
||
|
||
```css
|
||
: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.
|
||
<!-- go-doc:generated:end -->
|