217 lines
8.3 KiB
Markdown
217 lines
8.3 KiB
Markdown
|
|
# 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
|
||
|
|
<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`:
|
||
|
|
|
||
|
|
```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 |
|
||
|
|
| --- | --- | --- |
|
||
|
|
| `<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:
|
||
|
|
|
||
|
|
```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.
|