Files
Ekklesia/docs/architecture/decisions/0060-light-dark-system-theme-and-motion.md
ekklesiaandClaude Opus 5.5 d42b64a093
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
feat(theme): Light/Dark/System, reduced motion, and one radius scale
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]>
2026-10-14 20:00:39 -05:00

190 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).