feat(theme): Light/Dark/System, reduced motion, and one radius scale (Stage 1, ADR 0060) #107
@@ -937,6 +937,13 @@ and `PageHeader` with a Back action that returns only to a validated Dashboard p
|
||||
traps and restores focus, and `pnpm lint` refuses `window.alert`, `confirm`, and `prompt`
|
||||
repository-wide.
|
||||
|
||||
The Dashboard follows the operating system's **Light or Dark** appearance and **reduced-motion**
|
||||
setting, live and without a reload, and so does every module's UI, through the same design tokens
|
||||
([ADR 0060](docs/architecture/decisions/0060-light-dark-system-theme-and-motion.md)). The dark theme is
|
||||
designed rather than inverted, every text and focus pairing is contrast-tested to WCAG AA in both
|
||||
schemes, corners scale from one `radius.scale` token, and an explicit Light or Dark choice is one call
|
||||
away once account preferences exist. Displays stay light.
|
||||
|
||||
## Quick start
|
||||
|
||||
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
|
||||
|
||||
@@ -151,6 +151,10 @@ reaching into the artifact or the artifact importing the host.
|
||||
|
||||
## Deferred
|
||||
|
||||
> **Cross-reference — see [ADR 0060](0060-light-dark-system-theme-and-motion.md).** A dark theme,
|
||||
> System appearance, reduced motion, and a radius scale have landed; ADR 0059 added the focus-trap
|
||||
> utility. The rest of this list is unchanged.
|
||||
|
||||
A theme editor, a brand builder, per-installation persisted overrides, a dark theme or any second
|
||||
theme, density modes, an icon set, charting, layout primitives beyond what the elements need, a
|
||||
documentation site, and a focus-trap utility.
|
||||
|
||||
@@ -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).
|
||||
@@ -68,3 +68,4 @@ ADRs capture durable choices that affect more than one package or phase.
|
||||
- [0057: Dashboard URLs are real, host-owned routes](0057-dashboard-urls-are-host-owned-routes.md) — Accepted
|
||||
- [0058: The Dashboard shell's navigation, and Lucide as Ekklesia's icon vocabulary](0058-dashboard-shell-navigation-and-lucide-icons.md) — Accepted
|
||||
- [0059: Shared interaction primitives — confirmation, toasts, errors, loading, Back, and no native dialogs](0059-shared-interaction-primitives.md) — Accepted
|
||||
- [0060: Light, Dark, and System appearance; reduced motion; and one radius scale](0060-light-dark-system-theme-and-motion.md) — Accepted
|
||||
|
||||
@@ -596,7 +596,12 @@ elements a real module dashboard cannot be built without, every value a token re
|
||||
shared interactions — a focus-trapping `Dialog` and `ConfirmationDialog`, a per-tree toast system,
|
||||
`ErrorState` with collapsed technical detail, skeletons, and `PageHeader`'s Back — and the lint
|
||||
refuses browser-native dialogs repository-wide
|
||||
([ADR 0059](decisions/0059-shared-interaction-primitives.md)). `pnpm boundaries` opens
|
||||
([ADR 0059](decisions/0059-shared-interaction-primitives.md)). Every colour token has a designed dark
|
||||
value, and the token stylesheet selects Light, Dark, or the system's scheme — and zeroes motion
|
||||
durations when motion is reduced — in CSS alone, so the Dashboard and every module root follow the
|
||||
operating system live; the host chooses an explicit appearance through a small controller, applied
|
||||
before React renders. Contrast is tested for every pairing the elements use, in both schemes
|
||||
([ADR 0060](decisions/0060-light-dark-system-theme-and-motion.md)). `pnpm boundaries` opens
|
||||
exactly two shared roles to modules **by name**, and pins both design packages beneath everything
|
||||
else. The Dashboard and display install the defaults at their document root; custom properties
|
||||
inherit into every module artifact, so a module reads `var(--ekklesia-…)` with nothing to import at
|
||||
|
||||
@@ -895,6 +895,28 @@ The host installs the default token values at its own root, and CSS custom prope
|
||||
through an isolated artifact's boundary — so the tokens are simply there, with nothing to import at
|
||||
run time and nothing for the host to hand you.
|
||||
|
||||
**Light, Dark, and System come with the tokens**
|
||||
([ADR 0060](../architecture/decisions/0060-light-dark-system-theme-and-motion.md)). The host decides
|
||||
the appearance, and the token values change under you — live when the person's system switches —
|
||||
inside your isolated root too. Your UI is themed for free as long as every colour it uses is a token:
|
||||
never assume a white background, black text, or one brand colour, and never set the appearance
|
||||
yourself. `color-scheme` follows as well, so native inputs and scrollbars match. Colours that
|
||||
represent *content* rather than interface — a preview of what a physical screen shows, an uploaded
|
||||
image — are yours to keep literal.
|
||||
|
||||
**Animate for a motion token, not a literal duration.** `motion.duration-short` (transitions) and
|
||||
`motion.duration-ambient` (a gentle, continuous indication) are zero when motion is reduced — by the
|
||||
system or by the person's preference — so an animation that uses them needs no
|
||||
`prefers-reduced-motion` rule of its own, and honours an explicit "allow motion" that such a rule
|
||||
would ignore:
|
||||
|
||||
```css
|
||||
.calendar-panel { transition: opacity var(--ekklesia-motion-duration-short) ease; }
|
||||
```
|
||||
|
||||
**Round corners with `radius.sm`, `radius.md`, or `radius.lg`.** They scale together from one
|
||||
`radius.scale` value, so a church's chosen roundness reaches your UI without you changing anything.
|
||||
|
||||
Every other shared package stays rejected inside a module: `@ekklesia/api-client`,
|
||||
`@ekklesia/auth-client`, `@ekklesia/surface-sdk`, and `@ekklesia/module-ui-host` are not yours to
|
||||
import, and `pnpm boundaries` says so.
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
export * from "./appearance.js";
|
||||
export * from "./install.js";
|
||||
export * from "./registry.js";
|
||||
export * from "./token.js";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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-");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,9 +7,19 @@
|
||||
this page's own origin, the same-origin topology. Never an inline script, so a strict
|
||||
script-src needs no exception for it. -->
|
||||
<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>
|
||||
</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
|
||||
shell creates inside its own layout. -->
|
||||
<div id="ekklesia-host"></div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { designToken } from "@ekklesia/design-tokens";
|
||||
import { type FormEvent, type ReactElement, useEffect, useRef, useState } from "react";
|
||||
import { HostAuthError } from "../auth-protocol.js";
|
||||
|
||||
@@ -5,60 +6,68 @@ import { HostAuthError } from "../auth-protocol.js";
|
||||
* The shared visual language of every host-owned auth screen.
|
||||
*
|
||||
* Exported so the login, recovery, reset, and verification screens are literally the same card
|
||||
* rather than four that drift apart. Modules never render any of them.
|
||||
* rather than four that drift apart. Modules never render any of them. Every colour, radius, and
|
||||
* size is a design token (ADR 0039, ADR 0060), so these screens follow the person's light, dark, or
|
||||
* system appearance like the rest of the Dashboard; the action is the platform's primary button.
|
||||
*/
|
||||
export const authStyles = {
|
||||
button: {
|
||||
background: "#1c1917",
|
||||
border: "1px solid #1c1917",
|
||||
borderRadius: "0.375rem",
|
||||
color: "#fafaf9",
|
||||
background: designToken("accent.base"),
|
||||
border: `${designToken("border.width")} solid ${designToken("accent.base")}`,
|
||||
borderRadius: designToken("radius.md"),
|
||||
color: designToken("accent.foreground"),
|
||||
cursor: "pointer",
|
||||
font: "inherit",
|
||||
fontWeight: 600,
|
||||
padding: "0.625rem 0.875rem",
|
||||
fontWeight: designToken("typography.weight-bold"),
|
||||
minHeight: designToken("control.height-md"),
|
||||
padding: `${designToken("spacing.sm")} ${designToken("spacing.lg")}`,
|
||||
},
|
||||
card: {
|
||||
background: "#ffffff",
|
||||
border: "1px solid #e7e5e4",
|
||||
borderRadius: "0.75rem",
|
||||
boxShadow: "0 12px 32px rgb(28 25 23 / 0.08)",
|
||||
background: designToken("background.raised"),
|
||||
border: `${designToken("border.width")} solid ${designToken("border.base")}`,
|
||||
borderRadius: designToken("radius.lg"),
|
||||
boxShadow: designToken("shadow.md"),
|
||||
display: "grid",
|
||||
gap: "1rem",
|
||||
gap: designToken("spacing.lg"),
|
||||
maxWidth: "24rem",
|
||||
padding: "1.5rem",
|
||||
padding: designToken("spacing.xl"),
|
||||
width: "100%",
|
||||
},
|
||||
error: { color: "#9f1239", margin: 0 },
|
||||
field: { display: "grid", gap: "0.375rem" },
|
||||
form: { display: "grid", gap: "1rem" },
|
||||
heading: { fontSize: "1.5rem", margin: 0 },
|
||||
error: { color: designToken("text.danger"), margin: 0 },
|
||||
field: { display: "grid", gap: designToken("spacing.xs") },
|
||||
form: { display: "grid", gap: designToken("spacing.lg") },
|
||||
heading: { fontSize: designToken("typography.size-heading-2"), margin: 0 },
|
||||
host: {
|
||||
alignItems: "center",
|
||||
background: "#fafaf9",
|
||||
color: "#1c1917",
|
||||
background: designToken("background.page"),
|
||||
color: designToken("text.primary"),
|
||||
display: "flex",
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
fontFamily: designToken("typography.family-sans"),
|
||||
justifyContent: "center",
|
||||
minHeight: "100vh",
|
||||
padding: "1rem",
|
||||
padding: designToken("spacing.lg"),
|
||||
},
|
||||
input: {
|
||||
border: "1px solid #a8a29e",
|
||||
borderRadius: "0.375rem",
|
||||
background: designToken("background.surface"),
|
||||
border: `${designToken("border.width")} solid ${designToken("border.control")}`,
|
||||
borderRadius: designToken("radius.md"),
|
||||
boxSizing: "border-box",
|
||||
color: designToken("text.primary"),
|
||||
font: "inherit",
|
||||
padding: "0.625rem 0.75rem",
|
||||
minHeight: designToken("control.height-md"),
|
||||
padding: `${designToken("spacing.sm")} ${designToken("control.padding-inline")}`,
|
||||
width: "100%",
|
||||
},
|
||||
introduction: { color: "#57534e", margin: 0 },
|
||||
label: { fontWeight: 600 },
|
||||
introduction: { color: designToken("text.secondary"), margin: 0 },
|
||||
label: { fontWeight: designToken("typography.weight-bold") },
|
||||
secondaryAction: {
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: "#1c1917",
|
||||
color: designToken("accent.strong"),
|
||||
cursor: "pointer",
|
||||
font: "inherit",
|
||||
justifySelf: "start",
|
||||
minHeight: designToken("control.height-sm"),
|
||||
padding: 0,
|
||||
textDecoration: "underline",
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { designToken } from "@ekklesia/design-tokens";
|
||||
import { loadModuleUi, type ModuleUiImporter } from "@ekklesia/module-ui-host";
|
||||
import type { ModuleUiPrincipal } from "@ekklesia/module-ui-sdk";
|
||||
import { type ReactElement, useEffect, useRef, useState } from "react";
|
||||
@@ -121,12 +122,12 @@ export function ModuleUiFrame(props: ModuleUiFrameProps): ReactElement {
|
||||
style={{ display: "grid", gap: "0.75rem", minWidth: 0, width: "100%" }}
|
||||
>
|
||||
{status === "loading" ? (
|
||||
<p data-testid="module-loading" style={{ color: "#57534e", margin: 0 }}>
|
||||
<p data-testid="module-loading" style={{ color: designToken("text.secondary"), margin: 0 }}>
|
||||
Loading module…
|
||||
</p>
|
||||
) : undefined}
|
||||
{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
|
||||
to check the installation.
|
||||
</p>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { installEkklesiaDesignTokens } from "@ekklesia/design-tokens";
|
||||
import {
|
||||
createEkklesiaThemeController,
|
||||
installEkklesiaDesignTokens,
|
||||
} from "@ekklesia/design-tokens";
|
||||
import { EkklesiaUiStyles } from "@ekklesia/ui";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { consumeAuthLanding } from "../auth-protocol.js";
|
||||
@@ -65,6 +68,25 @@ if (!container) {
|
||||
*/
|
||||
installEkklesiaDesignTokens(document);
|
||||
|
||||
/**
|
||||
* The Dashboard's appearance and motion (ADR 0060), applied to the document root before React
|
||||
* renders anything.
|
||||
*
|
||||
* Both are `system` until a person chooses otherwise: the token stylesheet then follows the
|
||||
* operating system's light/dark and reduced-motion settings live, by CSS alone, into every module
|
||||
* root. This is the one place a remembered choice will be supplied: the account preferences work
|
||||
* reads it synchronously and passes it here, so an explicit Light or Dark is in place before the
|
||||
* first frame. Nothing is stored yet, and there is no second, temporary way to choose.
|
||||
*/
|
||||
createEkklesiaThemeController({
|
||||
appearance: "system",
|
||||
...(typeof window.matchMedia === "function"
|
||||
? { matchMedia: (query: string) => window.matchMedia(query) }
|
||||
: {}),
|
||||
motion: "system",
|
||||
target: document.documentElement,
|
||||
});
|
||||
|
||||
/**
|
||||
* A page whose API origin is not a canonical origin fails closed: it makes no request at all, because
|
||||
* every request it would make carries credentials to an address it cannot vouch for. The message is
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { designToken } from "@ekklesia/design-tokens";
|
||||
|
||||
/**
|
||||
* The Dashboard shell's own stylesheet (ADR 0058): what inline styles cannot express — hover,
|
||||
* `:focus-visible`, a reduced-motion override — for the shell's navigation, account menu, tooltip,
|
||||
* and drawer.
|
||||
* The Dashboard shell's own stylesheet (ADR 0058): what inline styles cannot express — hover and
|
||||
* `:focus-visible` — for the shell's navigation, account menu, tooltip, and drawer. Durations are
|
||||
* motion tokens, so reduced motion needs no rule here (ADR 0060).
|
||||
*
|
||||
* Every value is a design token (ADR 0039), never a literal colour. Every class is `ek-shell-`
|
||||
* prefixed, so nothing here can collide with `@ekklesia/ui`'s `ek-` elements, and it is installed
|
||||
@@ -35,7 +35,7 @@ export const SHELL_STYLESHEET = `
|
||||
padding: ${designToken("spacing.md")};
|
||||
position: sticky;
|
||||
top: 0;
|
||||
transition: width 160ms ease;
|
||||
transition: width ${designToken("motion.duration-short")} ease;
|
||||
}
|
||||
|
||||
.ek-shell-sidebar__top {
|
||||
@@ -205,9 +205,5 @@ export const SHELL_STYLESHEET = `
|
||||
z-index: 41;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ek-shell-sidebar {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
/* The sidebar's transition lasts a motion token, which is zero when motion is reduced (ADR 0060). */
|
||||
`;
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { designToken, installEkklesiaDesignTokens } from "@ekklesia/design-tokens";
|
||||
import {
|
||||
createEkklesiaThemeController,
|
||||
designToken,
|
||||
installEkklesiaDesignTokens,
|
||||
} from "@ekklesia/design-tokens";
|
||||
import { EkklesiaUiStyles } from "@ekklesia/ui";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { EkklesiaHostConfig } from "../host-protocol.js";
|
||||
@@ -52,6 +56,17 @@ try {
|
||||
* module's display artifact then inherits the tokens into its isolated root (ADR 0039). */
|
||||
installEkklesiaDesignTokens(document);
|
||||
|
||||
/**
|
||||
* A display stays light, whatever its operating system prefers (ADR 0060). A screen in a lobby or
|
||||
* a sanctuary is configured once by whoever set it up, not by a person's appearance preference, and
|
||||
* Stage 0 displays must look exactly as they did. Motion still follows the system.
|
||||
*/
|
||||
createEkklesiaThemeController({
|
||||
appearance: "light",
|
||||
motion: "system",
|
||||
target: document.documentElement,
|
||||
});
|
||||
|
||||
createRoot(container).render(
|
||||
<>
|
||||
<EkklesiaUiStyles />
|
||||
|
||||
@@ -34,6 +34,15 @@ describe("Dashboard host asset locator", () => {
|
||||
expect(rootSource).not.toContain("dashboard-assets");
|
||||
});
|
||||
|
||||
it("declares both colour schemes before any script runs, and takes its background from tokens", async () => {
|
||||
// So a dark system never flashes a white page while the shell loads (ADR 0060).
|
||||
const html = await readFile(path.join(packageDirectory, "index.html"), "utf8");
|
||||
|
||||
expect(html).toContain('<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 () => {
|
||||
const html = await readFile(path.join(packageDirectory, "index.html"), "utf8");
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user