Merge pull request 'feat(theme): Light/Dark/System, reduced motion, and one radius scale (Stage 1, ADR 0060)' (#107) from feat/theme-foundation into main
CI / verify (push) Successful in 1m10s
CI / infrastructure-smoke (push) Successful in 35s
CI / package-release (push) Successful in 51s
CI / image-build (push) Successful in 34s
CI / migration-smoke (push) Successful in 2m17s
CI / deploy-smoke (same-origin) (push) Successful in 48s
CI / package-publication (push) Successful in 58s
CI / deploy-smoke (split) (push) Successful in 2m30s

Reviewed-on: #107
Reviewed-by: Zayin_Admin <[email protected]>
This commit was merged in pull request #107.
This commit is contained in:
2026-10-15 01:22:01 +00:00
25 changed files with 1294 additions and 78 deletions
+7
View File
@@ -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` traps and restores focus, and `pnpm lint` refuses `window.alert`, `confirm`, and `prompt`
repository-wide. 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 ## Quick start
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose. Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
@@ -151,6 +151,10 @@ reaching into the artifact or the artifact importing the host.
## Deferred ## 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 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 theme, density modes, an icon set, charting, layout primitives beyond what the elements need, a
documentation site, and a focus-trap utility. documentation site, and a focus-trap utility.
@@ -0,0 +1,189 @@
# 0060: Light, Dark, and System appearance; reduced motion; and one radius scale
- Status: Accepted
- Date: 2026-10-15
- Extends: [0039](0039-shared-design-tokens-and-elements.md),
[0040](0040-module-stylesheets-and-artifact-isolation.md),
[0058](0058-dashboard-shell-navigation-and-lucide-icons.md),
[0059](0059-shared-interaction-primitives.md)
## Context
DESIGN.md requires every normal first-party interface to support Light, Dark, and System, with System
the default and a person's choice syncing across their devices (§3.2, locked decisions 25–26);
reduced motion following the operating system by default, with a personal override (§3.3, locked
27); a configurable, bounded corner radius (§3.6); shared semantic tokens for every colour (§3.1);
and WCAG AA (§24, locked 92).
ADR 0039 built the semantic token registry and explicitly deferred "a dark theme or any second theme".
The registry had one value per token and was installed as a single `:root` rule. Modules already
consumed it the right way: every module artifact mounts in an isolated shadow root (ADR 0040), and
CSS custom properties inherit through shadow boundaries, so a module reads `var(--ekklesia-…)`
without importing anything at run time.
Measured before this change, the light theme did not meet AA in two places: muted text was 4.10–4.43:1
on the page, canvas, and surface (4.5:1 required), and form controls drew their boundary in
`border.base`, 1.38:1 against a surface (3:1 required for a control's boundary, §1.4.11).
## Decision
### A token may vary by colour scheme, and by motion
`DesignTokenInput` gains two optional values, validated exactly like `value`:
- **`dark`** — the token's value in the dark scheme. Every colour, border colour, and shadow has one;
lengths, weights, and font stacks do not, and keep one value.
- **`reduced`** — the token's value when motion is reduced. Every motion token has one, and it is zero.
A registry built without either emits exactly the single rule it always did. The registry adds
`valueFor(name, scheme)`.
### The stylesheet resolves the mode, in CSS
`installEkklesiaDesignTokens` installs one stylesheet that selects values by CSS alone:
```css
:root { color-scheme: light; /* every default (light) value */ }
@media (prefers-color-scheme: dark) {
:root:not([data-ekklesia-appearance="light"]) { color-scheme: dark; /* dark values */ }
}
:root[data-ekklesia-appearance="dark"] { color-scheme: dark; /* dark values */ }
@media (prefers-reduced-motion: reduce) {
:root:not([data-ekklesia-motion="allow"]) { /* motion durations: 0 */ }
}
:root[data-ekklesia-motion="reduce"] { /* motion durations: 0 */ }
```
(and the same with `:host(…)` for an isolated root that has no inheriting ancestor).
Because the browser resolves it:
- **System is live.** An operating-system change restyles the page immediately — no script, no
listener, no reload.
- **An explicit choice wins.** `light` or `dark` on the theme root overrides the system; `system` (or
no attribute) follows it.
- **Modules follow without a contract change.** Custom properties and `color-scheme` inherit into
every module's shadow root, so the same switch restyles module UI — Signage included — with no SDK
change and no module release. This was verified in a real browser across the shadow boundary.
- **Native UI follows too.** `color-scheme` goes with the tokens, so form controls and scrollbars
match the scheme; nothing critical depends on browser-default styling.
### A small appearance API in `@ekklesia/design-tokens`
Framework-free and dependency-free, like the rest of the package:
- `EkklesiaAppearance` = `system` | `light` | `dark`; `EkklesiaMotionPreference` = `system` | `reduce` |
`allow` — the model DESIGN.md asks for, including a future "allow" override of a reduced-motion
system.
- `parseEkklesiaAppearance` / `parseEkklesiaMotionPreference` read untrusted stored values; anything
unrecognised is `system`.
- `resolveEkklesiaColorScheme` / `resolveEkklesiaReducedMotion` say what is in effect.
- `createEkklesiaThemeController({ target, matchMedia?, appearance?, motion? })` sets the two attributes
**immediately** and answers `colorScheme()`, `reducedMotion()`, `setAppearance`, `setMotion`, and
`subscribe` (which also reports system changes) for code that needs to know.
The host owns the choice. A module reads the theme through tokens and never sets appearance; nothing
in the module UI contract changed.
### The shell applies it before React renders
`surfaces/shell/src/browser/main.tsx` creates the controller on `document.documentElement` before the
first render, with `system` for both. That is the **bootstrap hook** for the account preferences
work: it will read a remembered choice synchronously and pass it here, so an explicit Light or Dark is
in place before the first frame. Nothing is persisted in this decision, and there is no temporary
user-facing switch to remove later.
The Dashboard page declares `<meta name="color-scheme" content="light dark">` and takes its body
background and text colour from tokens, so the browser's own canvas is already in the system's scheme
before any script runs: a dark system never flashes white while the shell loads. (A future explicit
choice opposite to the system can still show the system's empty canvas for the moment before the
module script runs; that is the remaining cost of a page with no inline script.)
The **display host stays Light**, whatever its operating system prefers. A screen in a lobby is
configured by whoever set it up, not by a person's appearance preference, and Stage 0 displays must
look exactly as they did; motion there still follows the system.
### The dark theme is designed, not inverted
Teal-tinted charcoal backgrounds in an elevation ladder (page `#0e1615` → canvas → surface → raised),
never pure black; primary text `#e4edeb`, never pure white; the same teal accent, slightly lifted, with
dark text on it rather than a glowing control; status colours desaturated to sit on dark tints; deeper
shadows; and a darker overlay. It is the same product in both schemes.
### Contrast is tested, and two light pairings were fixed
`packages/design-tokens/tests/contrast.test.ts` computes WCAG contrast for 41 pairings the elements
actually use — text on every background tier, quiet and selected navigation, primary and danger
buttons, field hints and errors, status notices and badges, control boundaries, and focus rings — in
both schemes: 4.5:1 for text, 3:1 for control boundaries and focus indication. A real-browser check of
rendered elements in both schemes agrees.
Two intentional, visible light-theme changes make it pass:
- `text.muted` darkens from `#6c7b77` to `#5d6d69` (5.44:1 on a surface).
- A new **`border.control`** token (`#7a8d89`, 3.5:1) draws form controls' boundaries instead of
`border.base`, which stays the quieter decorative border of cards and panels. `border.strong` is
redefined to be stronger than `border.control` in both schemes, since it is the hovered control's
border.
`background.placeholder` is new: the skeleton's colour, visibly distinct from the page and a surface
in both schemes (it had used `background.inset`, which in a dark scheme is correctly darker than the
page and made skeletons vanish).
### Motion and radius are tokens
A new **`motion`** category: `motion.duration-short` (160ms; transitions such as the sidebar and a
toast arriving) and `motion.duration-ambient` (1.6s; a loading placeholder's pulse, and the spinner
at 0.625 of it). Both are zero when motion is reduced, so an element that animates for a motion-token
duration needs no reduced-motion rule of its own; the per-element rules in `@ekklesia/ui` and the
shell are removed, which is also what lets an explicit `allow` work.
**`radius.scale`** (default `1`) multiplies `radius.sm`, `radius.md`, and `radius.lg`, which become
`calc(<today's value> * var(--ekklesia-radius-scale))`; `radius.none` and `radius.pill` do not scale.
At the default nothing looks different. A later appearance or branding control changes one value to
make every corner squarer or rounder; it should offer a bounded range (DESIGN.md §3.6).
### Every Dashboard colour and corner is a token, and that is checked
The sign-in, verification, and recovery card (`AuthCard`) and the module loading and error text
(`ModuleUiFrame`) carried literal colours; they now use tokens, and the sign-in action is the
platform's primary button. `tests/theme-guards.test.ts` scans the shell's Dashboard source and
`@ekklesia/ui` for raw colours and literal corner radii, and `@ekklesia/ui`'s own tests require its
stylesheet's colours, radii, and durations to be tokens. The display host is the named exception,
pinned light, until displays move to Settings → Displays.
## Alternatives
- **Toggle a class from JavaScript on OS changes.** Rejected: CSS media queries already do it, live,
in every isolated root, with nothing to wire up or forget to unsubscribe.
- **A second token registry or stylesheet per theme.** Rejected: two registries drift; one token with
two values cannot lose its dark half without a test failing.
- **Automatic dark values (inverting, or adjusting lightness).** Rejected: an inverted theme is exactly
the naive dark mode DESIGN.md warns against, and generated colours would not be reviewable.
- **Inline bootstrap script to apply a stored choice before paint.** Rejected: the Dashboard has no
inline script so a strict `script-src` needs no exception; the meta element covers the system case.
- **Per-element `prefers-reduced-motion` rules.** Replaced: they cannot honour an explicit `allow`, and
every new animation would have to remember one.
- **Many radius tokens per component.** Rejected: one multiplier over a three-step scale is what a
bounded "roundness" control needs.
- **Letting the display host follow the system.** Rejected for now: it would change Stage 0 screens
overnight when a kiosk's OS setting changes.
## Consequences
- The Dashboard and every module UI are Light or Dark by the operating system today; an explicit choice
is one call once the account preferences exist.
- `@ekklesia/design-tokens` adds the `motion` category, `dark` and `reduced` values, `valueFor`, three
tokens (`border.control`, `background.placeholder`, `radius.scale`) plus two motion tokens, and the
appearance API. `@ekklesia/ui` uses them. Both changes are additive; code that switches exhaustively
over `DesignTokenCategory` sees one more case.
- Visible changes in light: slightly darker muted text and form-control borders, and the sign-in card
in the platform's colours.
## Deferred
The Account → Appearance page and synced preference storage; a church theme editor and per-installation
overrides of dark values; branding; the display host's own appearance and its pairing and offline
screens' colours; the bounded radius range a future control offers; and exposing the resolved scheme
to module code (modules read it through tokens today).
+1
View File
@@ -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 - [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 - [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 - [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
+6 -1
View File
@@ -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, 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 `ErrorState` with collapsed technical detail, skeletons, and `PageHeader`'s Back — and the lint
refuses browser-native dialogs repository-wide 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 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 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 inherit into every module artifact, so a module reads `var(--ekklesia-…)` with nothing to import at
+22
View File
@@ -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 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. 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`, 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 `@ekklesia/auth-client`, `@ekklesia/surface-sdk`, and `@ekklesia/module-ui-host` are not yours to
import, and `pnpm boundaries` says so. import, and `pnpm boundaries` says so.
+162
View File
@@ -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);
}
};
},
};
}
+1
View File
@@ -1,3 +1,4 @@
export * from "./appearance.js";
export * from "./install.js"; export * from "./install.js";
export * from "./registry.js"; export * from "./registry.js";
export * from "./token.js"; export * from "./token.js";
+93 -8
View File
@@ -12,9 +12,12 @@ import {
* every consumer's meaning changing with it: `background.raised` stays "the surface that sits above * 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. * 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 — * The default values are Ekklesia's calm, teal-accented light theme, and each colour carries a
* a deployment overrides them by installing different values at its host root, and this pass * `dark` value: a designed dark theme of the same product — teal-tinted charcoal rather than black,
* deliberately ships no theme editor, brand builder, or dark-mode variant to do that with. * 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 * ## Status is never colour alone
* *
@@ -32,6 +35,7 @@ const declarations = [
label: "Page background", label: "Page background",
name: "background.page", name: "background.page",
value: "#f3f7f6", value: "#f3f7f6",
dark: "#0e1615",
}, },
{ {
description: description:
@@ -39,30 +43,43 @@ const declarations = [
label: "Canvas background", label: "Canvas background",
name: "background.canvas", name: "background.canvas",
value: "#f8faf9", value: "#f8faf9",
dark: "#121b1a",
}, },
{ {
description: "An ordinary panel, card, table, or dialog body sitting on the page.", description: "An ordinary panel, card, table, or dialog body sitting on the page.",
label: "Surface background", label: "Surface background",
name: "background.surface", name: "background.surface",
value: "#ffffff", value: "#ffffff",
dark: "#162120",
}, },
{ {
description: "A surface deliberately lifted above an ordinary surface, such as a popover.", description: "A surface deliberately lifted above an ordinary surface, such as a popover.",
label: "Raised surface background", label: "Raised surface background",
name: "background.raised", name: "background.raised",
value: "#ffffff", value: "#ffffff",
dark: "#1c2826",
}, },
{ {
description: "The scrim drawn behind a modal dialog to separate it from the page beneath.", description: "The scrim drawn behind a modal dialog to separate it from the page beneath.",
label: "Overlay background", label: "Overlay background",
name: "background.overlay", name: "background.overlay",
value: "rgba(23, 23, 21, 0.55)", 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.", description: "A recessed area such as a text input, a code block, or a well.",
label: "Inset background", label: "Inset background",
name: "background.inset", name: "background.inset",
value: "#edf3f1", 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", label: "Primary text",
name: "text.primary", name: "text.primary",
value: "#182321", value: "#182321",
dark: "#e4edeb",
}, },
{ {
description: "Supporting text that is still content: captions, secondary columns, help text.", description: "Supporting text that is still content: captions, secondary columns, help text.",
label: "Secondary text", label: "Secondary text",
name: "text.secondary", name: "text.secondary",
value: "#4f5f5b", value: "#4f5f5b",
dark: "#b4c3bf",
}, },
{ {
description: "De-emphasised text such as placeholders, timestamps, and disabled labels.", description: "De-emphasised text such as placeholders, timestamps, and disabled labels.",
label: "Muted text", label: "Muted text",
name: "text.muted", name: "text.muted",
value: "#6c7b77", value: "#5d6d69",
dark: "#93a5a0",
}, },
{ {
description: "Text drawn on a strongly coloured background, such as a primary button.", description: "Text drawn on a strongly coloured background, such as a primary button.",
label: "Inverse text", label: "Inverse text",
name: "text.inverse", name: "text.inverse",
value: "#ffffff", value: "#ffffff",
dark: "#0e1615",
}, },
{ {
description: "Text reporting a successful outcome, paired with the success symbol.", description: "Text reporting a successful outcome, paired with the success symbol.",
label: "Success text", label: "Success text",
name: "text.success", name: "text.success",
value: "#166534", value: "#166534",
dark: "#86d9a7",
}, },
{ {
description: "Text reporting a caution or a degraded state, paired with the warning symbol.", description: "Text reporting a caution or a degraded state, paired with the warning symbol.",
label: "Warning text", label: "Warning text",
name: "text.warning", name: "text.warning",
value: "#854d0e", value: "#854d0e",
dark: "#e9c37a",
}, },
{ {
description: "Text reporting a failure or a destructive consequence, paired with its symbol.", description: "Text reporting a failure or a destructive consequence, paired with its symbol.",
label: "Danger text", label: "Danger text",
name: "text.danger", name: "text.danger",
value: "#9f1239", value: "#9f1239",
dark: "#f5a6b6",
}, },
// --------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------
@@ -119,42 +143,49 @@ const declarations = [
label: "Accent", label: "Accent",
name: "accent.base", name: "accent.base",
value: "#4fb3a8", value: "#4fb3a8",
dark: "#5bbcb1",
}, },
{ {
description: "The accent background under a hovered primary action.", description: "The accent background under a hovered primary action.",
label: "Accent hover", label: "Accent hover",
name: "accent.hover", name: "accent.hover",
value: "#5ab9ae", value: "#5ab9ae",
dark: "#6cc5bb",
}, },
{ {
description: "The accent background while a primary action is pressed.", description: "The accent background while a primary action is pressed.",
label: "Accent active", label: "Accent active",
name: "accent.active", name: "accent.active",
value: "#45a59b", value: "#45a59b",
dark: "#4ea99f",
}, },
{ {
description: "A deeper accent for pressed primary actions and high-emphasis emphasis.", description: "A deeper accent for pressed primary actions and high-emphasis emphasis.",
label: "Strong accent", label: "Strong accent",
name: "accent.strong", name: "accent.strong",
value: "#246b64", value: "#246b64",
dark: "#82d3c8",
}, },
{ {
description: "A faint accent wash for selected rows and subtle accent backgrounds.", description: "A faint accent wash for selected rows and subtle accent backgrounds.",
label: "Subtle accent", label: "Subtle accent",
name: "accent.subtle", name: "accent.subtle",
value: "#def2ef", value: "#def2ef",
dark: "#163330",
}, },
{ {
description: "Foreground drawn on top of the accent background.", description: "Foreground drawn on top of the accent background.",
label: "Accent foreground", label: "Accent foreground",
name: "accent.foreground", name: "accent.foreground",
value: "#102a27", value: "#102a27",
dark: "#04201d",
}, },
{ {
description: "The focus ring colour. Never the only signal that something has focus.", description: "The focus ring colour. Never the only signal that something has focus.",
label: "Focus ring", label: "Focus ring",
name: "accent.focus", name: "accent.focus",
value: "#287a72", value: "#287a72",
dark: "#7fd4c9",
}, },
// --------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------
@@ -165,18 +196,29 @@ const declarations = [
label: "Border", label: "Border",
name: "border.base", name: "border.base",
value: "#d3dedb", value: "#d3dedb",
dark: "#2b3836",
}, },
{ {
description: "A heavier border for emphasis, active inputs, and selected outlines.", description: "A heavier border for emphasis, active inputs, and selected outlines.",
label: "Strong border", label: "Strong border",
name: "border.strong", name: "border.strong",
value: "#91a6a1", value: "#637571",
dark: "#8a9c98",
}, },
{ {
description: "A hairline rule separating rows or sections inside one surface.", description: "A hairline rule separating rows or sections inside one surface.",
label: "Divider", label: "Divider",
name: "border.divider", name: "border.divider",
value: "#e2eae8", 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.", description: "The border width used by controls and panels.",
@@ -193,12 +235,14 @@ const declarations = [
label: "Success background", label: "Success background",
name: "status.success-background", name: "status.success-background",
value: "#dcfce7", value: "#dcfce7",
dark: "#12301f",
}, },
{ {
description: "Border of a success badge or banner.", description: "Border of a success badge or banner.",
label: "Success border", label: "Success border",
name: "status.success-border", name: "status.success-border",
value: "#86efac", value: "#86efac",
dark: "#2f6e49",
}, },
{ {
description: "The non-colour signal for success, rendered beside the label.", description: "The non-colour signal for success, rendered beside the label.",
@@ -211,12 +255,14 @@ const declarations = [
label: "Warning background", label: "Warning background",
name: "status.warning-background", name: "status.warning-background",
value: "#fef3c7", value: "#fef3c7",
dark: "#33280f",
}, },
{ {
description: "Border of a warning badge or banner.", description: "Border of a warning badge or banner.",
label: "Warning border", label: "Warning border",
name: "status.warning-border", name: "status.warning-border",
value: "#fcd34d", value: "#fcd34d",
dark: "#7c6121",
}, },
{ {
description: "The non-colour signal for a warning, rendered beside the label.", description: "The non-colour signal for a warning, rendered beside the label.",
@@ -229,12 +275,14 @@ const declarations = [
label: "Danger background", label: "Danger background",
name: "status.danger-background", name: "status.danger-background",
value: "#ffe4e6", value: "#ffe4e6",
dark: "#3b1a22",
}, },
{ {
description: "Border of a danger badge or banner.", description: "Border of a danger badge or banner.",
label: "Danger border", label: "Danger border",
name: "status.danger-border", name: "status.danger-border",
value: "#fda4af", value: "#fda4af",
dark: "#8c3648",
}, },
{ {
description: "The non-colour signal for danger or failure, rendered beside the label.", description: "The non-colour signal for danger or failure, rendered beside the label.",
@@ -247,12 +295,14 @@ const declarations = [
label: "Info background", label: "Info background",
name: "status.info-background", name: "status.info-background",
value: "#def2ef", value: "#def2ef",
dark: "#14322e",
}, },
{ {
description: "Border of a neutral informational badge or banner.", description: "Border of a neutral informational badge or banner.",
label: "Info border", label: "Info border",
name: "status.info-border", name: "status.info-border",
value: "#9bd5ce", value: "#9bd5ce",
dark: "#2f6c63",
}, },
{ {
description: "The non-colour signal for information, rendered beside the label.", description: "The non-colour signal for information, rendered beside the label.",
@@ -269,24 +319,28 @@ const declarations = [
label: "Hover background", label: "Hover background",
name: "state.hover-background", name: "state.hover-background",
value: "#edf3f1", value: "#edf3f1",
dark: "#1f2c2a",
}, },
{ {
description: "Background wash under a control being pressed.", description: "Background wash under a control being pressed.",
label: "Pressed background", label: "Pressed background",
name: "state.pressed-background", name: "state.pressed-background",
value: "#e1ebe8", value: "#e1ebe8",
dark: "#273634",
}, },
{ {
description: "Background of a selected row, tab, or list item.", description: "Background of a selected row, tab, or list item.",
label: "Selected background", label: "Selected background",
name: "state.selected-background", name: "state.selected-background",
value: "#def2ef", value: "#def2ef",
dark: "#183b37",
}, },
{ {
description: "Foreground of a selected row, tab, or list item.", description: "Foreground of a selected row, tab, or list item.",
label: "Selected foreground", label: "Selected foreground",
name: "state.selected-foreground", name: "state.selected-foreground",
value: "#205f59", value: "#205f59",
dark: "#a3e6dc",
}, },
{ {
description: "Opacity applied to a disabled control. Disabled state is never colour alone.", description: "Opacity applied to a disabled control. Disabled state is never colour alone.",
@@ -462,23 +516,30 @@ const declarations = [
name: "radius.none", name: "radius.none",
value: "0", 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.", description: "Corner radius for badges and small inline elements.",
label: "Small radius", label: "Small radius",
name: "radius.sm", name: "radius.sm",
value: "0.25rem", value: "calc(0.25rem * var(--ekklesia-radius-scale))",
}, },
{ {
description: "Corner radius for controls, inputs, and buttons.", description: "Corner radius for controls, inputs, and buttons.",
label: "Medium radius", label: "Medium radius",
name: "radius.md", name: "radius.md",
value: "0.625rem", value: "calc(0.625rem * var(--ekklesia-radius-scale))",
}, },
{ {
description: "Corner radius for panels and dialogs.", description: "Corner radius for panels and dialogs.",
label: "Large radius", label: "Large radius",
name: "radius.lg", name: "radius.lg",
value: "0.875rem", value: "calc(0.875rem * var(--ekklesia-radius-scale))",
}, },
{ {
description: "Fully rounded ends, for pills and status dots.", description: "Fully rounded ends, for pills and status dots.",
@@ -497,18 +558,21 @@ const declarations = [
label: "Small shadow", label: "Small shadow",
name: "shadow.sm", name: "shadow.sm",
value: "0 1px 3px rgba(24, 35, 33, 0.08)", 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.", description: "Shadow for a popover or a raised surface.",
label: "Medium shadow", label: "Medium shadow",
name: "shadow.md", name: "shadow.md",
value: "0 8px 24px rgba(24, 35, 33, 0.12)", 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.", description: "Shadow for a modal dialog.",
label: "Large shadow", label: "Large shadow",
name: "shadow.lg", name: "shadow.lg",
value: "0 18px 48px rgba(24, 35, 33, 0.2)", 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", name: "control.padding-inline",
value: "0.75rem", 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[]; ] as const satisfies readonly DesignTokenInput[];
/** Every registered token name, as a union — the compile-time half of "modules consume registered /** Every registered token name, as a union — the compile-time half of "modules consume registered
+110 -6
View File
@@ -26,6 +26,7 @@ export const DESIGN_TOKEN_CATEGORIES = [
"radius", "radius",
"shadow", "shadow",
"control", "control",
"motion",
] as const; ] as const;
export type DesignTokenCategory = (typeof DESIGN_TOKEN_CATEGORIES)[number]; export type DesignTokenCategory = (typeof DESIGN_TOKEN_CATEGORIES)[number];
@@ -82,8 +83,18 @@ export interface DesignTokenInput {
readonly label: string; readonly label: string;
/** What the token means and when to reach for it. Enumerable, never rendered by the platform. */ /** What the token means and when to reach for it. Enumerable, never rendered by the platform. */
readonly description: string; 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; 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. */ /** 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)) { if (!isTokenValue(input.value)) {
throw new DesignTokenError("DESIGN_TOKEN_VALUE_INVALID"); 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({ return Object.freeze({
category: category as DesignTokenCategory, category: category as DesignTokenCategory,
cssVariable, cssVariable,
@@ -198,6 +215,8 @@ function validateToken(input: DesignTokenInput): DesignToken {
label: input.label, label: input.label,
name: input.name, name: input.name,
value: input.value, 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. */ * an arbitrary selector string is exactly the unbounded value this package refuses everywhere else. */
export type DesignTokenScope = "root" | "host"; 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<Name extends string = string> { export interface DesignTokenRegistry<Name extends string = string> {
/** Every token, in declaration order. Stable, frozen, and the enumeration a theme editor reads. */ /** Every token, in declaration order. Stable, frozen, and the enumeration a theme editor reads. */
list(): readonly DesignToken[]; list(): readonly DesignToken[];
@@ -219,10 +254,52 @@ export interface DesignTokenRegistry<Name extends string = string> {
variable(name: Name): string; variable(name: Name): string;
/** `var(--ekklesia-…)` for a registered token — what component code actually writes. */ /** `var(--ekklesia-…)` for a registered token — what component code actually writes. */
reference(name: Name): string; 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; 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. * Validates a declaration list and returns the immutable registry built from it.
* *
@@ -272,10 +349,37 @@ export function createDesignTokenRegistry<const Tokens extends readonly DesignTo
if (scope !== "root" && scope !== "host") { if (scope !== "root" && scope !== "host") {
throw new DesignTokenError("DESIGN_TOKEN_SCOPE_INVALID"); throw new DesignTokenError("DESIGN_TOKEN_SCOPE_INVALID");
} }
const declarationText = tokens const selectors = selectorsFor(scope);
.map((token) => ` ${token.cssVariable}: ${token.value};`) const themed = tokens.some((token) => token.dark !== undefined);
.join("\n"); const moving = tokens.some((token) => token.reduced !== undefined);
return `${scope === "root" ? ":root" : ":host"} {\n${declarationText}\n}\n`; // `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, variable: (name: string): string => requireToken(name).cssVariable,
}); });
@@ -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);
}
});
});
@@ -98,11 +98,17 @@ describe("the Ekklesia design token registry", () => {
expect(ekklesiaDesignTokens.variable("text.primary")).toBe("--ekklesia-text-primary"); 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 stylesheet = ekklesiaDesignTokenStylesheet();
const base = stylesheet.slice(0, stylesheet.indexOf("}"));
for (const token of ekklesiaDesignTokens.list()) { 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);
} }
}); });
+325
View File
@@ -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<string, string>();
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);
});
});
+1 -1
View File
@@ -150,7 +150,7 @@ export function ConfirmationDialog(props: ConfirmationDialogProps): ReactElement
autoCapitalize="off" autoCapitalize="off"
autoComplete="off" autoComplete="off"
autoCorrect="off" autoCorrect="off"
className="ek-input ek-focusable" className="ek-control ek-focusable"
disabled={busy} disabled={busy}
id={phraseId} id={phraseId}
onChange={(event) => setTyped(event.currentTarget.value)} onChange={(event) => setTyped(event.currentTarget.value)}
+9 -14
View File
@@ -162,7 +162,7 @@ export const EKKLESIA_UI_STYLESHEET = `
.ek-control { .ek-control {
background: ${designToken("background.surface")}; 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")}; border-radius: ${designToken("radius.md")};
box-sizing: border-box; box-sizing: border-box;
color: ${designToken("text.primary")}; color: ${designToken("text.primary")};
@@ -587,7 +587,8 @@ export const EKKLESIA_UI_STYLESHEET = `
} }
.ek-spinner { .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: ${designToken("state.focus-visible-width")} solid ${designToken("border.base")};
border-block-start-color: ${designToken("accent.base")}; border-block-start-color: ${designToken("accent.base")};
border-radius: ${designToken("radius.pill")}; border-radius: ${designToken("radius.pill")};
@@ -656,7 +657,7 @@ export const EKKLESIA_UI_STYLESHEET = `
} }
.ek-toast-region .ek-toast { .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; pointer-events: auto;
} }
@@ -727,8 +728,8 @@ export const EKKLESIA_UI_STYLESHEET = `
} }
.ek-skeleton { .ek-skeleton {
animation: ek-skeleton-pulse 1.6s ease-in-out infinite; animation: ek-skeleton-pulse ${designToken("motion.duration-ambient")} ease-in-out infinite;
background: ${designToken("background.inset")}; background: ${designToken("background.placeholder")};
border-radius: ${designToken("radius.sm")}; border-radius: ${designToken("radius.sm")};
display: block; 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 /* Every animation above takes its duration from a motion token, which is zero when motion is
them depends on movement to be understood. */ reduced — by the system, or by a person's own preference (ADR 0060) — so none of them needs a
@media (prefers-reduced-motion: reduce) { reduced-motion rule of its own, and nothing here depends on movement to be understood. */
.ek-spinner,
.ek-skeleton,
.ek-toast-region .ek-toast {
animation: none;
}
}
`; `;
/** Marks a stylesheet this package installed, so installation is idempotent per root. */ /** Marks a stylesheet this package installed, so installation is idempotent per root. */
+6 -5
View File
@@ -725,12 +725,13 @@ describe("Skeleton", () => {
expect(group.textContent).toBe("Loading displays"); expect(group.textContent).toBe("Loading displays");
}); });
it("stops moving for a person who asked for reduced motion", () => { it("stops moving for a person who asked for reduced motion, through the shared motion tokens", () => {
const reduced = EKKLESIA_UI_STYLESHEET.slice( // The pulse lasts one ambient cycle, which the token stylesheet zeroes when motion is reduced —
EKKLESIA_UI_STYLESHEET.indexOf("@media (prefers-reduced-motion: reduce)"), // 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(EKKLESIA_UI_STYLESHEET).not.toContain("prefers-reduced-motion");
expect(reduced).toContain("animation: none");
}); });
}); });
+28
View File
@@ -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); 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-");
}
});
});
+11 -1
View File
@@ -7,9 +7,19 @@
this page's own origin, the same-origin topology. Never an inline script, so a strict this page's own origin, the same-origin topology. Never an inline script, so a strict
script-src needs no exception for it. --> script-src needs no exception for it. -->
<meta content="" name="ekklesia-api-origin" /> <meta content="" name="ekklesia-api-origin" />
<!-- Light and dark are both supported (ADR 0060). The browser paints its own canvas in the
system's scheme before any script runs, so a dark system never flashes white first. -->
<meta content="light dark" name="color-scheme" />
<title>Ekklesia</title> <title>Ekklesia</title>
</head> </head>
<body style="margin: 0; min-width: 0"> <body
style="
margin: 0;
min-width: 0;
background: var(--ekklesia-background-page);
color: var(--ekklesia-text-primary);
"
>
<!-- The shell's root. A module UI never receives this element: it is given a container the <!-- The shell's root. A module UI never receives this element: it is given a container the
shell creates inside its own layout. --> shell creates inside its own layout. -->
<div id="ekklesia-host"></div> <div id="ekklesia-host"></div>
+36 -27
View File
@@ -1,3 +1,4 @@
import { designToken } from "@ekklesia/design-tokens";
import { type FormEvent, type ReactElement, useEffect, useRef, useState } from "react"; import { type FormEvent, type ReactElement, useEffect, useRef, useState } from "react";
import { HostAuthError } from "../auth-protocol.js"; 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. * The shared visual language of every host-owned auth screen.
* *
* Exported so the login, recovery, reset, and verification screens are literally the same card * 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 = { export const authStyles = {
button: { button: {
background: "#1c1917", background: designToken("accent.base"),
border: "1px solid #1c1917", border: `${designToken("border.width")} solid ${designToken("accent.base")}`,
borderRadius: "0.375rem", borderRadius: designToken("radius.md"),
color: "#fafaf9", color: designToken("accent.foreground"),
cursor: "pointer", cursor: "pointer",
font: "inherit", font: "inherit",
fontWeight: 600, fontWeight: designToken("typography.weight-bold"),
padding: "0.625rem 0.875rem", minHeight: designToken("control.height-md"),
padding: `${designToken("spacing.sm")} ${designToken("spacing.lg")}`,
}, },
card: { card: {
background: "#ffffff", background: designToken("background.raised"),
border: "1px solid #e7e5e4", border: `${designToken("border.width")} solid ${designToken("border.base")}`,
borderRadius: "0.75rem", borderRadius: designToken("radius.lg"),
boxShadow: "0 12px 32px rgb(28 25 23 / 0.08)", boxShadow: designToken("shadow.md"),
display: "grid", display: "grid",
gap: "1rem", gap: designToken("spacing.lg"),
maxWidth: "24rem", maxWidth: "24rem",
padding: "1.5rem", padding: designToken("spacing.xl"),
width: "100%", width: "100%",
}, },
error: { color: "#9f1239", margin: 0 }, error: { color: designToken("text.danger"), margin: 0 },
field: { display: "grid", gap: "0.375rem" }, field: { display: "grid", gap: designToken("spacing.xs") },
form: { display: "grid", gap: "1rem" }, form: { display: "grid", gap: designToken("spacing.lg") },
heading: { fontSize: "1.5rem", margin: 0 }, heading: { fontSize: designToken("typography.size-heading-2"), margin: 0 },
host: { host: {
alignItems: "center", alignItems: "center",
background: "#fafaf9", background: designToken("background.page"),
color: "#1c1917", color: designToken("text.primary"),
display: "flex", display: "flex",
fontFamily: "system-ui, sans-serif", fontFamily: designToken("typography.family-sans"),
justifyContent: "center", justifyContent: "center",
minHeight: "100vh", minHeight: "100vh",
padding: "1rem", padding: designToken("spacing.lg"),
}, },
input: { input: {
border: "1px solid #a8a29e", background: designToken("background.surface"),
borderRadius: "0.375rem", border: `${designToken("border.width")} solid ${designToken("border.control")}`,
borderRadius: designToken("radius.md"),
boxSizing: "border-box",
color: designToken("text.primary"),
font: "inherit", font: "inherit",
padding: "0.625rem 0.75rem", minHeight: designToken("control.height-md"),
padding: `${designToken("spacing.sm")} ${designToken("control.padding-inline")}`,
width: "100%", width: "100%",
}, },
introduction: { color: "#57534e", margin: 0 }, introduction: { color: designToken("text.secondary"), margin: 0 },
label: { fontWeight: 600 }, label: { fontWeight: designToken("typography.weight-bold") },
secondaryAction: { secondaryAction: {
background: "transparent", background: "transparent",
border: "none", border: "none",
color: "#1c1917", color: designToken("accent.strong"),
cursor: "pointer", cursor: "pointer",
font: "inherit", font: "inherit",
justifySelf: "start", justifySelf: "start",
minHeight: designToken("control.height-sm"),
padding: 0, padding: 0,
textDecoration: "underline", textDecoration: "underline",
}, },
+3 -2
View File
@@ -1,3 +1,4 @@
import { designToken } from "@ekklesia/design-tokens";
import { loadModuleUi, type ModuleUiImporter } from "@ekklesia/module-ui-host"; import { loadModuleUi, type ModuleUiImporter } from "@ekklesia/module-ui-host";
import type { ModuleUiPrincipal } from "@ekklesia/module-ui-sdk"; import type { ModuleUiPrincipal } from "@ekklesia/module-ui-sdk";
import { type ReactElement, useEffect, useRef, useState } from "react"; 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%" }} style={{ display: "grid", gap: "0.75rem", minWidth: 0, width: "100%" }}
> >
{status === "loading" ? ( {status === "loading" ? (
<p data-testid="module-loading" style={{ color: "#57534e", margin: 0 }}> <p data-testid="module-loading" style={{ color: designToken("text.secondary"), margin: 0 }}>
Loading module… Loading module…
</p> </p>
) : undefined} ) : undefined}
{status === "failed" ? ( {status === "failed" ? (
<p data-testid="module-error" style={{ color: "#9f1239", margin: 0 }}> <p data-testid="module-error" style={{ color: designToken("text.danger"), margin: 0 }}>
This module’s interface could not be loaded. Try another module, or ask an administrator This module’s interface could not be loaded. Try another module, or ask an administrator
to check the installation. to check the installation.
</p> </p>
+23 -1
View File
@@ -1,4 +1,7 @@
import { installEkklesiaDesignTokens } from "@ekklesia/design-tokens"; import {
createEkklesiaThemeController,
installEkklesiaDesignTokens,
} from "@ekklesia/design-tokens";
import { EkklesiaUiStyles } from "@ekklesia/ui"; import { EkklesiaUiStyles } from "@ekklesia/ui";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { consumeAuthLanding } from "../auth-protocol.js"; import { consumeAuthLanding } from "../auth-protocol.js";
@@ -65,6 +68,25 @@ if (!container) {
*/ */
installEkklesiaDesignTokens(document); 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 * 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 * every request it would make carries credentials to an address it cannot vouch for. The message is
+5 -9
View File
@@ -1,9 +1,9 @@
import { designToken } from "@ekklesia/design-tokens"; import { designToken } from "@ekklesia/design-tokens";
/** /**
* The Dashboard shell's own stylesheet (ADR 0058): what inline styles cannot express — hover, * The Dashboard shell's own stylesheet (ADR 0058): what inline styles cannot express — hover and
* `:focus-visible`, a reduced-motion override — for the shell's navigation, account menu, tooltip, * `:focus-visible` — for the shell's navigation, account menu, tooltip, and drawer. Durations are
* and drawer. * 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-` * 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 * 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")}; padding: ${designToken("spacing.md")};
position: sticky; position: sticky;
top: 0; top: 0;
transition: width 160ms ease; transition: width ${designToken("motion.duration-short")} ease;
} }
.ek-shell-sidebar__top { .ek-shell-sidebar__top {
@@ -205,9 +205,5 @@ export const SHELL_STYLESHEET = `
z-index: 41; z-index: 41;
} }
@media (prefers-reduced-motion: reduce) { /* The sidebar's transition lasts a motion token, which is zero when motion is reduced (ADR 0060). */
.ek-shell-sidebar {
transition: none;
}
}
`; `;
+16 -1
View File
@@ -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 { EkklesiaUiStyles } from "@ekklesia/ui";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import type { EkklesiaHostConfig } from "../host-protocol.js"; 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). */ * module's display artifact then inherits the tokens into its isolated root (ADR 0039). */
installEkklesiaDesignTokens(document); 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( createRoot(container).render(
<> <>
<EkklesiaUiStyles /> <EkklesiaUiStyles />
@@ -34,6 +34,15 @@ describe("Dashboard host asset locator", () => {
expect(rootSource).not.toContain("dashboard-assets"); 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('<meta content="light dark" name="color-scheme" />');
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 () => { it("loads exactly one fixed, absolute, same-origin script and no inline script", async () => {
const html = await readFile(path.join(packageDirectory, "index.html"), "utf8"); const html = await readFile(path.join(packageDirectory, "index.html"), "utf8");
+90
View File
@@ -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<string[]> {
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<string[]> {
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);
}
});
});