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.