Files
Ekklesia/docs/architecture/decisions/0060-light-dark-system-theme-and-motion.md
T
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

11 KiB
Raw Blame History

0060: Light, Dark, and System appearance; reduced motion; and one radius scale

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:

: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).