feat(theme): Light/Dark/System, reduced motion, and one radius scale
CI / verify (pull_request) Successful in 1m10s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 52s
CI / migration-smoke (pull_request) Successful in 2m20s
CI / image-build (pull_request) Successful in 1m5s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / package-publication (pull_request) Successful in 58s
CI / deploy-smoke (split) (pull_request) Successful in 2m38s

The theme foundation DESIGN.md requires (ADR 0060):

- A design token may carry a `dark` value and a `reduced` value. The
  token stylesheet selects them in CSS alone: dark under
  prefers-color-scheme unless the host chose light, or when it chose
  dark; motion durations zero under prefers-reduced-motion unless it
  allowed motion, or when it chose reduce. `color-scheme` travels with
  the tokens. System is live, an explicit choice wins, and every module
  root follows through custom-property inheritance, with no module
  contract change.
- @ekklesia/design-tokens adds a small appearance API: system/light/dark
  and system/reduce/allow, parsers for untrusted stored values,
  resolvers, and a controller that applies the attributes immediately.
  The shell applies it before React renders. That is the hook the
  account preferences work will feed; nothing is persisted. The
  Dashboard page declares `color-scheme: light dark` so a dark system
  never flashes white. Displays stay light.
- A designed dark theme: teal-tinted charcoal, never pure black or
  white, the same teal accent.
- WCAG AA contrast is tested for 41 element pairings in both schemes.
  Two light pairings failed and are fixed visibly: text.muted darkens,
  and form controls get a new border.control (3:1).
  background.placeholder keeps skeletons visible in dark.
- Motion tokens (duration-short, duration-ambient) replace literal
  durations and every per-element reduced-motion rule. radius.scale
  multiplies sm/md/lg, unchanged at its default of 1.
- The sign-in card and module loading/error text move from literal
  colours to tokens. Guards keep raw colours and literal radii out of
  Dashboard product source and @ekklesia/ui.
- Fixes ConfirmationDialog's phrase input, which used a class that
  does not exist (`ek-input`) and so was unstyled.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-14 20:00:39 -05:00
co-authored by Claude Opus 5.5
parent e3e2a901a7
commit d42b64a093
25 changed files with 1294 additions and 78 deletions
+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 "./registry.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
* 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
+110 -6
View File
@@ -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<Name extends string = string> {
/** Every token, in declaration order. Stable, frozen, and the enumeration a theme editor reads. */
list(): readonly DesignToken[];
@@ -219,10 +254,52 @@ export interface DesignTokenRegistry<Name extends string = string> {
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<const Tokens extends readonly DesignTo
if (scope !== "root" && scope !== "host") {
throw new DesignTokenError("DESIGN_TOKEN_SCOPE_INVALID");
}
const declarationText = tokens
.map((token) => ` ${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,
});
@@ -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");
});
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);
}
});
+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"
autoComplete="off"
autoCorrect="off"
className="ek-input ek-focusable"
className="ek-control ek-focusable"
disabled={busy}
id={phraseId}
onChange={(event) => setTyped(event.currentTarget.value)}
+9 -14
View File
@@ -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. */
+6 -5
View File
@@ -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");
});
});
+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);
});
});
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-");
}
});
});