From d42b64a0937ab4accd04cf9ec1479d5a50435196 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Wed, 14 Oct 2026 20:00:39 -0500 Subject: [PATCH] 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 --- README.md | 7 + .../0039-shared-design-tokens-and-elements.md | 4 + ...0060-light-dark-system-theme-and-motion.md | 189 ++++++++++ docs/architecture/decisions/README.md | 1 + docs/architecture/platform-architecture.md | 7 +- docs/module-development/README.md | 22 ++ packages/design-tokens/src/appearance.ts | 162 +++++++++ packages/design-tokens/src/index.ts | 1 + packages/design-tokens/src/registry.ts | 101 +++++- packages/design-tokens/src/token.ts | 116 ++++++- packages/design-tokens/tests/contrast.test.ts | 129 +++++++ packages/design-tokens/tests/registry.test.ts | 10 +- packages/design-tokens/tests/theme.test.ts | 325 ++++++++++++++++++ packages/ui/src/confirmation.tsx | 2 +- packages/ui/src/styles.ts | 23 +- packages/ui/tests/interaction.test.tsx | 11 +- packages/ui/tests/tokens.test.ts | 28 ++ surfaces/shell/index.html | 12 +- surfaces/shell/src/browser/AuthCard.tsx | 63 ++-- surfaces/shell/src/browser/ModuleUiFrame.tsx | 5 +- surfaces/shell/src/browser/main.tsx | 24 +- surfaces/shell/src/browser/shell-styles.ts | 14 +- surfaces/shell/src/display/main.tsx | 17 +- surfaces/shell/tests/dashboard-assets.test.ts | 9 + tests/theme-guards.test.ts | 90 +++++ 25 files changed, 1294 insertions(+), 78 deletions(-) create mode 100644 docs/architecture/decisions/0060-light-dark-system-theme-and-motion.md create mode 100644 packages/design-tokens/src/appearance.ts create mode 100644 packages/design-tokens/tests/contrast.test.ts create mode 100644 packages/design-tokens/tests/theme.test.ts create mode 100644 tests/theme-guards.test.ts diff --git a/README.md b/README.md index f487463..08f17f2 100644 --- a/README.md +++ b/README.md @@ -937,6 +937,13 @@ and `PageHeader` with a Back action that returns only to a validated Dashboard p traps and restores focus, and `pnpm lint` refuses `window.alert`, `confirm`, and `prompt` repository-wide. +The Dashboard follows the operating system's **Light or Dark** appearance and **reduced-motion** +setting, live and without a reload, and so does every module's UI, through the same design tokens +([ADR 0060](docs/architecture/decisions/0060-light-dark-system-theme-and-motion.md)). The dark theme is +designed rather than inverted, every text and focus pairing is contrast-tested to WCAG AA in both +schemes, corners scale from one `radius.scale` token, and an explicit Light or Dark choice is one call +away once account preferences exist. Displays stay light. + ## Quick start Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose. diff --git a/docs/architecture/decisions/0039-shared-design-tokens-and-elements.md b/docs/architecture/decisions/0039-shared-design-tokens-and-elements.md index 1e11c1b..4a8fd59 100644 --- a/docs/architecture/decisions/0039-shared-design-tokens-and-elements.md +++ b/docs/architecture/decisions/0039-shared-design-tokens-and-elements.md @@ -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. diff --git a/docs/architecture/decisions/0060-light-dark-system-theme-and-motion.md b/docs/architecture/decisions/0060-light-dark-system-theme-and-motion.md new file mode 100644 index 0000000..06ce24f --- /dev/null +++ b/docs/architecture/decisions/0060-light-dark-system-theme-and-motion.md @@ -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 `` 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( * 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). diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index 7d94c73..d8d1072 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -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 diff --git a/docs/architecture/platform-architecture.md b/docs/architecture/platform-architecture.md index d4f523f..655d445 100644 --- a/docs/architecture/platform-architecture.md +++ b/docs/architecture/platform-architecture.md @@ -596,7 +596,12 @@ elements a real module dashboard cannot be built without, every value a token re shared interactions — a focus-trapping `Dialog` and `ConfirmationDialog`, a per-tree toast system, `ErrorState` with collapsed technical detail, skeletons, and `PageHeader`'s Back — and the lint refuses browser-native dialogs repository-wide -([ADR 0059](decisions/0059-shared-interaction-primitives.md)). `pnpm boundaries` opens +([ADR 0059](decisions/0059-shared-interaction-primitives.md)). Every colour token has a designed dark +value, and the token stylesheet selects Light, Dark, or the system's scheme — and zeroes motion +durations when motion is reduced — in CSS alone, so the Dashboard and every module root follow the +operating system live; the host chooses an explicit appearance through a small controller, applied +before React renders. Contrast is tested for every pairing the elements use, in both schemes +([ADR 0060](decisions/0060-light-dark-system-theme-and-motion.md)). `pnpm boundaries` opens exactly two shared roles to modules **by name**, and pins both design packages beneath everything else. The Dashboard and display install the defaults at their document root; custom properties inherit into every module artifact, so a module reads `var(--ekklesia-…)` with nothing to import at diff --git a/docs/module-development/README.md b/docs/module-development/README.md index 96bd7c1..fea3d96 100644 --- a/docs/module-development/README.md +++ b/docs/module-development/README.md @@ -895,6 +895,28 @@ The host installs the default token values at its own root, and CSS custom prope through an isolated artifact's boundary — so the tokens are simply there, with nothing to import at run time and nothing for the host to hand you. +**Light, Dark, and System come with the tokens** +([ADR 0060](../architecture/decisions/0060-light-dark-system-theme-and-motion.md)). The host decides +the appearance, and the token values change under you — live when the person's system switches — +inside your isolated root too. Your UI is themed for free as long as every colour it uses is a token: +never assume a white background, black text, or one brand colour, and never set the appearance +yourself. `color-scheme` follows as well, so native inputs and scrollbars match. Colours that +represent *content* rather than interface — a preview of what a physical screen shows, an uploaded +image — are yours to keep literal. + +**Animate for a motion token, not a literal duration.** `motion.duration-short` (transitions) and +`motion.duration-ambient` (a gentle, continuous indication) are zero when motion is reduced — by the +system or by the person's preference — so an animation that uses them needs no +`prefers-reduced-motion` rule of its own, and honours an explicit "allow motion" that such a rule +would ignore: + +```css +.calendar-panel { transition: opacity var(--ekklesia-motion-duration-short) ease; } +``` + +**Round corners with `radius.sm`, `radius.md`, or `radius.lg`.** They scale together from one +`radius.scale` value, so a church's chosen roundness reaches your UI without you changing anything. + Every other shared package stays rejected inside a module: `@ekklesia/api-client`, `@ekklesia/auth-client`, `@ekklesia/surface-sdk`, and `@ekklesia/module-ui-host` are not yours to import, and `pnpm boundaries` says so. diff --git a/packages/design-tokens/src/appearance.ts b/packages/design-tokens/src/appearance.ts new file mode 100644 index 0000000..788ef46 --- /dev/null +++ b/packages/design-tokens/src/appearance.ts @@ -0,0 +1,162 @@ +import { + DESIGN_TOKEN_APPEARANCE_ATTRIBUTE, + DESIGN_TOKEN_MOTION_ATTRIBUTE, + type DesignTokenColorScheme, +} from "./token.js"; + +/** + * A person's appearance preference (DESIGN.md §3.2, ADR 0060). `system` follows the operating + * system's light or dark setting, live; it is the default. + */ +export const EKKLESIA_APPEARANCES = Object.freeze(["system", "light", "dark"] as const); +export type EkklesiaAppearance = (typeof EKKLESIA_APPEARANCES)[number]; + +/** + * A person's motion preference (DESIGN.md §3.3, ADR 0060). `system` follows + * `prefers-reduced-motion`; `reduce` always reduces; `allow` keeps the platform's subtle motion even + * when the system asks for less. + */ +export const EKKLESIA_MOTION_PREFERENCES = Object.freeze(["system", "reduce", "allow"] as const); +export type EkklesiaMotionPreference = (typeof EKKLESIA_MOTION_PREFERENCES)[number]; + +export const EKKLESIA_DARK_SCHEME_QUERY = "(prefers-color-scheme: dark)"; +export const EKKLESIA_REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)"; + +/** Reads an untrusted stored or received value; anything unrecognised is the default, `system`. */ +export function parseEkklesiaAppearance(value: unknown): EkklesiaAppearance { + return EKKLESIA_APPEARANCES.includes(value as EkklesiaAppearance) + ? (value as EkklesiaAppearance) + : "system"; +} + +/** Reads an untrusted stored or received value; anything unrecognised is the default, `system`. */ +export function parseEkklesiaMotionPreference(value: unknown): EkklesiaMotionPreference { + return EKKLESIA_MOTION_PREFERENCES.includes(value as EkklesiaMotionPreference) + ? (value as EkklesiaMotionPreference) + : "system"; +} + +/** The scheme an appearance shows, given whether the system currently prefers dark. */ +export function resolveEkklesiaColorScheme( + appearance: EkklesiaAppearance, + systemPrefersDark: boolean, +): DesignTokenColorScheme { + if (appearance === "system") { + return systemPrefersDark ? "dark" : "light"; + } + return appearance; +} + +/** Whether motion is reduced, given the preference and whether the system currently asks for less. */ +export function resolveEkklesiaReducedMotion( + preference: EkklesiaMotionPreference, + systemPrefersReduced: boolean, +): boolean { + return preference === "system" ? systemPrefersReduced : preference === "reduce"; +} + +/** The element a host themes: its document's root element, or an isolated root's host element. */ +export interface EkklesiaThemeTarget { + setAttribute(name: string, value: string): void; +} + +/** A media query list, structurally: what `window.matchMedia` returns. */ +export interface EkklesiaMediaQueryList { + readonly matches: boolean; + addEventListener(type: "change", listener: () => void): void; + removeEventListener(type: "change", listener: () => void): void; +} + +export interface EkklesiaThemeControllerOptions { + readonly target: EkklesiaThemeTarget; + /** The browser's `matchMedia`, handed in by the host. Without one, the system is taken to prefer + * light and full motion. */ + readonly matchMedia?: (query: string) => EkklesiaMediaQueryList; + readonly appearance?: EkklesiaAppearance; + readonly motion?: EkklesiaMotionPreference; +} + +/** + * The host's handle on its theme (ADR 0060). + * + * The **stylesheet** does the work: installed tokens resolve light, dark, and reduced motion in CSS + * from the system's media features and two attributes on the theme root, so a change of OS setting + * restyles everything at once — every isolated module root included, since custom properties and + * `color-scheme` inherit — with no script and no reload. This controller only sets those attributes, + * and answers what is currently in effect for code that needs to know. + */ +export interface EkklesiaThemeController { + appearance(): EkklesiaAppearance; + motion(): EkklesiaMotionPreference; + /** The scheme on screen now: the explicit appearance, or the system's while it is `system`. */ + colorScheme(): DesignTokenColorScheme; + /** Whether motion is reduced now. */ + reducedMotion(): boolean; + setAppearance(appearance: EkklesiaAppearance): void; + setMotion(motion: EkklesiaMotionPreference): void; + /** Notifies after a preference changes, and after the system's setting changes. */ + subscribe(listener: () => void): () => void; +} + +/** + * Applies a theme to its target **immediately** — before anything renders, which is what keeps a + * persisted explicit choice from flashing the other scheme first — and returns its controller. + * Preferences are taken through {@link parseEkklesiaAppearance} and + * {@link parseEkklesiaMotionPreference}, so an untrusted stored value can be passed straight in. + */ +export function createEkklesiaThemeController( + options: EkklesiaThemeControllerOptions, +): EkklesiaThemeController { + let appearance = parseEkklesiaAppearance(options.appearance); + let motion = parseEkklesiaMotionPreference(options.motion); + const darkQuery = options.matchMedia?.(EKKLESIA_DARK_SCHEME_QUERY); + const motionQuery = options.matchMedia?.(EKKLESIA_REDUCED_MOTION_QUERY); + const listeners = new Set<() => void>(); + const notify = (): void => { + for (const listener of [...listeners]) { + listener(); + } + }; + + options.target.setAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE, appearance); + options.target.setAttribute(DESIGN_TOKEN_MOTION_ATTRIBUTE, motion); + + return { + appearance: () => appearance, + colorScheme: () => resolveEkklesiaColorScheme(appearance, darkQuery?.matches ?? false), + motion: () => motion, + reducedMotion: () => resolveEkklesiaReducedMotion(motion, motionQuery?.matches ?? false), + setAppearance(next) { + const parsed = parseEkklesiaAppearance(next); + if (parsed === appearance) { + return; + } + appearance = parsed; + options.target.setAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE, appearance); + notify(); + }, + setMotion(next) { + const parsed = parseEkklesiaMotionPreference(next); + if (parsed === motion) { + return; + } + motion = parsed; + options.target.setAttribute(DESIGN_TOKEN_MOTION_ATTRIBUTE, motion); + notify(); + }, + subscribe(listener) { + if (listeners.size === 0) { + darkQuery?.addEventListener("change", notify); + motionQuery?.addEventListener("change", notify); + } + listeners.add(listener); + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + darkQuery?.removeEventListener("change", notify); + motionQuery?.removeEventListener("change", notify); + } + }; + }, + }; +} diff --git a/packages/design-tokens/src/index.ts b/packages/design-tokens/src/index.ts index 350ab32..d96c536 100644 --- a/packages/design-tokens/src/index.ts +++ b/packages/design-tokens/src/index.ts @@ -1,3 +1,4 @@ +export * from "./appearance.js"; export * from "./install.js"; export * from "./registry.js"; export * from "./token.js"; diff --git a/packages/design-tokens/src/registry.ts b/packages/design-tokens/src/registry.ts index 5ee8bb4..87f5a24 100644 --- a/packages/design-tokens/src/registry.ts +++ b/packages/design-tokens/src/registry.ts @@ -12,9 +12,12 @@ import { * every consumer's meaning changing with it: `background.raised` stays "the surface that sits above * an ordinary surface" whatever a church's brand makes it. * - * The default values are Ekklesia's calm, teal-accented light theme. They remain semantic defaults — - * a deployment overrides them by installing different values at its host root, and this pass - * deliberately ships no theme editor, brand builder, or dark-mode variant to do that with. + * The default values are Ekklesia's calm, teal-accented light theme, and each colour carries a + * `dark` value: a designed dark theme of the same product — teal-tinted charcoal rather than black, + * text that is never pure white, an accent that stays the same teal rather than glowing — selected by + * `prefers-color-scheme` or by the host's explicit appearance (ADR 0060). Every text and focus + * pairing a component uses meets WCAG 2.2 AA in both, and `tests/contrast.test.ts` holds that. + * There is still no theme editor or brand builder; a deployment overrides values at its host root. * * ## Status is never colour alone * @@ -32,6 +35,7 @@ const declarations = [ label: "Page background", name: "background.page", value: "#f3f7f6", + dark: "#0e1615", }, { description: @@ -39,30 +43,43 @@ const declarations = [ label: "Canvas background", name: "background.canvas", value: "#f8faf9", + dark: "#121b1a", }, { description: "An ordinary panel, card, table, or dialog body sitting on the page.", label: "Surface background", name: "background.surface", value: "#ffffff", + dark: "#162120", }, { description: "A surface deliberately lifted above an ordinary surface, such as a popover.", label: "Raised surface background", name: "background.raised", value: "#ffffff", + dark: "#1c2826", }, { description: "The scrim drawn behind a modal dialog to separate it from the page beneath.", label: "Overlay background", name: "background.overlay", value: "rgba(23, 23, 21, 0.55)", + dark: "rgba(3, 8, 7, 0.68)", }, { description: "A recessed area such as a text input, a code block, or a well.", label: "Inset background", name: "background.inset", value: "#edf3f1", + dark: "#0b1211", + }, + { + description: + "The shape of content that is still loading — a skeleton placeholder. Visibly distinct from the page and from a surface in both schemes.", + label: "Placeholder background", + name: "background.placeholder", + value: "#e3ebe9", + dark: "#243230", }, // --------------------------------------------------------------------------------------------- @@ -73,42 +90,49 @@ const declarations = [ label: "Primary text", name: "text.primary", value: "#182321", + dark: "#e4edeb", }, { description: "Supporting text that is still content: captions, secondary columns, help text.", label: "Secondary text", name: "text.secondary", value: "#4f5f5b", + dark: "#b4c3bf", }, { description: "De-emphasised text such as placeholders, timestamps, and disabled labels.", label: "Muted text", name: "text.muted", - value: "#6c7b77", + value: "#5d6d69", + dark: "#93a5a0", }, { description: "Text drawn on a strongly coloured background, such as a primary button.", label: "Inverse text", name: "text.inverse", value: "#ffffff", + dark: "#0e1615", }, { description: "Text reporting a successful outcome, paired with the success symbol.", label: "Success text", name: "text.success", value: "#166534", + dark: "#86d9a7", }, { description: "Text reporting a caution or a degraded state, paired with the warning symbol.", label: "Warning text", name: "text.warning", value: "#854d0e", + dark: "#e9c37a", }, { description: "Text reporting a failure or a destructive consequence, paired with its symbol.", label: "Danger text", name: "text.danger", value: "#9f1239", + dark: "#f5a6b6", }, // --------------------------------------------------------------------------------------------- @@ -119,42 +143,49 @@ const declarations = [ label: "Accent", name: "accent.base", value: "#4fb3a8", + dark: "#5bbcb1", }, { description: "The accent background under a hovered primary action.", label: "Accent hover", name: "accent.hover", value: "#5ab9ae", + dark: "#6cc5bb", }, { description: "The accent background while a primary action is pressed.", label: "Accent active", name: "accent.active", value: "#45a59b", + dark: "#4ea99f", }, { description: "A deeper accent for pressed primary actions and high-emphasis emphasis.", label: "Strong accent", name: "accent.strong", value: "#246b64", + dark: "#82d3c8", }, { description: "A faint accent wash for selected rows and subtle accent backgrounds.", label: "Subtle accent", name: "accent.subtle", value: "#def2ef", + dark: "#163330", }, { description: "Foreground drawn on top of the accent background.", label: "Accent foreground", name: "accent.foreground", value: "#102a27", + dark: "#04201d", }, { description: "The focus ring colour. Never the only signal that something has focus.", label: "Focus ring", name: "accent.focus", value: "#287a72", + dark: "#7fd4c9", }, // --------------------------------------------------------------------------------------------- @@ -165,18 +196,29 @@ const declarations = [ label: "Border", name: "border.base", value: "#d3dedb", + dark: "#2b3836", }, { description: "A heavier border for emphasis, active inputs, and selected outlines.", label: "Strong border", name: "border.strong", - value: "#91a6a1", + value: "#637571", + dark: "#8a9c98", }, { description: "A hairline rule separating rows or sections inside one surface.", label: "Divider", name: "border.divider", value: "#e2eae8", + dark: "#222e2c", + }, + { + description: + "The boundary of a form control — an input, a select — which must stand out at least 3:1 against what surrounds it (WCAG 2.2 §1.4.11).", + label: "Control border", + name: "border.control", + value: "#7a8d89", + dark: "#667a76", }, { description: "The border width used by controls and panels.", @@ -193,12 +235,14 @@ const declarations = [ label: "Success background", name: "status.success-background", value: "#dcfce7", + dark: "#12301f", }, { description: "Border of a success badge or banner.", label: "Success border", name: "status.success-border", value: "#86efac", + dark: "#2f6e49", }, { description: "The non-colour signal for success, rendered beside the label.", @@ -211,12 +255,14 @@ const declarations = [ label: "Warning background", name: "status.warning-background", value: "#fef3c7", + dark: "#33280f", }, { description: "Border of a warning badge or banner.", label: "Warning border", name: "status.warning-border", value: "#fcd34d", + dark: "#7c6121", }, { description: "The non-colour signal for a warning, rendered beside the label.", @@ -229,12 +275,14 @@ const declarations = [ label: "Danger background", name: "status.danger-background", value: "#ffe4e6", + dark: "#3b1a22", }, { description: "Border of a danger badge or banner.", label: "Danger border", name: "status.danger-border", value: "#fda4af", + dark: "#8c3648", }, { description: "The non-colour signal for danger or failure, rendered beside the label.", @@ -247,12 +295,14 @@ const declarations = [ label: "Info background", name: "status.info-background", value: "#def2ef", + dark: "#14322e", }, { description: "Border of a neutral informational badge or banner.", label: "Info border", name: "status.info-border", value: "#9bd5ce", + dark: "#2f6c63", }, { description: "The non-colour signal for information, rendered beside the label.", @@ -269,24 +319,28 @@ const declarations = [ label: "Hover background", name: "state.hover-background", value: "#edf3f1", + dark: "#1f2c2a", }, { description: "Background wash under a control being pressed.", label: "Pressed background", name: "state.pressed-background", value: "#e1ebe8", + dark: "#273634", }, { description: "Background of a selected row, tab, or list item.", label: "Selected background", name: "state.selected-background", value: "#def2ef", + dark: "#183b37", }, { description: "Foreground of a selected row, tab, or list item.", label: "Selected foreground", name: "state.selected-foreground", value: "#205f59", + dark: "#a3e6dc", }, { description: "Opacity applied to a disabled control. Disabled state is never colour alone.", @@ -462,23 +516,30 @@ const declarations = [ name: "radius.none", value: "0", }, + { + description: + "How rounded every corner is, as one multiplier: 1 is the default, 0 is square. Small, medium, and large radii scale with it; none and pill do not.", + label: "Corner roundness", + name: "radius.scale", + value: "1", + }, { description: "Corner radius for badges and small inline elements.", label: "Small radius", name: "radius.sm", - value: "0.25rem", + value: "calc(0.25rem * var(--ekklesia-radius-scale))", }, { description: "Corner radius for controls, inputs, and buttons.", label: "Medium radius", name: "radius.md", - value: "0.625rem", + value: "calc(0.625rem * var(--ekklesia-radius-scale))", }, { description: "Corner radius for panels and dialogs.", label: "Large radius", name: "radius.lg", - value: "0.875rem", + value: "calc(0.875rem * var(--ekklesia-radius-scale))", }, { description: "Fully rounded ends, for pills and status dots.", @@ -497,18 +558,21 @@ const declarations = [ label: "Small shadow", name: "shadow.sm", value: "0 1px 3px rgba(24, 35, 33, 0.08)", + dark: "0 1px 3px rgba(0, 0, 0, 0.45)", }, { description: "Shadow for a popover or a raised surface.", label: "Medium shadow", name: "shadow.md", value: "0 8px 24px rgba(24, 35, 33, 0.12)", + dark: "0 8px 24px rgba(0, 0, 0, 0.5)", }, { description: "Shadow for a modal dialog.", label: "Large shadow", name: "shadow.lg", value: "0 18px 48px rgba(24, 35, 33, 0.2)", + dark: "0 18px 48px rgba(0, 0, 0, 0.6)", }, // --------------------------------------------------------------------------------------------- @@ -538,6 +602,27 @@ const declarations = [ name: "control.padding-inline", value: "0.75rem", }, + + // --------------------------------------------------------------------------------------------- + // Motion — every duration an element animates for. Each is zero when motion is reduced (ADR 0060), + // so an element that uses these tokens needs no reduced-motion rule of its own. + // --------------------------------------------------------------------------------------------- + { + description: + "A quick, purposeful transition: a sidebar collapsing, a notification arriving. Zero when motion is reduced.", + label: "Short duration", + name: "motion.duration-short", + value: "160ms", + reduced: "0ms", + }, + { + description: + "One cycle of a gentle, continuous indication that something is happening, such as a loading placeholder. Zero when motion is reduced.", + label: "Ambient duration", + name: "motion.duration-ambient", + value: "1.6s", + reduced: "0s", + }, ] as const satisfies readonly DesignTokenInput[]; /** Every registered token name, as a union — the compile-time half of "modules consume registered diff --git a/packages/design-tokens/src/token.ts b/packages/design-tokens/src/token.ts index a252f9b..af72b04 100644 --- a/packages/design-tokens/src/token.ts +++ b/packages/design-tokens/src/token.ts @@ -26,6 +26,7 @@ export const DESIGN_TOKEN_CATEGORIES = [ "radius", "shadow", "control", + "motion", ] as const; export type DesignTokenCategory = (typeof DESIGN_TOKEN_CATEGORIES)[number]; @@ -82,8 +83,18 @@ export interface DesignTokenInput { readonly label: string; /** What the token means and when to reach for it. Enumerable, never rendered by the platform. */ readonly description: string; - /** The default CSS value installed at a host root. */ + /** The default CSS value installed at a host root: the light value, for a colour. */ readonly value: string; + /** + * The value in the dark colour scheme, when it differs (ADR 0060). A token without one keeps its + * value in both schemes — a length, a weight, a font stack. Validated exactly like `value`. + */ + readonly dark?: string; + /** + * The value when motion is reduced (ADR 0060), for a motion token: a duration of zero. A token + * without one is unaffected by the motion preference. Validated exactly like `value`. + */ + readonly reduced?: string; } /** A validated token: its declaration plus the two derived facts callers actually consume. */ @@ -191,6 +202,12 @@ function validateToken(input: DesignTokenInput): DesignToken { if (!isTokenValue(input.value)) { throw new DesignTokenError("DESIGN_TOKEN_VALUE_INVALID"); } + if (input.dark !== undefined && !isTokenValue(input.dark)) { + throw new DesignTokenError("DESIGN_TOKEN_VALUE_INVALID"); + } + if (input.reduced !== undefined && !isTokenValue(input.reduced)) { + throw new DesignTokenError("DESIGN_TOKEN_VALUE_INVALID"); + } return Object.freeze({ category: category as DesignTokenCategory, cssVariable, @@ -198,6 +215,8 @@ function validateToken(input: DesignTokenInput): DesignToken { label: input.label, name: input.name, value: input.value, + ...(input.dark === undefined ? {} : { dark: input.dark }), + ...(input.reduced === undefined ? {} : { reduced: input.reduced }), }); } @@ -206,6 +225,22 @@ function validateToken(input: DesignTokenInput): DesignToken { * an arbitrary selector string is exactly the unbounded value this package refuses everywhere else. */ export type DesignTokenScope = "root" | "host"; +/** The colour schemes a token can carry a value for. */ +export type DesignTokenColorScheme = "light" | "dark"; + +/** + * The attribute a host sets on its theme root to choose an appearance (ADR 0060): `light` or `dark` + * forces that scheme; anything else, including its absence, follows `prefers-color-scheme`. + */ +export const DESIGN_TOKEN_APPEARANCE_ATTRIBUTE = "data-ekklesia-appearance"; + +/** + * The attribute a host sets on its theme root to choose motion (ADR 0060): `reduce` forces reduced + * motion; `allow` keeps full motion even when the system asks for less; anything else, including its + * absence, follows `prefers-reduced-motion`. + */ +export const DESIGN_TOKEN_MOTION_ATTRIBUTE = "data-ekklesia-motion"; + export interface DesignTokenRegistry { /** Every token, in declaration order. Stable, frozen, and the enumeration a theme editor reads. */ list(): readonly DesignToken[]; @@ -219,10 +254,52 @@ export interface DesignTokenRegistry { variable(name: Name): string; /** `var(--ekklesia-…)` for a registered token — what component code actually writes. */ reference(name: Name): string; - /** The complete default theme as one CSS rule, ready to install at a host root. */ + /** The value of a registered token in one colour scheme. Throws `DesignTokenError` otherwise. */ + valueFor(name: Name, scheme: DesignTokenColorScheme): string; + /** + * The complete theme, ready to install at a host root: every default value, and — when the + * registry has them — the dark values and the reduced-motion values, each selected by CSS alone + * (ADR 0060). A registry with neither emits exactly one rule. + */ stylesheet(scope?: DesignTokenScope): string; } +function declarationBlock( + tokens: readonly DesignToken[], + pick: (token: DesignToken) => string | undefined, + extra: readonly string[] = [], +): string { + const lines = tokens.flatMap((token) => { + const value = pick(token); + return value === undefined ? [] : [` ${token.cssVariable}: ${value};`]; + }); + return [...extra.map((line) => ` ${line};`), ...lines].join("\n"); +} + +/** + * The selectors one scope uses. `root` is a document; `host` is an isolated root with no inheriting + * ancestor, whose host element carries the attributes. Fixed strings — never a caller's selector. + */ +function selectorsFor(scope: DesignTokenScope) { + const appearance = DESIGN_TOKEN_APPEARANCE_ATTRIBUTE; + const motion = DESIGN_TOKEN_MOTION_ATTRIBUTE; + return scope === "root" + ? { + base: ":root", + darkExplicit: `:root[${appearance}="dark"]`, + darkSystem: `:root:not([${appearance}="light"])`, + reducedExplicit: `:root[${motion}="reduce"]`, + reducedSystem: `:root:not([${motion}="allow"])`, + } + : { + base: ":host", + darkExplicit: `:host([${appearance}="dark"])`, + darkSystem: `:host(:not([${appearance}="light"]))`, + reducedExplicit: `:host([${motion}="reduce"])`, + reducedSystem: `:host(:not([${motion}="allow"]))`, + }; +} + /** * Validates a declaration list and returns the immutable registry built from it. * @@ -272,10 +349,37 @@ export function createDesignTokenRegistry ` ${token.cssVariable}: ${token.value};`) - .join("\n"); - return `${scope === "root" ? ":root" : ":host"} {\n${declarationText}\n}\n`; + const selectors = selectorsFor(scope); + const themed = tokens.some((token) => token.dark !== undefined); + const moving = tokens.some((token) => token.reduced !== undefined); + // `color-scheme` goes with the tokens, so native controls and scrollbars always match the + // scheme the tokens are in — including inside an isolated root, which inherits it. + const rules = [ + `${selectors.base} {\n${declarationBlock( + tokens, + (token) => token.value, + themed ? ["color-scheme: light"] : [], + )}\n}`, + ]; + if (themed) { + const dark = declarationBlock(tokens, (token) => token.dark, ["color-scheme: dark"]); + rules.push( + `@media (prefers-color-scheme: dark) {\n${selectors.darkSystem} {\n${dark}\n}\n}`, + `${selectors.darkExplicit} {\n${dark}\n}`, + ); + } + if (moving) { + const reduced = declarationBlock(tokens, (token) => token.reduced); + rules.push( + `@media (prefers-reduced-motion: reduce) {\n${selectors.reducedSystem} {\n${reduced}\n}\n}`, + `${selectors.reducedExplicit} {\n${reduced}\n}`, + ); + } + return `${rules.join("\n")}\n`; + }, + valueFor: (name: string, scheme: DesignTokenColorScheme): string => { + const token = requireToken(name); + return scheme === "dark" ? (token.dark ?? token.value) : token.value; }, variable: (name: string): string => requireToken(name).cssVariable, }); diff --git a/packages/design-tokens/tests/contrast.test.ts b/packages/design-tokens/tests/contrast.test.ts new file mode 100644 index 0000000..9bbc192 --- /dev/null +++ b/packages/design-tokens/tests/contrast.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "vitest"; +import { + type DesignTokenColorScheme, + type EkklesiaDesignTokenName, + ekklesiaDesignTokens, +} from "../src/index.js"; + +/** + * WCAG 2.2 AA contrast for every text, control, and focus pairing Ekklesia's elements actually use, + * in both colour schemes (ADR 0060). 4.5:1 for text (§1.4.3); 3:1 for the boundary that identifies a + * form control and for focus indication (§1.4.11). These are computed from the token values, which + * is necessary but not sufficient: representative screens are also rendered in a real browser. + */ + +function channels(hex: string): readonly [number, number, number] { + const match = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/u.exec(hex); + if (!match) { + throw new Error(`not an opaque hex colour: ${hex}`); + } + return [match[1], match[2], match[3]].map((part) => Number.parseInt(part ?? "0", 16) / 255) as [ + number, + number, + number, + ]; +} + +function luminance(hex: string): number { + const [r, g, b] = channels(hex).map((value) => + value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4, + ) as [number, number, number]; + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +function contrastRatio(first: string, second: string): number { + const [lighter, darker] = [luminance(first), luminance(second)].sort((a, b) => b - a) as [ + number, + number, + ]; + return (lighter + 0.05) / (darker + 0.05); +} + +type Pairing = readonly [EkklesiaDesignTokenName, EkklesiaDesignTokenName, number, string]; + +const TEXT = 4.5; +const NON_TEXT = 3; + +/** Foreground, background, required ratio, and where it appears. */ +const PAIRINGS: readonly Pairing[] = [ + ["text.primary", "background.page", TEXT, "body text on the page"], + ["text.primary", "background.canvas", TEXT, "sidebar and empty states"], + ["text.primary", "background.surface", TEXT, "panels, inputs"], + ["text.primary", "background.raised", TEXT, "dialogs, menus, toasts"], + ["text.primary", "background.inset", TEXT, "technical details"], + ["text.secondary", "background.page", TEXT, "descriptions on the page"], + ["text.secondary", "background.canvas", TEXT, "quiet navigation items"], + ["text.secondary", "background.surface", TEXT, "panel descriptions"], + ["text.secondary", "background.raised", TEXT, "dialog descriptions, toast bodies"], + ["text.secondary", "background.inset", TEXT, "technical detail text"], + ["text.secondary", "state.hover-background", TEXT, "hovered quiet button"], + ["text.primary", "state.pressed-background", TEXT, "pressed button"], + ["text.muted", "background.page", TEXT, "hints and metadata on the page"], + ["text.muted", "background.canvas", TEXT, "group labels in the sidebar"], + ["text.muted", "background.surface", TEXT, "field hints, placeholders"], + ["text.muted", "background.raised", TEXT, "menu captions, phrase status"], + ["accent.foreground", "accent.base", TEXT, "primary button"], + ["accent.foreground", "accent.hover", TEXT, "primary button, hovered"], + ["accent.foreground", "accent.active", TEXT, "primary button, pressed"], + ["accent.strong", "background.page", TEXT, "eyebrow and accent text"], + ["accent.strong", "background.surface", TEXT, "accent text in a panel"], + ["state.selected-foreground", "state.selected-background", TEXT, "the open page in navigation"], + ["text.danger", "status.danger-background", TEXT, "danger button and badge"], + ["text.danger", "background.surface", TEXT, "field error"], + ["text.danger", "background.canvas", TEXT, "error state title"], + ["text.danger", "background.raised", TEXT, "error in a dialog"], + ["text.warning", "status.warning-background", TEXT, "warning badge and notice"], + ["text.success", "status.success-background", TEXT, "success badge and notice"], + ["text.primary", "status.info-background", TEXT, "information notice"], + ["text.primary", "status.warning-background", TEXT, "warning notice body"], + ["text.primary", "status.danger-background", TEXT, "danger notice body"], + ["text.primary", "status.success-background", TEXT, "success notice body"], + ["text.primary", "accent.subtle", TEXT, "selected tab"], + ["border.control", "background.surface", NON_TEXT, "an input's boundary"], + ["border.control", "background.page", NON_TEXT, "an input on the page"], + ["border.control", "background.raised", NON_TEXT, "an input in a dialog"], + ["text.danger", "background.surface", NON_TEXT, "an invalid input's boundary"], + ["accent.focus", "background.page", NON_TEXT, "focus ring on the page"], + ["accent.focus", "background.canvas", NON_TEXT, "focus ring in the sidebar"], + ["accent.focus", "background.surface", NON_TEXT, "focus ring in a panel"], + ["accent.focus", "background.raised", NON_TEXT, "focus ring in a dialog"], +]; + +describe.each([ + "light", + "dark", +] as const satisfies readonly DesignTokenColorScheme[])("WCAG 2.2 AA contrast, %s", (scheme) => { + it.each(PAIRINGS)("%s on %s ≥ %s:1 (%s)", (foreground, background, required) => { + const ratio = contrastRatio( + ekklesiaDesignTokens.valueFor(foreground, scheme), + ekklesiaDesignTokens.valueFor(background, scheme), + ); + expect(Number(ratio.toFixed(2))).toBeGreaterThanOrEqual(required); + }); +}); + +describe("the contrast calculator", () => { + it("agrees with WCAG's reference values", () => { + expect(contrastRatio("#000000", "#ffffff")).toBeCloseTo(21, 5); + expect(contrastRatio("#ffffff", "#ffffff")).toBeCloseTo(1, 5); + expect(contrastRatio("#767676", "#ffffff")).toBeCloseTo(4.54, 2); + }); +}); + +describe("decorative placeholders stay visible in both schemes", () => { + // A skeleton is hidden from assistive technology, so WCAG sets no ratio for it; it still has to be + // seen against what it sits on, or it is a blank page rather than a shape of what is coming. + it.each(["light", "dark"] as const)("%s", (scheme) => { + for (const background of [ + "background.page", + "background.surface", + "background.canvas", + ] as const) { + const ratio = contrastRatio( + ekklesiaDesignTokens.valueFor("background.placeholder", scheme), + ekklesiaDesignTokens.valueFor(background, scheme), + ); + expect(ratio, background).toBeGreaterThanOrEqual(1.1); + } + }); +}); diff --git a/packages/design-tokens/tests/registry.test.ts b/packages/design-tokens/tests/registry.test.ts index f964319..80389b2 100644 --- a/packages/design-tokens/tests/registry.test.ts +++ b/packages/design-tokens/tests/registry.test.ts @@ -98,11 +98,17 @@ describe("the Ekklesia design token registry", () => { expect(ekklesiaDesignTokens.variable("text.primary")).toBe("--ekklesia-text-primary"); }); - it("emits a stylesheet declaring every registered token exactly once", () => { + it("emits a stylesheet declaring every token once in its base rule, and again only where it varies", () => { const stylesheet = ekklesiaDesignTokenStylesheet(); + const base = stylesheet.slice(0, stylesheet.indexOf("}")); for (const token of ekklesiaDesignTokens.list()) { - expect(stylesheet.split(`${token.cssVariable}:`).length).toBe(2); + expect(base.split(`${token.cssVariable}:`).length, token.name).toBe(2); + // Once more in each of the two dark rules (system and explicit), and in each of the two + // reduced-motion rules, and nowhere else (ADR 0060). + const expected = + 1 + (token.dark === undefined ? 0 : 2) + (token.reduced === undefined ? 0 : 2); + expect(stylesheet.split(`${token.cssVariable}:`).length - 1, token.name).toBe(expected); } }); diff --git a/packages/design-tokens/tests/theme.test.ts b/packages/design-tokens/tests/theme.test.ts new file mode 100644 index 0000000..4310de4 --- /dev/null +++ b/packages/design-tokens/tests/theme.test.ts @@ -0,0 +1,325 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createDesignTokenRegistry, + createEkklesiaThemeController, + DESIGN_TOKEN_APPEARANCE_ATTRIBUTE, + DESIGN_TOKEN_MOTION_ATTRIBUTE, + DesignTokenError, + EKKLESIA_DARK_SCHEME_QUERY, + EKKLESIA_REDUCED_MOTION_QUERY, + type EkklesiaMediaQueryList, + ekklesiaDesignTokens, + ekklesiaDesignTokenStylesheet, + parseEkklesiaAppearance, + parseEkklesiaMotionPreference, + resolveEkklesiaColorScheme, + resolveEkklesiaReducedMotion, +} from "../src/index.js"; + +/** A media query a test can flip, as a running OS would. */ +function mediaQuery(initial = false) { + const listeners = new Set<() => void>(); + const query: EkklesiaMediaQueryList & { set(matches: boolean): void } = { + addEventListener: (_type, listener) => listeners.add(listener), + matches: initial, + removeEventListener: (_type, listener) => listeners.delete(listener), + set(matches) { + (query as { matches: boolean }).matches = matches; + for (const listener of [...listeners]) { + listener(); + } + }, + }; + return { listeners, query }; +} + +function environment(options: { dark?: boolean; reduced?: boolean } = {}) { + const dark = mediaQuery(options.dark); + const reduced = mediaQuery(options.reduced); + const attributes = new Map(); + const target = { + setAttribute: vi.fn((name: string, value: string) => attributes.set(name, value)), + }; + const matchMedia = (query: string) => { + if (query === EKKLESIA_DARK_SCHEME_QUERY) { + return dark.query; + } + if (query === EKKLESIA_REDUCED_MOTION_QUERY) { + return reduced.query; + } + throw new Error(`unexpected query ${query}`); + }; + return { attributes, dark, matchMedia, reduced, target }; +} + +const COLOUR_CATEGORIES = new Set(["background", "text", "accent", "status", "state"]); +const isColour = (value: string) => /^(?:#[0-9a-f]{6}|rgba\()/u.test(value); + +describe("light and dark token sets", () => { + it("gives every colour and shadow a dark value, so no semantic token is missing in either theme", () => { + const colours = ekklesiaDesignTokens + .list() + .filter((token) => isColour(token.value) || token.category === "shadow"); + expect(colours.length).toBeGreaterThan(35); + for (const token of colours) { + if (token.name === "shadow.none") { + continue; + } + expect(token.dark, token.name).toBeDefined(); + expect(token.dark, token.name).not.toBe(token.value); + } + }); + + it("varies only colour, shadow, and border-colour tokens by scheme", () => { + for (const token of ekklesiaDesignTokens.list()) { + if (token.dark === undefined) { + continue; + } + expect( + COLOUR_CATEGORIES.has(token.category) || + token.category === "shadow" || + (token.category === "border" && isColour(token.value)), + token.name, + ).toBe(true); + } + }); + + it("is a designed dark theme, not an inversion: no pure black or pure white surfaces or text", () => { + for (const name of [ + "background.page", + "background.canvas", + "background.surface", + "background.raised", + "text.primary", + ] as const) { + expect(ekklesiaDesignTokens.valueFor(name, "dark"), name).not.toMatch( + /^#(?:000000|ffffff)$/u, + ); + } + }); + + it("answers a token's value per scheme, falling back to its single value", () => { + expect(ekklesiaDesignTokens.valueFor("background.page", "light")).toBe("#f3f7f6"); + expect(ekklesiaDesignTokens.valueFor("background.page", "dark")).toBe("#0e1615"); + expect(ekklesiaDesignTokens.valueFor("spacing.md", "dark")).toBe("0.75rem"); + expect(() => ekklesiaDesignTokens.valueFor("nope" as "spacing.md", "dark")).toThrow( + DesignTokenError, + ); + }); + + it("validates a dark or reduced value exactly like a default value", () => { + for (const hostile of ["red; color: blue", "url(x)", "#fff !important", "a{b}"]) { + expect(() => + createDesignTokenRegistry([ + { dark: hostile, description: "d", label: "l", name: "text.x", value: "#fff" }, + ]), + ).toThrow(DesignTokenError); + expect(() => + createDesignTokenRegistry([ + { description: "d", label: "l", name: "motion.x", reduced: hostile, value: "1s" }, + ]), + ).toThrow(DesignTokenError); + } + }); +}); + +describe("the theme stylesheet resolves appearance and motion in CSS", () => { + const stylesheet = ekklesiaDesignTokenStylesheet(); + + it("is deterministic", () => { + expect(ekklesiaDesignTokenStylesheet()).toBe(stylesheet); + }); + + it("is light by default and says so to the browser", () => { + expect(stylesheet.startsWith(":root {\n color-scheme: light;\n")).toBe(true); + expect(stylesheet).toContain("--ekklesia-background-page: #f3f7f6;"); + }); + + it("follows the system into dark unless light was chosen, and goes dark when dark was chosen", () => { + expect(stylesheet).toContain( + `@media (prefers-color-scheme: dark) {\n:root:not([${DESIGN_TOKEN_APPEARANCE_ATTRIBUTE}="light"]) {\n color-scheme: dark;\n`, + ); + expect(stylesheet).toContain( + `:root[${DESIGN_TOKEN_APPEARANCE_ATTRIBUTE}="dark"] {\n color-scheme: dark;\n`, + ); + const darkRule = stylesheet.slice( + stylesheet.indexOf(`:root[${DESIGN_TOKEN_APPEARANCE_ATTRIBUTE}="dark"]`), + ); + expect(darkRule).toContain("--ekklesia-background-page: #0e1615;"); + }); + + it("zeroes every motion duration when the system asks, unless motion was allowed, and when reduce was chosen", () => { + expect(stylesheet).toContain( + `@media (prefers-reduced-motion: reduce) {\n:root:not([${DESIGN_TOKEN_MOTION_ATTRIBUTE}="allow"]) {\n --ekklesia-motion-duration-short: 0ms;\n --ekklesia-motion-duration-ambient: 0s;\n}\n}`, + ); + expect(stylesheet).toContain( + `:root[${DESIGN_TOKEN_MOTION_ATTRIBUTE}="reduce"] {\n --ekklesia-motion-duration-short: 0ms;\n --ekklesia-motion-duration-ambient: 0s;\n}`, + ); + for (const token of ekklesiaDesignTokens.listByCategory("motion")) { + expect(token.reduced, token.name).toMatch(/^0m?s$/u); + } + }); + + it("uses the same rules on an isolated root's host element", () => { + const host = ekklesiaDesignTokenStylesheet("host"); + expect(host.startsWith(":host {")).toBe(true); + expect(host).toContain(`:host(:not([${DESIGN_TOKEN_APPEARANCE_ATTRIBUTE}="light"]))`); + expect(host).toContain(`:host([${DESIGN_TOKEN_APPEARANCE_ATTRIBUTE}="dark"])`); + expect(host).toContain(`:host([${DESIGN_TOKEN_MOTION_ATTRIBUTE}="reduce"])`); + }); +}); + +describe("radius", () => { + it("scales the small, medium, and large radii from one multiplier, keeping today's values", () => { + expect(ekklesiaDesignTokens.get("radius.scale")?.value).toBe("1"); + expect(ekklesiaDesignTokens.get("radius.sm")?.value).toBe( + "calc(0.25rem * var(--ekklesia-radius-scale))", + ); + expect(ekklesiaDesignTokens.get("radius.md")?.value).toBe( + "calc(0.625rem * var(--ekklesia-radius-scale))", + ); + expect(ekklesiaDesignTokens.get("radius.lg")?.value).toBe( + "calc(0.875rem * var(--ekklesia-radius-scale))", + ); + expect(ekklesiaDesignTokens.get("radius.pill")?.value).toBe("9999px"); + expect(ekklesiaDesignTokens.get("radius.none")?.value).toBe("0"); + }); +}); + +describe("appearance and motion preferences", () => { + it("default to system, and read untrusted values safely", () => { + expect(parseEkklesiaAppearance(undefined)).toBe("system"); + expect(parseEkklesiaAppearance("dark")).toBe("dark"); + expect(parseEkklesiaAppearance("DARK")).toBe("system"); + expect(parseEkklesiaAppearance({})).toBe("system"); + expect(parseEkklesiaMotionPreference("allow")).toBe("allow"); + expect(parseEkklesiaMotionPreference("off")).toBe("system"); + }); + + it("resolve to what is on screen", () => { + expect(resolveEkklesiaColorScheme("system", false)).toBe("light"); + expect(resolveEkklesiaColorScheme("system", true)).toBe("dark"); + expect(resolveEkklesiaColorScheme("light", true)).toBe("light"); + expect(resolveEkklesiaColorScheme("dark", false)).toBe("dark"); + expect(resolveEkklesiaReducedMotion("system", true)).toBe(true); + expect(resolveEkklesiaReducedMotion("system", false)).toBe(false); + expect(resolveEkklesiaReducedMotion("reduce", false)).toBe(true); + expect(resolveEkklesiaReducedMotion("allow", true)).toBe(false); + }); +}); + +describe("the theme controller", () => { + it("defaults to System and applies its attributes at once, before anything renders", () => { + const env = environment(); + const controller = createEkklesiaThemeController({ + matchMedia: env.matchMedia, + target: env.target, + }); + + expect(controller.appearance()).toBe("system"); + expect(controller.motion()).toBe("system"); + expect(env.attributes.get(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe("system"); + expect(env.attributes.get(DESIGN_TOKEN_MOTION_ATTRIBUTE)).toBe("system"); + }); + + it("resolves System to Light or Dark from the system, and follows it live", () => { + const env = environment({ dark: false }); + const controller = createEkklesiaThemeController({ + matchMedia: env.matchMedia, + target: env.target, + }); + const listener = vi.fn(); + controller.subscribe(listener); + expect(controller.colorScheme()).toBe("light"); + + env.dark.query.set(true); + + expect(controller.colorScheme()).toBe("dark"); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it("lets an explicit Light or Dark ignore the system", () => { + for (const appearance of ["light", "dark"] as const) { + const env = environment({ dark: appearance === "light" }); + const controller = createEkklesiaThemeController({ + appearance, + matchMedia: env.matchMedia, + target: env.target, + }); + expect(controller.colorScheme(), appearance).toBe(appearance); + env.dark.query.set(appearance !== "light"); + expect(controller.colorScheme(), appearance).toBe(appearance); + expect(env.attributes.get(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe(appearance); + } + }); + + it("switches mode in place, without a reload, and only notifies on a real change", () => { + const env = environment({ dark: true }); + const controller = createEkklesiaThemeController({ + matchMedia: env.matchMedia, + target: env.target, + }); + const listener = vi.fn(); + controller.subscribe(listener); + + controller.setAppearance("light"); + expect(env.attributes.get(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe("light"); + expect(controller.colorScheme()).toBe("light"); + controller.setAppearance("light"); + controller.setAppearance("dark"); + controller.setAppearance("system"); + expect(controller.colorScheme()).toBe("dark"); + expect(listener).toHaveBeenCalledTimes(3); + }); + + it("follows the system's reduced-motion setting, and supports an explicit Reduce or Allow", () => { + const env = environment({ reduced: false }); + const controller = createEkklesiaThemeController({ + matchMedia: env.matchMedia, + target: env.target, + }); + expect(controller.reducedMotion()).toBe(false); + env.reduced.query.set(true); + expect(controller.reducedMotion()).toBe(true); + + controller.setMotion("allow"); + expect(env.attributes.get(DESIGN_TOKEN_MOTION_ATTRIBUTE)).toBe("allow"); + expect(controller.reducedMotion()).toBe(false); + controller.setMotion("reduce"); + env.reduced.query.set(false); + expect(controller.reducedMotion()).toBe(true); + }); + + it("takes an untrusted stored preference straight in", () => { + const env = environment(); + const controller = createEkklesiaThemeController({ + appearance: "purple" as "dark", + matchMedia: env.matchMedia, + motion: 7 as unknown as "reduce", + target: env.target, + }); + expect(controller.appearance()).toBe("system"); + expect(controller.motion()).toBe("system"); + }); + + it("listens to the system only while someone is subscribed", () => { + const env = environment(); + const controller = createEkklesiaThemeController({ + matchMedia: env.matchMedia, + target: env.target, + }); + const unsubscribe = controller.subscribe(() => undefined); + expect(env.dark.listeners.size).toBe(1); + expect(env.reduced.listeners.size).toBe(1); + unsubscribe(); + expect(env.dark.listeners.size).toBe(0); + expect(env.reduced.listeners.size).toBe(0); + }); + + it("works with no media queries at all, as light with full motion", () => { + const env = environment(); + const controller = createEkklesiaThemeController({ target: env.target }); + expect(controller.colorScheme()).toBe("light"); + expect(controller.reducedMotion()).toBe(false); + }); +}); diff --git a/packages/ui/src/confirmation.tsx b/packages/ui/src/confirmation.tsx index 7063019..070a46c 100644 --- a/packages/ui/src/confirmation.tsx +++ b/packages/ui/src/confirmation.tsx @@ -150,7 +150,7 @@ export function ConfirmationDialog(props: ConfirmationDialogProps): ReactElement autoCapitalize="off" autoComplete="off" autoCorrect="off" - className="ek-input ek-focusable" + className="ek-control ek-focusable" disabled={busy} id={phraseId} onChange={(event) => setTyped(event.currentTarget.value)} diff --git a/packages/ui/src/styles.ts b/packages/ui/src/styles.ts index 935bebb..00fcc1d 100644 --- a/packages/ui/src/styles.ts +++ b/packages/ui/src/styles.ts @@ -162,7 +162,7 @@ export const EKKLESIA_UI_STYLESHEET = ` .ek-control { background: ${designToken("background.surface")}; - border: ${designToken("border.width")} solid ${designToken("border.base")}; + border: ${designToken("border.width")} solid ${designToken("border.control")}; border-radius: ${designToken("radius.md")}; box-sizing: border-box; color: ${designToken("text.primary")}; @@ -587,7 +587,8 @@ export const EKKLESIA_UI_STYLESHEET = ` } .ek-spinner { - animation: ek-spin 1s linear infinite; + /* Spins at 0.625 of the ambient cycle (1s by default); not at all when motion is reduced. */ + animation: ek-spin calc(${designToken("motion.duration-ambient")} * 0.625) linear infinite; border: ${designToken("state.focus-visible-width")} solid ${designToken("border.base")}; border-block-start-color: ${designToken("accent.base")}; border-radius: ${designToken("radius.pill")}; @@ -656,7 +657,7 @@ export const EKKLESIA_UI_STYLESHEET = ` } .ek-toast-region .ek-toast { - animation: ek-toast-enter 160ms ease-out; + animation: ek-toast-enter ${designToken("motion.duration-short")} ease-out; pointer-events: auto; } @@ -727,8 +728,8 @@ export const EKKLESIA_UI_STYLESHEET = ` } .ek-skeleton { - animation: ek-skeleton-pulse 1.6s ease-in-out infinite; - background: ${designToken("background.inset")}; + animation: ek-skeleton-pulse ${designToken("motion.duration-ambient")} ease-in-out infinite; + background: ${designToken("background.placeholder")}; border-radius: ${designToken("radius.sm")}; display: block; } @@ -768,15 +769,9 @@ export const EKKLESIA_UI_STYLESHEET = ` } } -/* Every animation in these elements stops for a person who asked for reduced motion; nothing in - them depends on movement to be understood. */ -@media (prefers-reduced-motion: reduce) { - .ek-spinner, - .ek-skeleton, - .ek-toast-region .ek-toast { - animation: none; - } -} +/* Every animation above takes its duration from a motion token, which is zero when motion is + reduced — by the system, or by a person's own preference (ADR 0060) — so none of them needs a + reduced-motion rule of its own, and nothing here depends on movement to be understood. */ `; /** Marks a stylesheet this package installed, so installation is idempotent per root. */ diff --git a/packages/ui/tests/interaction.test.tsx b/packages/ui/tests/interaction.test.tsx index d041dfc..e97b352 100644 --- a/packages/ui/tests/interaction.test.tsx +++ b/packages/ui/tests/interaction.test.tsx @@ -725,12 +725,13 @@ describe("Skeleton", () => { expect(group.textContent).toBe("Loading displays"); }); - it("stops moving for a person who asked for reduced motion", () => { - const reduced = EKKLESIA_UI_STYLESHEET.slice( - EKKLESIA_UI_STYLESHEET.indexOf("@media (prefers-reduced-motion: reduce)"), + it("stops moving for a person who asked for reduced motion, through the shared motion tokens", () => { + // The pulse lasts one ambient cycle, which the token stylesheet zeroes when motion is reduced — + // by the system or by an explicit preference (ADR 0060) — so the element needs no rule of its own. + expect(EKKLESIA_UI_STYLESHEET).toMatch( + /\.ek-skeleton \{[^}]*animation: ek-skeleton-pulse var\(--ekklesia-motion-duration-ambient\)/su, ); - expect(reduced).toMatch(/\.ek-skeleton[,\s]/u); - expect(reduced).toContain("animation: none"); + expect(EKKLESIA_UI_STYLESHEET).not.toContain("prefers-reduced-motion"); }); }); diff --git a/packages/ui/tests/tokens.test.ts b/packages/ui/tests/tokens.test.ts index b81827c..941cacd 100644 --- a/packages/ui/tests/tokens.test.ts +++ b/packages/ui/tests/tokens.test.ts @@ -91,3 +91,31 @@ describe("the shared elements consume design tokens, never hardcoded theme value expect(document.querySelectorAll(`style[${EKKLESIA_UI_STYLE_ATTRIBUTE}]`).length).toBe(0); }); }); + +describe("the element stylesheet takes every colour, radius, and duration from a token (ADR 0060)", () => { + // Comments are dropped: they may mention a value without using it. + const css = EKKLESIA_UI_STYLESHEET.replace(/\/\*[\s\S]*?\*\//gu, ""); + + it("contains no raw colour", () => { + expect(css).not.toMatch(/#[0-9a-f]{3,8}\b/iu); + expect(css).not.toMatch(/\b(?:rgba?|hsla?|oklch|lab|lch|hwb)\(/iu); + // A named colour is a raw colour too; `transparent` and `currentColor` are not colours. + expect(css).not.toMatch(/:\s*(?:white|black|red|green|blue|gray|grey)\s*;/iu); + }); + + it("rounds corners only with radius tokens", () => { + for (const match of css.matchAll(/border-radius:\s*([^;]+);/gu)) { + // Each corner is a radius token, or square. + for (const corner of (match[1] ?? "").split(/\s+/u)) { + expect(corner, match[0]).toMatch(/^(?:var\(--ekklesia-radius-[a-z]+\)|0)$/u); + } + } + }); + + it("animates only for motion-token durations", () => { + for (const match of css.matchAll(/(?:animation|transition):\s*([^;]+);/gu)) { + expect(match[1], match[0]).not.toMatch(/\b\d+(?:\.\d+)?m?s\b/u); + expect(match[1], match[0]).toContain("--ekklesia-motion-duration-"); + } + }); +}); diff --git a/surfaces/shell/index.html b/surfaces/shell/index.html index 5e76d50..f1437cc 100644 --- a/surfaces/shell/index.html +++ b/surfaces/shell/index.html @@ -7,9 +7,19 @@ this page's own origin, the same-origin topology. Never an inline script, so a strict script-src needs no exception for it. --> + + Ekklesia - +
diff --git a/surfaces/shell/src/browser/AuthCard.tsx b/surfaces/shell/src/browser/AuthCard.tsx index 8d94971..5a6e72e 100644 --- a/surfaces/shell/src/browser/AuthCard.tsx +++ b/surfaces/shell/src/browser/AuthCard.tsx @@ -1,3 +1,4 @@ +import { designToken } from "@ekklesia/design-tokens"; import { type FormEvent, type ReactElement, useEffect, useRef, useState } from "react"; import { HostAuthError } from "../auth-protocol.js"; @@ -5,60 +6,68 @@ import { HostAuthError } from "../auth-protocol.js"; * The shared visual language of every host-owned auth screen. * * Exported so the login, recovery, reset, and verification screens are literally the same card - * rather than four that drift apart. Modules never render any of them. + * rather than four that drift apart. Modules never render any of them. Every colour, radius, and + * size is a design token (ADR 0039, ADR 0060), so these screens follow the person's light, dark, or + * system appearance like the rest of the Dashboard; the action is the platform's primary button. */ export const authStyles = { button: { - background: "#1c1917", - border: "1px solid #1c1917", - borderRadius: "0.375rem", - color: "#fafaf9", + background: designToken("accent.base"), + border: `${designToken("border.width")} solid ${designToken("accent.base")}`, + borderRadius: designToken("radius.md"), + color: designToken("accent.foreground"), cursor: "pointer", font: "inherit", - fontWeight: 600, - padding: "0.625rem 0.875rem", + fontWeight: designToken("typography.weight-bold"), + minHeight: designToken("control.height-md"), + padding: `${designToken("spacing.sm")} ${designToken("spacing.lg")}`, }, card: { - background: "#ffffff", - border: "1px solid #e7e5e4", - borderRadius: "0.75rem", - boxShadow: "0 12px 32px rgb(28 25 23 / 0.08)", + background: designToken("background.raised"), + border: `${designToken("border.width")} solid ${designToken("border.base")}`, + borderRadius: designToken("radius.lg"), + boxShadow: designToken("shadow.md"), display: "grid", - gap: "1rem", + gap: designToken("spacing.lg"), maxWidth: "24rem", - padding: "1.5rem", + padding: designToken("spacing.xl"), width: "100%", }, - error: { color: "#9f1239", margin: 0 }, - field: { display: "grid", gap: "0.375rem" }, - form: { display: "grid", gap: "1rem" }, - heading: { fontSize: "1.5rem", margin: 0 }, + error: { color: designToken("text.danger"), margin: 0 }, + field: { display: "grid", gap: designToken("spacing.xs") }, + form: { display: "grid", gap: designToken("spacing.lg") }, + heading: { fontSize: designToken("typography.size-heading-2"), margin: 0 }, host: { alignItems: "center", - background: "#fafaf9", - color: "#1c1917", + background: designToken("background.page"), + color: designToken("text.primary"), display: "flex", - fontFamily: "system-ui, sans-serif", + fontFamily: designToken("typography.family-sans"), justifyContent: "center", minHeight: "100vh", - padding: "1rem", + padding: designToken("spacing.lg"), }, input: { - border: "1px solid #a8a29e", - borderRadius: "0.375rem", + background: designToken("background.surface"), + border: `${designToken("border.width")} solid ${designToken("border.control")}`, + borderRadius: designToken("radius.md"), + boxSizing: "border-box", + color: designToken("text.primary"), font: "inherit", - padding: "0.625rem 0.75rem", + minHeight: designToken("control.height-md"), + padding: `${designToken("spacing.sm")} ${designToken("control.padding-inline")}`, width: "100%", }, - introduction: { color: "#57534e", margin: 0 }, - label: { fontWeight: 600 }, + introduction: { color: designToken("text.secondary"), margin: 0 }, + label: { fontWeight: designToken("typography.weight-bold") }, secondaryAction: { background: "transparent", border: "none", - color: "#1c1917", + color: designToken("accent.strong"), cursor: "pointer", font: "inherit", justifySelf: "start", + minHeight: designToken("control.height-sm"), padding: 0, textDecoration: "underline", }, diff --git a/surfaces/shell/src/browser/ModuleUiFrame.tsx b/surfaces/shell/src/browser/ModuleUiFrame.tsx index 3957769..80b175c 100644 --- a/surfaces/shell/src/browser/ModuleUiFrame.tsx +++ b/surfaces/shell/src/browser/ModuleUiFrame.tsx @@ -1,3 +1,4 @@ +import { designToken } from "@ekklesia/design-tokens"; import { loadModuleUi, type ModuleUiImporter } from "@ekklesia/module-ui-host"; import type { ModuleUiPrincipal } from "@ekklesia/module-ui-sdk"; import { type ReactElement, useEffect, useRef, useState } from "react"; @@ -121,12 +122,12 @@ export function ModuleUiFrame(props: ModuleUiFrameProps): ReactElement { style={{ display: "grid", gap: "0.75rem", minWidth: 0, width: "100%" }} > {status === "loading" ? ( -

+

Loading module…

) : undefined} {status === "failed" ? ( -

+

This module’s interface could not be loaded. Try another module, or ask an administrator to check the installation.

diff --git a/surfaces/shell/src/browser/main.tsx b/surfaces/shell/src/browser/main.tsx index 6463848..b451c9e 100644 --- a/surfaces/shell/src/browser/main.tsx +++ b/surfaces/shell/src/browser/main.tsx @@ -1,4 +1,7 @@ -import { installEkklesiaDesignTokens } from "@ekklesia/design-tokens"; +import { + createEkklesiaThemeController, + installEkklesiaDesignTokens, +} from "@ekklesia/design-tokens"; import { EkklesiaUiStyles } from "@ekklesia/ui"; import { createRoot } from "react-dom/client"; import { consumeAuthLanding } from "../auth-protocol.js"; @@ -65,6 +68,25 @@ if (!container) { */ installEkklesiaDesignTokens(document); +/** + * The Dashboard's appearance and motion (ADR 0060), applied to the document root before React + * renders anything. + * + * Both are `system` until a person chooses otherwise: the token stylesheet then follows the + * operating system's light/dark and reduced-motion settings live, by CSS alone, into every module + * root. This is the one place a remembered choice will be supplied: the account preferences work + * reads it synchronously and passes it here, so an explicit Light or Dark is in place before the + * first frame. Nothing is stored yet, and there is no second, temporary way to choose. + */ +createEkklesiaThemeController({ + appearance: "system", + ...(typeof window.matchMedia === "function" + ? { matchMedia: (query: string) => window.matchMedia(query) } + : {}), + motion: "system", + target: document.documentElement, +}); + /** * A page whose API origin is not a canonical origin fails closed: it makes no request at all, because * every request it would make carries credentials to an address it cannot vouch for. The message is diff --git a/surfaces/shell/src/browser/shell-styles.ts b/surfaces/shell/src/browser/shell-styles.ts index 7dc725e..15c077f 100644 --- a/surfaces/shell/src/browser/shell-styles.ts +++ b/surfaces/shell/src/browser/shell-styles.ts @@ -1,9 +1,9 @@ import { designToken } from "@ekklesia/design-tokens"; /** - * The Dashboard shell's own stylesheet (ADR 0058): what inline styles cannot express — hover, - * `:focus-visible`, a reduced-motion override — for the shell's navigation, account menu, tooltip, - * and drawer. + * The Dashboard shell's own stylesheet (ADR 0058): what inline styles cannot express — hover and + * `:focus-visible` — for the shell's navigation, account menu, tooltip, and drawer. Durations are + * motion tokens, so reduced motion needs no rule here (ADR 0060). * * Every value is a design token (ADR 0039), never a literal colour. Every class is `ek-shell-` * prefixed, so nothing here can collide with `@ekklesia/ui`'s `ek-` elements, and it is installed @@ -35,7 +35,7 @@ export const SHELL_STYLESHEET = ` padding: ${designToken("spacing.md")}; position: sticky; top: 0; - transition: width 160ms ease; + transition: width ${designToken("motion.duration-short")} ease; } .ek-shell-sidebar__top { @@ -205,9 +205,5 @@ export const SHELL_STYLESHEET = ` z-index: 41; } -@media (prefers-reduced-motion: reduce) { - .ek-shell-sidebar { - transition: none; - } -} +/* The sidebar's transition lasts a motion token, which is zero when motion is reduced (ADR 0060). */ `; diff --git a/surfaces/shell/src/display/main.tsx b/surfaces/shell/src/display/main.tsx index 345e03e..3aaf879 100644 --- a/surfaces/shell/src/display/main.tsx +++ b/surfaces/shell/src/display/main.tsx @@ -1,4 +1,8 @@ -import { designToken, installEkklesiaDesignTokens } from "@ekklesia/design-tokens"; +import { + createEkklesiaThemeController, + designToken, + installEkklesiaDesignTokens, +} from "@ekklesia/design-tokens"; import { EkklesiaUiStyles } from "@ekklesia/ui"; import { createRoot } from "react-dom/client"; import type { EkklesiaHostConfig } from "../host-protocol.js"; @@ -52,6 +56,17 @@ try { * module's display artifact then inherits the tokens into its isolated root (ADR 0039). */ installEkklesiaDesignTokens(document); +/** + * A display stays light, whatever its operating system prefers (ADR 0060). A screen in a lobby or + * a sanctuary is configured once by whoever set it up, not by a person's appearance preference, and + * Stage 0 displays must look exactly as they did. Motion still follows the system. + */ +createEkklesiaThemeController({ + appearance: "light", + motion: "system", + target: document.documentElement, +}); + createRoot(container).render( <> diff --git a/surfaces/shell/tests/dashboard-assets.test.ts b/surfaces/shell/tests/dashboard-assets.test.ts index b84a094..a8fdd0b 100644 --- a/surfaces/shell/tests/dashboard-assets.test.ts +++ b/surfaces/shell/tests/dashboard-assets.test.ts @@ -34,6 +34,15 @@ describe("Dashboard host asset locator", () => { expect(rootSource).not.toContain("dashboard-assets"); }); + it("declares both colour schemes before any script runs, and takes its background from tokens", async () => { + // So a dark system never flashes a white page while the shell loads (ADR 0060). + const html = await readFile(path.join(packageDirectory, "index.html"), "utf8"); + + expect(html).toContain(''); + expect(html).toContain("background: var(--ekklesia-background-page);"); + expect(html).toContain("color: var(--ekklesia-text-primary);"); + }); + it("loads exactly one fixed, absolute, same-origin script and no inline script", async () => { const html = await readFile(path.join(packageDirectory, "index.html"), "utf8"); diff --git a/tests/theme-guards.test.ts b/tests/theme-guards.test.ts new file mode 100644 index 0000000..5fc0ad7 --- /dev/null +++ b/tests/theme-guards.test.ts @@ -0,0 +1,90 @@ +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +/** + * Every colour and corner in the Dashboard's own product UI comes from a design token (ADR 0039, + * ADR 0060), so Light, Dark, and System — and later a church's theme — reach all of it. + * + * The scan covers the shell's Dashboard source and `@ekklesia/ui`. It looks for what a design token + * would replace: a hex, `rgb()`, or `hsl()` colour, and a literal corner radius. Comments are ignored, + * and so is `@ekklesia/design-tokens` itself, which is where those values are meant to live. + */ + +const repositoryRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url))); + +const PRODUCT_SOURCE = [ + path.join("surfaces", "shell", "src", "browser"), + path.join("packages", "ui", "src"), +]; + +/** + * Deliberate, named exceptions. The display host (`surfaces/shell/src/display`) is not scanned at + * all: it is pinned to the light scheme and its pairing and offline screens keep their Stage 0 look + * until displays move to Settings → Displays. + */ +const RAW_COLOUR = /#[0-9a-fA-F]{3,8}\b|\b(?:rgba?|hsla?)\(/u; +const RAW_RADIUS = /\bborder-?[Rr]adius["']?\s*:\s*["'`]?\s*(?!var\(|\$\{)\d/u; + +function withoutComments(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//gu, "").replace(/(^|[^:])\/\/.*$/gmu, "$1"); +} + +async function files(directory: string): Promise { + const found: string[] = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const full = path.join(directory, entry.name); + if (entry.isDirectory()) { + found.push(...(await files(full))); + } else if (/\.tsx?$/u.test(entry.name)) { + found.push(full); + } + } + return found; +} + +async function offenders(pattern: RegExp): Promise { + const found: string[] = []; + for (const root of PRODUCT_SOURCE) { + for (const file of await files(path.join(repositoryRoot, root))) { + withoutComments(await readFile(file, "utf8")) + .split("\n") + .forEach((line, index) => { + if (pattern.test(line)) { + found.push(`${path.relative(repositoryRoot, file)}:${index + 1}: ${line.trim()}`); + } + }); + } + } + return found; +} + +describe("the Dashboard's product UI is built from tokens", () => { + it("has no raw colour outside the token package", async () => { + expect(await offenders(RAW_COLOUR)).toEqual([]); + }); + + it("has no literal corner radius", async () => { + expect(await offenders(RAW_RADIUS)).toEqual([]); + }); + + it("would notice one", () => { + for (const line of [ + 'color: "#9f1239",', + "background: rgb(28 25 23 / 0.08);", + 'borderRadius: "0.375rem",', + "border-radius: 4px;", + ]) { + expect(RAW_COLOUR.test(line) || RAW_RADIUS.test(line), line).toBe(true); + } + for (const line of [ + 'borderRadius: designToken("radius.md"),', + `border-radius: \${designToken("radius.md")};`, + 'querySelector("#ekklesia-host")', + 'id="ekklesia-sidebar-navigation"', + ]) { + expect(RAW_COLOUR.test(line) || RAW_RADIUS.test(line), line).toBe(false); + } + }); +});