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).
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.
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.
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.
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)
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]>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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:
prefers-color-scheme: dark, unless the host choselight. They also apply when the host chosedark.prefers-reduced-motion: reduce, unless motion isallowed. They also drop when the host chosereduce.color-schemegoes with the tokens, so native inputs and scrollbars match.What this gives you:
color-schemeinherit into every module's shadow root. I verified this in Chromium on a module mounted by the real loader, so Signage needs no release.<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.Public package changes (additive, 0.2.x train)
@ekklesia/design-tokensDesignTokenInput.dark?and.reduced?, validated exactly likevalue.registry.valueFor(name, scheme).motioncategory.DESIGN_TOKEN_APPEARANCE_ATTRIBUTEandDESIGN_TOKEN_MOTION_ATTRIBUTE.EkklesiaAppearance(system/light/dark) andEkklesiaMotionPreference(system/reduce/allow), plus their value lists;parseEkklesiaAppearanceandparseEkklesiaMotionPreference, which turn any untrusted value intosystem;resolveEkklesiaColorSchemeandresolveEkklesiaReducedMotion;createEkklesiaThemeController({ target, matchMedia?, appearance?, motion? }), which applies the attributes immediately and offerscolorScheme(),reducedMotion(),setAppearance,setMotion, andsubscribe(which also reports system changes).border.control,background.placeholder,radius.scale,motion.duration-short,motion.duration-ambient;darkvalue.DesignTokenCategorywill see one more case.@ekklesia/uiborder.control, and skeletons usebackground.placeholder.allowworks.Shell (private)
AuthCard(sign-in, verification, recovery) andModuleUiFrametext use tokens instead of literal colours.Semantic token model
The existing vocabulary already names the roles DESIGN.md lists, so it is kept:
The additions are only what a role was actually missing:
border.control: a control's 3:1 boundary;background.placeholder: skeletons;radius.scale;Dark theme
Designed, not inverted:
#0e1615), never pure black;#e4edeb, never pure white;Contrast (WCAG 2.2 AA)
packages/design-tokens/tests/contrast.test.tschecks 41 pairings the elements actually use, in both schemes: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.mutedwas 4.10–4.43:1 and is now#5d6d69(5.44:1 on a surface, ≥4.5 on page and canvas).border.baseat 1.38:1. They now use the newborder.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.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.duration-shortis 160ms andmotion.duration-ambientis 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.tsscans the shell's Dashboard source and@ekklesia/uifor raw hex,rgb()andhsl()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 classek-input, which doesn't exist, so it rendered with browser-default styling. It now usesek-control.Tests
theme.test.ts,contrast.test.ts, and updatedregistry.test.ts; 144 tests):valueFor, and hostiledarkandreducedvalues rejected;matchMediaat all;@ekklesia/ui: the skeleton runs on motion tokens, with noprefers-reduced-motionrule left, plus the stylesheet guards above.index.htmldeclares both schemes and takes its background from tokens.theme-guards.test.ts.Browser and visual testing (headless Chromium, real loader and shadow root)
color-schemereaddarkinside the module too.background.placeholderfixes 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 andsplit: 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
setAppearance/setMotionand supply the initial value at the bootstrap hook.DESIGN.mdis 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