8.3 KiB
The shared styling contract
One stylesheet and two controls, adopted by adding them to a page. Nothing in an adopting application's own CSS has to change.
- What it is
- Adding it
- The two controls
- The token vocabulary
- Migrating an application
- Verifying an adoption
What it is
Six applications render design decisions six different ways. This is the layer they all sit on: a set of custom properties on the root element, scoped by a palette and an appearance, plus a compatibility block for the names those applications already use.
:root[data-theme='tokyo'][data-mode='dark'] {
--bn-bg: #1a1b26;
--bn-surface: #20222f;
/* ... */
}
/* Compatibility: applications that already use these names keep working. */
:root[data-theme='tokyo'][data-mode='dark'] {
--bg: var(--bn-bg);
--surface: var(--bn-surface);
}
Two things are deliberate and worth understanding before adopting it.
The namespaced names come first. --bn-bg cannot collide with anything on the page
that receives the stylesheet. An unprefixed --surface is a collision waiting to happen,
and which value wins depends on cascade order.
The unprefixed names come second, as aliases. They reference the namespaced ones
rather than repeating the values, so there is exactly one place a colour is written.
This is what makes adoption cheap: an application that already uses --bg, --surface
and --line keeps working, and deleting its own :root block is the whole migration.
Adding it
<head>
<link rel="stylesheet" href="https://themes.example.com/rt/v1/contract.css" data-theme-contract>
</head>
<body>
<nav>
<bananas-theme-selector></bananas-theme-selector>
<bananas-appearance-toggle></bananas-appearance-toggle>
</nav>
</body>
<script type="module" src="https://themes.example.com/rt/v1/components.js"></script>
data-theme-contract marks the stylesheet so the selector can find it. The marker is
optional — the selector also accepts any stylesheet whose href ends in
contract.css — but it is unambiguous, and it is what makes the selector work with no
base attribute.
Configuration
| Setting | Where | Default |
|---|---|---|
| Service origin | the contract.css href |
— |
| Palette | data-theme on the root element |
the element's own attribute |
| Appearance | data-mode on the root element |
admin, meaning no theme |
| Persistence | localStorage, key go-bananas:appearance |
per origin |
Cross-origin deployments
The stylesheet needs no CORS. The components.js module and the contract.json fetch
behind the selector both do, so a page on a different origin from the service needs that
origin in the service's CORS_ALLOWED_ORIGINS:
BANANAS_CORS_ALLOWED_ORIGINS=https://app.example.com
A same-origin deployment needs nothing configured. One setting is the entire cost.
The two controls
Both are custom elements with a shadow root. That is not decoration:
- Page CSS cannot reach inside, so a global
button { all: unset }or a Tailwind preflight cannot restyle them. - Their CSS cannot leak out, so they cannot restyle the page. This is the same property that makes a live preview of a palette safe: the preview's styles stay in the preview.
- Custom properties still cross the boundary, so the controls follow the palette they are switching without one extra rule.
Custom elements rather than framework components, because the applications are a mix of vanilla JS, React and Tailwind. A React component is unusable by the first; a custom element works in all three with no build step and no import map.
| Element | Purpose | Attributes |
|---|---|---|
<bananas-theme-selector> |
picks the palette from contract.json |
base, label, compact |
<bananas-appearance-toggle> |
admin chrome, light, or dark | label, show-labels |
Both take a value property for framework interop, and emit a bubbling composed event:
bananas:theme with {slug, mode}, bananas:appearance with {mode}.
Stying hooks: ::part(button), ::part(group), and --bn-control-radius,
--bn-control-gap, --bn-control-padding, --bn-control-font.
Admin is a third state, not a fourth mode
The appearance toggle has three positions, and admin is not light or dark. It means
"no theme": the application's own chrome. An administration screen has to look like the
administration screen even while a palette is being previewed, and that is a different
question from light or dark.
Building your own control
The store is exported from the same module, so a bespoke control is a few lines:
import { getContractState } from 'https://themes.example.com/rt/v1/components.js'
const state = getContractState()
state.subscribe(({ theme, mode }) => render(theme, mode))
state.selectTheme('tokyo')
state.selectMode('dark')
The token vocabulary
Surfaces, from the page background up to the most raised:
| Token | Role |
|---|---|
--bn-bg |
page background |
--bn-surface |
card |
--bn-surface-raised |
popover, menu, raised panel |
--bn-surface-sunken |
inset well, track |
--bn-overlay |
scrim behind a dialog |
Text, in descending emphasis: --bn-text, --bn-muted, --bn-subtle, --bn-inverted.
Chrome: --bn-border, --bn-border-strong, --bn-focus.
Accent: --bn-accent, --bn-accent-hover, --bn-accent-active, --bn-on-accent,
--bn-secondary, --bn-on-secondary.
Status: --bn-good, --bn-warning, --bn-danger, --bn-info.
The geometry, spacing and typography scales are re-exported rather than re-derived, so
--bn-radius-md is the very variable the theme defines and an override in the theme's own
stylesheet still wins.
Interaction states are built with color-mix rather than a hand-tuned ladder, so one
accent serves every state and the ladder cannot drift between palettes:
.card-action:hover {
background: color-mix(in srgb, var(--bn-accent) 12%, var(--bn-surface));
}
Migrating an application
- Add the link and the script. Nothing else. The application is now themed, and its
own
:rootblock still wins for any property it defines. - Delete the application's
:roottheme colours. The unprefixed aliases take over. Keep the rest of the stylesheet; it does not need editing. - Replace the hand-rolled switcher with the two elements, and delete the script that
drove it. This matters: two scripts writing
data-thememeans whichever loaded last wins. - Optionally rename
--bgto--bn-bgand drop the aliases. This is the only step that touches call sites, so it is the one to defer.
Palettes available by default
Eleven, and their slugs deliberately match the applications that own them so that adoption does not require renaming a stored value:
| Slug | Origin | Default |
|---|---|---|
midnight, daylight, mono, high-contrast |
go-bananas | mixed |
urpi-standard |
go-guess | light |
tokyo, catppuccin, tomorrow, kanagawa |
go-loose | mixed |
nordic, obsidian |
go-tell | dark |
Every palette has both a light and a dark scheme. Three of the source applications had only one, so the other is derived; those are marked as derived in the theme's description.
Verifying an adoption
Two checks, both of which run headless Chromium and both of which are repeatable:
make check-contract # the contract and the controls, 16 assertions
make check-pilot # both adopting applications, 20 and 23 assertions
check-pilot is the one that matters. Each of its two runs loads an adopting
application's real built shell and its real stylesheet, changes only the theme
origin, and then asserts that clicking the shipped components moves data-theme and
that the application's own CSS followed.
- go-loose (20 assertions) kept its hand-written palette. The components drive it through the attribute its CSS already keyed on.
- go-tell (23 assertions) was fully tokenised: it had no custom properties at all,
340 hardcoded hex values and 153 per-palette override rules. Its stylesheet now reads
from the contract, and the check also asserts that the
terminalpalette's globalfont-family: ... !importantreaches the application but not into the controls' shadow roots.
If both pass, the consolidation is real rather than decorative.