feat(theme): Light/Dark/System, reduced motion, and one radius scale
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
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]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e3e2a901a7
commit
d42b64a093
@@ -151,6 +151,10 @@ reaching into the artifact or the artifact importing the host.
|
||||
|
||||
## Deferred
|
||||
|
||||
> **Cross-reference — see [ADR 0060](0060-light-dark-system-theme-and-motion.md).** A dark theme,
|
||||
> System appearance, reduced motion, and a radius scale have landed; ADR 0059 added the focus-trap
|
||||
> utility. The rest of this list is unchanged.
|
||||
|
||||
A theme editor, a brand builder, per-installation persisted overrides, a dark theme or any second
|
||||
theme, density modes, an icon set, charting, layout primitives beyond what the elements need, a
|
||||
documentation site, and a focus-trap utility.
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
# 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).
|
||||
@@ -68,3 +68,4 @@ ADRs capture durable choices that affect more than one package or phase.
|
||||
- [0057: Dashboard URLs are real, host-owned routes](0057-dashboard-urls-are-host-owned-routes.md) — Accepted
|
||||
- [0058: The Dashboard shell's navigation, and Lucide as Ekklesia's icon vocabulary](0058-dashboard-shell-navigation-and-lucide-icons.md) — Accepted
|
||||
- [0059: Shared interaction primitives — confirmation, toasts, errors, loading, Back, and no native dialogs](0059-shared-interaction-primitives.md) — Accepted
|
||||
- [0060: Light, Dark, and System appearance; reduced motion; and one radius scale](0060-light-dark-system-theme-and-motion.md) — Accepted
|
||||
|
||||
Reference in New Issue
Block a user