Imported from docs/styling-contract.md in the go-bananas repository.
---
# 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](#what-it-is)
- [Adding it](#adding-it)
- [The two controls](#the-two-controls)
- [The token vocabulary](#the-token-vocabulary)
- [Migrating an application](#migrating-an-application)
- [Verifying an adoption](#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.
```css
: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
```html
```
`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`:
```bash
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 |
| --- | --- | --- |
| `` | picks the palette from `contract.json` | `base`, `label`, `compact` |
| `` | 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:
```js
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:
```css
.card-action:hover {
background: color-mix(in srgb, var(--bn-accent) 12%, var(--bn-surface));
}
```
## Migrating an application
1. **Add the link and the script.** Nothing else. The application is now themed, and its
own `:root` block still wins for any property it defines.
2. **Delete the application's `:root` theme colours.** The unprefixed aliases take over.
Keep the rest of the stylesheet; it does not need editing.
3. **Replace the hand-rolled switcher** with the two elements, and delete the script that
drove it. This matters: two scripts writing `data-theme` means whichever loaded last
wins.
4. **Optionally rename** `--bg` to `--bn-bg` and 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:
```bash
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 `terminal` palette's global
`font-family: ... !important` reaches the application but not into the controls'
shadow roots.
If both pass, the consolidation is real rather than decorative.