feat(theme): Light/Dark/System, reduced motion, and one radius scale (Stage 1, ADR 0060) #107

Merged
Zayin_Admin merged 1 commits from feat/theme-foundation into main 2026-10-15 01:22:01 +00:00
Member

Stage 1 PR 4: the theme foundation. Light, Dark and System appearance, reduced motion, one radius scale, and WCAG AA contrast validation. The decisions are recorded in ADR 0060. There is no Appearance page and no stored preference. Those belong to the Account/Preferences PR, which only has to call this.

How it works

The token stylesheet resolves the theme in CSS alone:

  • Dark values apply under prefers-color-scheme: dark, unless the host chose light. They also apply when the host chose dark.
  • Motion durations drop to zero under prefers-reduced-motion: reduce, unless motion is allowed. They also drop when the host chose reduce.
  • color-scheme goes with the tokens, so native inputs and scrollbars match.

What this gives you:

  • System is the default, and it is live. When the operating system changes, the page restyles immediately with no script and no reload.
  • An explicit Light or Dark overrides the system.
  • Modules follow with no contract change. Custom properties and color-scheme inherit into every module's shadow root. I verified this in Chromium on a module mounted by the real loader, so Signage needs no release.
  • No white flash. The Dashboard page declares <meta name="color-scheme" content="light dark">, and its body takes the page token. The browser therefore paints its own canvas in the system scheme before any script runs.
  • Displays stay light, whatever their OS prefers. Stage 0 kiosks look exactly as before, and motion still follows the system there.

Public package changes (additive, 0.2.x train)

@ekklesia/design-tokens

  • DesignTokenInput.dark? and .reduced?, validated exactly like value.
  • registry.valueFor(name, scheme).
  • A new motion category.
  • DESIGN_TOKEN_APPEARANCE_ATTRIBUTE and DESIGN_TOKEN_MOTION_ATTRIBUTE.
  • The appearance API:
    • EkklesiaAppearance (system / light / dark) and EkklesiaMotionPreference (system / reduce / allow), plus their value lists;
    • parseEkklesiaAppearance and parseEkklesiaMotionPreference, which turn any untrusted value into system;
    • resolveEkklesiaColorScheme and resolveEkklesiaReducedMotion;
    • createEkklesiaThemeController({ target, matchMedia?, appearance?, motion? }), which applies the attributes immediately and offers colorScheme(), reducedMotion(), setAppearance, setMotion, and subscribe (which also reports system changes).
  • Tokens:
    • new: border.control, background.placeholder, radius.scale, motion.duration-short, motion.duration-ambient;
    • every colour and shadow gains a dark value.
  • Code that switches exhaustively over DesignTokenCategory will see one more case.

@ekklesia/ui

  • Form controls use border.control, and skeletons use background.placeholder.
  • Every animation uses a motion token, and the per-element reduced-motion rules are removed, so an explicit allow works.
  • No API change.

Shell (private)

  • Applies the controller before React renders. This is the bootstrap hook a remembered preference will supply.
  • The display host is pinned to Light.
  • AuthCard (sign-in, verification, recovery) and ModuleUiFrame text use tokens instead of literal colours.
  • The sidebar transition uses a motion token.

Semantic token model

The existing vocabulary already names the roles DESIGN.md lists, so it is kept:

  • the background elevation ladder: page, canvas, surface, raised, inset, overlay;
  • text tiers: primary, secondary, muted, inverse;
  • accent and its hover, active, strong, subtle, foreground and focus states;
  • borders;
  • status families with symbols;
  • hover, pressed, selected and disabled states.

The additions are only what a role was actually missing:

  • border.control: a control's 3:1 boundary;
  • background.placeholder: skeletons;
  • radius.scale;
  • motion durations.

Dark theme

