go-bananas/docs/styling-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

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

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

  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:

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.