CI / verify (pull_request) Successful in 1m10s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 52s
CI / migration-smoke (pull_request) Successful in 2m20s
CI / image-build (pull_request) Successful in 1m5s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / package-publication (pull_request) Successful in 58s
CI / deploy-smoke (split) (pull_request) Successful in 2m38s
The theme foundation DESIGN.md requires (ADR 0060): - A design token may carry a `dark` value and a `reduced` value. The token stylesheet selects them in CSS alone: dark under prefers-color-scheme unless the host chose light, or when it chose dark; motion durations zero under prefers-reduced-motion unless it allowed motion, or when it chose reduce. `color-scheme` travels with the tokens. System is live, an explicit choice wins, and every module root follows through custom-property inheritance, with no module contract change. - @ekklesia/design-tokens adds a small appearance API: system/light/dark and system/reduce/allow, parsers for untrusted stored values, resolvers, and a controller that applies the attributes immediately. The shell applies it before React renders. That is the hook the account preferences work will feed; nothing is persisted. The Dashboard page declares `color-scheme: light dark` so a dark system never flashes white. Displays stay light. - A designed dark theme: teal-tinted charcoal, never pure black or white, the same teal accent. - WCAG AA contrast is tested for 41 element pairings in both schemes. Two light pairings failed and are fixed visibly: text.muted darkens, and form controls get a new border.control (3:1). background.placeholder keeps skeletons visible in dark. - Motion tokens (duration-short, duration-ambient) replace literal durations and every per-element reduced-motion rule. radius.scale multiplies sm/md/lg, unchanged at its default of 1. - The sign-in card and module loading/error text move from literal colours to tokens. Guards keep raw colours and literal radii out of Dashboard product source and @ekklesia/ui. - Fixes ConfirmationDialog's phrase input, which used a class that does not exist (`ek-input`) and so was unstyled. Co-Authored-By: Claude Opus 5.5 <[email protected]>
190 lines
11 KiB
Markdown
190 lines
11 KiB
Markdown
# 0060: Light, Dark, and System appearance; reduced motion; and one radius scale
|
||
|
||
- Status: Accepted
|
||
- Date: 2026-10-15
|
||
- Extends: [0039](0039-shared-design-tokens-and-elements.md),
|
||
[0040](0040-module-stylesheets-and-artifact-isolation.md),
|
||
[0058](0058-dashboard-shell-navigation-and-lucide-icons.md),
|
||
[0059](0059-shared-interaction-primitives.md)
|
||
|
||
## Context
|
||
|
||
DESIGN.md requires every normal first-party interface to support Light, Dark, and System, with System
|
||
the default and a person's choice syncing across their devices (§3.2, locked decisions 25–26);
|
||
reduced motion following the operating system by default, with a personal override (§3.3, locked
|
||
27); a configurable, bounded corner radius (§3.6); shared semantic tokens for every colour (§3.1);
|
||
and WCAG AA (§24, locked 92).
|
||
|
||
ADR 0039 built the semantic token registry and explicitly deferred "a dark theme or any second theme".
|
||
The registry had one value per token and was installed as a single `:root` rule. Modules already
|
||
consumed it the right way: every module artifact mounts in an isolated shadow root (ADR 0040), and
|
||
CSS custom properties inherit through shadow boundaries, so a module reads `var(--ekklesia-…)`
|
||
without importing anything at run time.
|
||
|
||
Measured before this change, the light theme did not meet AA in two places: muted text was 4.10–4.43:1
|
||
on the page, canvas, and surface (4.5:1 required), and form controls drew their boundary in
|
||
`border.base`, 1.38:1 against a surface (3:1 required for a control's boundary, §1.4.11).
|
||
|
||
## Decision
|
||
|
||
### A token may vary by colour scheme, and by motion
|
||
|
||
`DesignTokenInput` gains two optional values, validated exactly like `value`:
|
||
|
||
- **`dark`** — the token's value in the dark scheme. Every colour, border colour, and shadow has one;
|
||
lengths, weights, and font stacks do not, and keep one value.
|
||
- **`reduced`** — the token's value when motion is reduced. Every motion token has one, and it is zero.
|
||
|
||
A registry built without either emits exactly the single rule it always did. The registry adds
|
||
`valueFor(name, scheme)`.
|
||
|
||
### The stylesheet resolves the mode, in CSS
|
||
|
||
`installEkklesiaDesignTokens` installs one stylesheet that selects values by CSS alone:
|
||
|
||
```css
|
||
:root { color-scheme: light; /* every default (light) value */ }
|
||
@media (prefers-color-scheme: dark) {
|
||
:root:not([data-ekklesia-appearance="light"]) { color-scheme: dark; /* dark values */ }
|
||
}
|
||
:root[data-ekklesia-appearance="dark"] { color-scheme: dark; /* dark values */ }
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
:root:not([data-ekklesia-motion="allow"]) { /* motion durations: 0 */ }
|
||
}
|
||
:root[data-ekklesia-motion="reduce"] { /* motion durations: 0 */ }
|
||
```
|
||
|
||
(and the same with `:host(…)` for an isolated root that has no inheriting ancestor).
|
||
|
||
Because the browser resolves it:
|
||
|
||
- **System is live.** An operating-system change restyles the page immediately — no script, no
|
||
listener, no reload.
|
||
- **An explicit choice wins.** `light` or `dark` on the theme root overrides the system; `system` (or
|
||
no attribute) follows it.
|
||
- **Modules follow without a contract change.** Custom properties and `color-scheme` inherit into
|
||
every module's shadow root, so the same switch restyles module UI — Signage included — with no SDK
|
||
change and no module release. This was verified in a real browser across the shadow boundary.
|
||
- **Native UI follows too.** `color-scheme` goes with the tokens, so form controls and scrollbars
|
||
match the scheme; nothing critical depends on browser-default styling.
|
||
|
||
### A small appearance API in `@ekklesia/design-tokens`
|
||
|
||
Framework-free and dependency-free, like the rest of the package:
|
||
|
||
- `EkklesiaAppearance` = `system` | `light` | `dark`; `EkklesiaMotionPreference` = `system` | `reduce` |
|
||
`allow` — the model DESIGN.md asks for, including a future "allow" override of a reduced-motion
|
||
system.
|
||
- `parseEkklesiaAppearance` / `parseEkklesiaMotionPreference` read untrusted stored values; anything
|
||
unrecognised is `system`.
|
||
- `resolveEkklesiaColorScheme` / `resolveEkklesiaReducedMotion` say what is in effect.
|
||
- `createEkklesiaThemeController({ target, matchMedia?, appearance?, motion? })` sets the two attributes
|
||
**immediately** and answers `colorScheme()`, `reducedMotion()`, `setAppearance`, `setMotion`, and
|
||
`subscribe` (which also reports system changes) for code that needs to know.
|
||
|
||
The host owns the choice. A module reads the theme through tokens and never sets appearance; nothing
|
||
in the module UI contract changed.
|
||
|
||
### The shell applies it before React renders
|
||
|
||
`surfaces/shell/src/browser/main.tsx` creates the controller on `document.documentElement` before the
|
||
first render, with `system` for both. That is the **bootstrap hook** for the account preferences
|
||
work: it will read a remembered choice synchronously and pass it here, so an explicit Light or Dark is
|
||
in place before the first frame. Nothing is persisted in this decision, and there is no temporary
|
||
user-facing switch to remove later.
|
||
|
||
The Dashboard page declares `<meta name="color-scheme" content="light dark">` and takes its body
|
||
background and text colour from tokens, so the browser's own canvas is already in the system's scheme
|
||
before any script runs: a dark system never flashes white while the shell loads. (A future explicit
|
||
choice opposite to the system can still show the system's empty canvas for the moment before the
|
||
module script runs; that is the remaining cost of a page with no inline script.)
|
||
|
||
The **display host stays Light**, whatever its operating system prefers. A screen in a lobby is
|
||
configured by whoever set it up, not by a person's appearance preference, and Stage 0 displays must
|
||
look exactly as they did; motion there still follows the system.
|
||
|
||
### The dark theme is designed, not inverted
|
||
|
||
Teal-tinted charcoal backgrounds in an elevation ladder (page `#0e1615` → canvas → surface → raised),
|
||
never pure black; primary text `#e4edeb`, never pure white; the same teal accent, slightly lifted, with
|
||
dark text on it rather than a glowing control; status colours desaturated to sit on dark tints; deeper
|
||
shadows; and a darker overlay. It is the same product in both schemes.
|
||
|
||
### Contrast is tested, and two light pairings were fixed
|
||
|
||
`packages/design-tokens/tests/contrast.test.ts` computes WCAG contrast for 41 pairings the elements
|
||
actually use — text on every background tier, quiet and selected navigation, primary and danger
|
||
buttons, field hints and errors, status notices and badges, control boundaries, and focus rings — in
|
||
both schemes: 4.5:1 for text, 3:1 for control boundaries and focus indication. A real-browser check of
|
||
rendered elements in both schemes agrees.
|
||
|
||
Two intentional, visible light-theme changes make it pass:
|
||
|
||
- `text.muted` darkens from `#6c7b77` to `#5d6d69` (5.44:1 on a surface).
|
||
- A new **`border.control`** token (`#7a8d89`, 3.5:1) draws form controls' boundaries instead of
|
||
`border.base`, which stays the quieter decorative border of cards and panels. `border.strong` is
|
||
redefined to be stronger than `border.control` in both schemes, since it is the hovered control's
|
||
border.
|
||
|
||
`background.placeholder` is new: the skeleton's colour, visibly distinct from the page and a surface
|
||
in both schemes (it had used `background.inset`, which in a dark scheme is correctly darker than the
|
||
page and made skeletons vanish).
|
||
|
||
### Motion and radius are tokens
|
||
|
||
A new **`motion`** category: `motion.duration-short` (160ms; transitions such as the sidebar and a
|
||
toast arriving) and `motion.duration-ambient` (1.6s; a loading placeholder's pulse, and the spinner
|
||
at 0.625 of it). Both are zero when motion is reduced, so an element that animates for a motion-token
|
||
duration needs no reduced-motion rule of its own; the per-element rules in `@ekklesia/ui` and the
|
||
shell are removed, which is also what lets an explicit `allow` work.
|
||
|
||
**`radius.scale`** (default `1`) multiplies `radius.sm`, `radius.md`, and `radius.lg`, which become
|
||
`calc(<today's value> * var(--ekklesia-radius-scale))`; `radius.none` and `radius.pill` do not scale.
|
||
At the default nothing looks different. A later appearance or branding control changes one value to
|
||
make every corner squarer or rounder; it should offer a bounded range (DESIGN.md §3.6).
|
||
|
||
### Every Dashboard colour and corner is a token, and that is checked
|
||
|
||
The sign-in, verification, and recovery card (`AuthCard`) and the module loading and error text
|
||
(`ModuleUiFrame`) carried literal colours; they now use tokens, and the sign-in action is the
|
||
platform's primary button. `tests/theme-guards.test.ts` scans the shell's Dashboard source and
|
||
`@ekklesia/ui` for raw colours and literal corner radii, and `@ekklesia/ui`'s own tests require its
|
||
stylesheet's colours, radii, and durations to be tokens. The display host is the named exception,
|
||
pinned light, until displays move to Settings → Displays.
|
||
|
||
## Alternatives
|
||
|
||
- **Toggle a class from JavaScript on OS changes.** Rejected: CSS media queries already do it, live,
|
||
in every isolated root, with nothing to wire up or forget to unsubscribe.
|
||
- **A second token registry or stylesheet per theme.** Rejected: two registries drift; one token with
|
||
two values cannot lose its dark half without a test failing.
|
||
- **Automatic dark values (inverting, or adjusting lightness).** Rejected: an inverted theme is exactly
|
||
the naive dark mode DESIGN.md warns against, and generated colours would not be reviewable.
|
||
- **Inline bootstrap script to apply a stored choice before paint.** Rejected: the Dashboard has no
|
||
inline script so a strict `script-src` needs no exception; the meta element covers the system case.
|
||
- **Per-element `prefers-reduced-motion` rules.** Replaced: they cannot honour an explicit `allow`, and
|
||
every new animation would have to remember one.
|
||
- **Many radius tokens per component.** Rejected: one multiplier over a three-step scale is what a
|
||
bounded "roundness" control needs.
|
||
- **Letting the display host follow the system.** Rejected for now: it would change Stage 0 screens
|
||
overnight when a kiosk's OS setting changes.
|
||
|
||
## Consequences
|
||
|
||
- The Dashboard and every module UI are Light or Dark by the operating system today; an explicit choice
|
||
is one call once the account preferences exist.
|
||
- `@ekklesia/design-tokens` adds the `motion` category, `dark` and `reduced` values, `valueFor`, three
|
||
tokens (`border.control`, `background.placeholder`, `radius.scale`) plus two motion tokens, and the
|
||
appearance API. `@ekklesia/ui` uses them. Both changes are additive; code that switches exhaustively
|
||
over `DesignTokenCategory` sees one more case.
|
||
- Visible changes in light: slightly darker muted text and form-control borders, and the sign-in card
|
||
in the platform's colours.
|
||
|
||
## Deferred
|
||
|
||
The Account → Appearance page and synced preference storage; a church theme editor and per-installation
|
||
overrides of dark values; branding; the display host's own appearance and its pairing and offline
|
||
screens' colours; the bounded radius range a future control offers; and exposing the resolved scheme
|
||
to module code (modules read it through tokens today).
|