Designed, not inverted:

  • teal-tinted charcoal in an elevation ladder (page #0e1615), 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;
  • desaturated status tints, deeper shadows, and a darker overlay.

Contrast (WCAG 2.2 AA)

packages/design-tokens/tests/contrast.test.ts checks 41 pairings the elements actually use, in both schemes:

  • text on every background tier;
  • quiet and selected navigation;
  • primary and danger buttons;
  • field hints and errors;
  • status notices and badges;
  • control boundaries;
  • focus rings.

Text must reach 4.5:1, and boundaries and focus 3:1. All 82 checks pass.

Two light-theme pairings failed before this PR and are fixed, visibly:

  • text.muted was 4.10–4.43:1 and is now #5d6d69 (5.44:1 on a surface, ≥4.5 on page and canvas).
  • Form controls drew their boundary in border.base at 1.38:1. They now use the new border.control (3.5:1). border.strong, which is the hovered control's border, is redefined to be stronger than that in both schemes; it used to be lighter.

In real Chromium, the rendered contrast of real elements reached at least 5.19:1 in light and 6.39:1 in dark. That covered sidebar links, group labels, the open page, the account control, the page title and description, primary and danger buttons, the field hint, input text, the error title, and success, warning and danger badges.

Radius and motion

  • Radius: radius.sm/md/lg = calc(<today's value> * var(--ekklesia-radius-scale)). The default scale of 1 changes nothing. In Chromium, the radius is 4px at scale 1, 0px at scale 0, and 8px at scale 2, across the shadow boundary.
  • Motion: motion.duration-short is 160ms and motion.duration-ambient is 1.6s. Both drop to 0 when motion is reduced. The spinner runs at 0.625 of the ambient value, so its speed is unchanged.

Guards

  • tests/theme-guards.test.ts scans the shell's Dashboard source and @ekklesia/ui for raw hex, rgb() and hsl() colours and for literal corner radii. It is tested against both positive and negative samples. The display host is the named exception: it is pinned light, and its pairing and offline screens keep their Stage 0 look.
  • @ekklesia/ui's own tests require every colour, radius and duration in its stylesheet to be a token.

Fixed along the way

  • ConfirmationDialog's phrase input (#106) used the class ek-input, which doesn't exist, so it rendered with browser-default styling. It now uses ek-control.
  • Visible change: the sign-in card is now in the platform's colours, and its action is the platform's primary button. Its hard-coded stone palette couldn't follow the theme.

Tests

  • Design tokens (new theme.test.ts, contrast.test.ts, and updated registry.test.ts; 144 tests):
    • complete light and dark sets with no missing semantic token, only colour roles varying, and no pure black or white surfaces;
    • valueFor, and hostile dark and reduced values rejected;
    • the stylesheet's system, explicit and host rules, and determinism;
    • radius scale;
    • preference parsing and resolution;
    • the controller: default System, System resolving to Light and to Dark, live OS changes, explicit Light and Dark ignoring the OS, switching without reload, motion following the system, explicit Reduce and Allow, untrusted input, subscription hygiene, and no matchMedia at all;
    • 82 contrast checks, and placeholder visibility in both schemes.
  • @ekklesia/ui: the skeleton runs on motion tokens, with no prefers-reduced-motion rule left, plus the stylesheet guards above.
  • Shell: index.html declares both schemes and takes its background from tokens.
  • Root: theme-guards.test.ts.

Browser and visual testing (headless Chromium, real loader and shadow root)

  • Emulating a light system and then a dark one restyled the shell and the module's panel and text inside its shadow root live, and color-scheme read dark inside the module too.
  • An explicit Light while the system was dark, and an explicit Dark while it was light, each overrode the system.
  • I measured the rendered contrast in both schemes (above).
  • Motion:
    • with a normal system, the skeleton ran at 1.6s and the sidebar transition at 0.16s;
    • when the system asked for reduced motion, both dropped to 0s;
    • with the person allowing motion, they returned to 1.6s and 0.16s;
    • with the person choosing reduce on a normal system, both dropped to 0s.
  • Screenshots in light and dark: the Dashboard with module content, the destructive typed confirmation, toasts (all four severities), expanded error details, the collapsed sidebar with the account menu, the phone drawer, and skeletons. Looking at them is what caught that skeletons vanished in dark; background.placeholder fixes that, and I re-checked it.

Results (all on the pushed commit)

  • TURBO_FORCE=true pnpm verify (no Turbo cache, see #105): passed. design-tokens 144, ui 61, shell 312, root 165, api 522, core 1161, every package.
  • pnpm infrastructure:config, pnpm deploy:config: passed.
  • pnpm test:package-release: passed. A standalone module consumed the 0.2.5 train with the new tokens and API.
  • pnpm test:package-publication: passed (disposable Gitea).
  • pnpm test:migrations:integration: passed, 401 tests.
  • scripts/smoke-deploy.sh, same-origin and split: both passed. The split run drives the real shell and a module in Chromium, desktop and phone, and the display host, which stays light.

Deferred

  • The Account → Appearance page and synced preference storage. They call setAppearance / setMotion and supply the initial value at the bootstrap hook.
  • A church theme editor and per-installation overrides of dark values.
  • Branding (#101).
  • The display host's own appearance, and its pairing and offline screens' literal colours.
  • The bounded radius range a future control offers.
  • Exposing the resolved scheme to module code beyond tokens.
  • DESIGN.md is unchanged. Its open items "exact semantic token inventory" and "exact configurable radius options" are not settled by a foundation: this PR establishes the mechanism, not the final inventory or the options offered.

🤖 Generated with Claude Code

Stage 1 PR 4: **the theme foundation**. Light, Dark and System appearance, reduced motion, one radius scale, and WCAG AA contrast validation. The decisions are recorded in [ADR 0060](docs/architecture/decisions/0060-light-dark-system-theme-and-motion.md). There is no Appearance page and no stored preference. Those belong to the Account/Preferences PR, which only has to call this. ## How it works The token stylesheet resolves the theme **in CSS alone**: - Dark values apply under `prefers-color-scheme: dark`, unless the host chose `light`. They also apply when the host chose `dark`. - Motion durations drop to zero under `prefers-reduced-motion: reduce`, unless motion is `allow`ed. They also drop when the host chose `reduce`. - `color-scheme` goes with the tokens, so native inputs and scrollbars match. What this gives you: - **System is the default, and it is live.** When the operating system changes, the page restyles immediately with no script and no reload. - **An explicit Light or Dark overrides the system.** - **Modules follow with no contract change.** Custom properties and `color-scheme` inherit into every module's shadow root. I verified this in Chromium on a module mounted by the real loader, so Signage needs no release. - **No white flash.** The Dashboard page declares `<meta name="color-scheme" content="light dark">`, and its body takes the page token. The browser therefore paints its own canvas in the system scheme before any script runs. - **Displays stay light**, whatever their OS prefers. Stage 0 kiosks look exactly as before, and motion still follows the system there. ## Public package changes (additive, 0.2.x train) **`@ekklesia/design-tokens`** - `DesignTokenInput.dark?` and `.reduced?`, validated exactly like `value`. - `registry.valueFor(name, scheme)`. - A new **`motion`** category. - `DESIGN_TOKEN_APPEARANCE_ATTRIBUTE` and `DESIGN_TOKEN_MOTION_ATTRIBUTE`. - The appearance API: - `EkklesiaAppearance` (`system` / `light` / `dark`) and `EkklesiaMotionPreference` (`system` / `reduce` / `allow`), plus their value lists; - `parseEkklesiaAppearance` and `parseEkklesiaMotionPreference`, which turn any untrusted value into `system`; - `resolveEkklesiaColorScheme` and `resolveEkklesiaReducedMotion`; - `createEkklesiaThemeController({ target, matchMedia?, appearance?, motion? })`, which applies the attributes immediately and offers `colorScheme()`, `reducedMotion()`, `setAppearance`, `setMotion`, and `subscribe` (which also reports system changes). - Tokens: - new: `border.control`, `background.placeholder`, `radius.scale`, `motion.duration-short`, `motion.duration-ambient`; - every colour and shadow gains a `dark` value. - Code that switches exhaustively over `DesignTokenCategory` will see one more case. **`@ekklesia/ui`** - Form controls use `border.control`, and skeletons use `background.placeholder`. - Every animation uses a motion token, and the per-element reduced-motion rules are removed, so an explicit `allow` works. - No API change. **Shell (private)** - Applies the controller before React renders. This is the bootstrap hook a remembered preference will supply. - The display host is pinned to Light. - `AuthCard` (sign-in, verification, recovery) and `ModuleUiFrame` text use tokens instead of literal colours. - The sidebar transition uses a motion token. ## Semantic token model The existing vocabulary already names the roles DESIGN.md lists, so it is kept: - the background elevation ladder: page, canvas, surface, raised, inset, overlay; - text tiers: primary, secondary, muted, inverse; - accent and its hover, active, strong, subtle, foreground and focus states; - borders; - status families with symbols; - hover, pressed, selected and disabled states. The additions are only what a role was actually missing: - **`border.control`**: a control's 3:1 boundary; - **`background.placeholder`**: skeletons; - **`radius.scale`**; - **motion durations**. ## Dark theme Designed, not inverted: - teal-tinted charcoal in an elevation ladder (page `#0e1615`), 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; - desaturated status tints, deeper shadows, and a darker overlay. ## Contrast (WCAG 2.2 AA) `packages/design-tokens/tests/contrast.test.ts` checks 41 pairings the elements actually use, in **both** schemes: - text on every background tier; - quiet and selected navigation; - primary and danger buttons; - field hints and errors; - status notices and badges; - control boundaries; - focus rings. Text must reach 4.5:1, and boundaries and focus 3:1. All 82 checks pass. **Two light-theme pairings failed before this PR and are fixed, visibly:** - `text.muted` was **4.10–4.43:1** and is now `#5d6d69` (5.44:1 on a surface, ≥4.5 on page and canvas). - Form controls drew their boundary in `border.base` at **1.38:1**. They now use the new `border.control` (3.5:1). `border.strong`, which is the hovered control's border, is redefined to be stronger than that in both schemes; it used to be lighter. In real Chromium, the rendered contrast of real elements reached at least **5.19:1 in light** and **6.39:1 in dark**. That covered sidebar links, group labels, the open page, the account control, the page title and description, primary and danger buttons, the field hint, input text, the error title, and success, warning and danger badges. ## Radius and motion - **Radius:** `radius.sm/md/lg` = `calc(<today's value> * var(--ekklesia-radius-scale))`. The default scale of 1 changes nothing. In Chromium, the radius is 4px at scale 1, 0px at scale 0, and 8px at scale 2, across the shadow boundary. - **Motion:** `motion.duration-short` is 160ms and `motion.duration-ambient` is 1.6s. Both drop to 0 when motion is reduced. The spinner runs at 0.625 of the ambient value, so its speed is unchanged. ## Guards - `tests/theme-guards.test.ts` scans the shell's Dashboard source and `@ekklesia/ui` for raw hex, `rgb()` and `hsl()` colours and for literal corner radii. It is tested against both positive and negative samples. The display host is the named exception: it is pinned light, and its pairing and offline screens keep their Stage 0 look. - `@ekklesia/ui`'s own tests require every colour, radius and duration in its stylesheet to be a token. ## Fixed along the way - `ConfirmationDialog`'s phrase input (#106) used the class `ek-input`, which doesn't exist, so it rendered with browser-default styling. It now uses `ek-control`. - **Visible change:** the sign-in card is now in the platform's colours, and its action is the platform's primary button. Its hard-coded stone palette couldn't follow the theme. ## Tests - **Design tokens** (new `theme.test.ts`, `contrast.test.ts`, and updated `registry.test.ts`; 144 tests): - complete light and dark sets with no missing semantic token, only colour roles varying, and no pure black or white surfaces; - `valueFor`, and hostile `dark` and `reduced` values rejected; - the stylesheet's system, explicit and host rules, and determinism; - radius scale; - preference parsing and resolution; - the controller: default System, System resolving to Light and to Dark, live OS changes, explicit Light and Dark ignoring the OS, switching without reload, motion following the system, explicit Reduce and Allow, untrusted input, subscription hygiene, and no `matchMedia` at all; - 82 contrast checks, and placeholder visibility in both schemes. - **`@ekklesia/ui`:** the skeleton runs on motion tokens, with no `prefers-reduced-motion` rule left, plus the stylesheet guards above. - **Shell:** `index.html` declares both schemes and takes its background from tokens. - **Root:** `theme-guards.test.ts`. ### Browser and visual testing (headless Chromium, real loader and shadow root) - Emulating a light system and then a dark one restyled the shell **and the module's panel and text inside its shadow root** live, and `color-scheme` read `dark` inside the module too. - An explicit Light while the system was dark, and an explicit Dark while it was light, each overrode the system. - I measured the rendered contrast in both schemes (above). - Motion: - with a normal system, the skeleton ran at 1.6s and the sidebar transition at 0.16s; - when the system asked for reduced motion, both dropped to 0s; - with the person allowing motion, they returned to 1.6s and 0.16s; - with the person choosing reduce on a normal system, both dropped to 0s. - Screenshots in light and dark: the Dashboard with module content, the destructive typed confirmation, toasts (all four severities), expanded error details, the collapsed sidebar with the account menu, the phone drawer, and skeletons. Looking at them is what caught that skeletons vanished in dark; `background.placeholder` fixes that, and I re-checked it. ### Results (all on the pushed commit) - `TURBO_FORCE=true pnpm verify` (no Turbo cache, see #105): passed. design-tokens 144, ui 61, shell 312, root 165, api 522, core 1161, every package. - `pnpm infrastructure:config`, `pnpm deploy:config`: passed. - `pnpm test:package-release`: passed. A standalone module consumed the 0.2.5 train with the new tokens and API. - `pnpm test:package-publication`: passed (disposable Gitea). - `pnpm test:migrations:integration`: passed, 401 tests. - `scripts/smoke-deploy.sh`, same-origin and `split`: both passed. The split run drives the real shell and a module in Chromium, desktop and phone, and the display host, which stays light. ## Deferred - The Account → Appearance page and synced preference storage. They call `setAppearance` / `setMotion` and supply the initial value at the bootstrap hook. - A church theme editor and per-installation overrides of dark values. - Branding (#101). - The display host's own appearance, and its pairing and offline screens' literal colours. - The bounded radius range a future control offers. - Exposing the resolved scheme to module code beyond tokens. - `DESIGN.md` is unchanged. Its open items "exact semantic token inventory" and "exact configurable radius options" are not settled by a foundation: this PR establishes the mechanism, not the final inventory or the options offered. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
ekklesia-bot added 1 commit 2026-10-15 01:10:50 +00:00
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
d42b64a093
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]>
Zayin_Admin merged commit 34692ac05f into main 2026-10-15 01:22:01 +00:00
Zayin_Admin deleted branch feat/theme-foundation 2026-10-15 01:22:01 +00:00
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Zayin/Ekklesia#107