();
+ const target = {
+ setAttribute: vi.fn((name: string, value: string) => attributes.set(name, value)),
+ };
+ const matchMedia = (query: string) => {
+ if (query === EKKLESIA_DARK_SCHEME_QUERY) {
+ return dark.query;
+ }
+ if (query === EKKLESIA_REDUCED_MOTION_QUERY) {
+ return reduced.query;
+ }
+ throw new Error(`unexpected query ${query}`);
+ };
+ return { attributes, dark, matchMedia, reduced, target };
+}
+
+const COLOUR_CATEGORIES = new Set(["background", "text", "accent", "status", "state"]);
+const isColour = (value: string) => /^(?:#[0-9a-f]{6}|rgba\()/u.test(value);
+
+describe("light and dark token sets", () => {
+ it("gives every colour and shadow a dark value, so no semantic token is missing in either theme", () => {
+ const colours = ekklesiaDesignTokens
+ .list()
+ .filter((token) => isColour(token.value) || token.category === "shadow");
+ expect(colours.length).toBeGreaterThan(35);
+ for (const token of colours) {
+ if (token.name === "shadow.none") {
+ continue;
+ }
+ expect(token.dark, token.name).toBeDefined();
+ expect(token.dark, token.name).not.toBe(token.value);
+ }
+ });
+
+ it("varies only colour, shadow, and border-colour tokens by scheme", () => {
+ for (const token of ekklesiaDesignTokens.list()) {
+ if (token.dark === undefined) {
+ continue;
+ }
+ expect(
+ COLOUR_CATEGORIES.has(token.category) ||
+ token.category === "shadow" ||
+ (token.category === "border" && isColour(token.value)),
+ token.name,
+ ).toBe(true);
+ }
+ });
+
+ it("is a designed dark theme, not an inversion: no pure black or pure white surfaces or text", () => {
+ for (const name of [
+ "background.page",
+ "background.canvas",
+ "background.surface",
+ "background.raised",
+ "text.primary",
+ ] as const) {
+ expect(ekklesiaDesignTokens.valueFor(name, "dark"), name).not.toMatch(
+ /^#(?:000000|ffffff)$/u,
+ );
+ }
+ });
+
+ it("answers a token's value per scheme, falling back to its single value", () => {
+ expect(ekklesiaDesignTokens.valueFor("background.page", "light")).toBe("#f3f7f6");
+ expect(ekklesiaDesignTokens.valueFor("background.page", "dark")).toBe("#0e1615");
+ expect(ekklesiaDesignTokens.valueFor("spacing.md", "dark")).toBe("0.75rem");
+ expect(() => ekklesiaDesignTokens.valueFor("nope" as "spacing.md", "dark")).toThrow(
+ DesignTokenError,
+ );
+ });
+
+ it("validates a dark or reduced value exactly like a default value", () => {
+ for (const hostile of ["red; color: blue", "url(x)", "#fff !important", "a{b}"]) {
+ expect(() =>
+ createDesignTokenRegistry([
+ { dark: hostile, description: "d", label: "l", name: "text.x", value: "#fff" },
+ ]),
+ ).toThrow(DesignTokenError);
+ expect(() =>
+ createDesignTokenRegistry([
+ { description: "d", label: "l", name: "motion.x", reduced: hostile, value: "1s" },
+ ]),
+ ).toThrow(DesignTokenError);
+ }
+ });
+});
+
+describe("the theme stylesheet resolves appearance and motion in CSS", () => {
+ const stylesheet = ekklesiaDesignTokenStylesheet();
+
+ it("is deterministic", () => {
+ expect(ekklesiaDesignTokenStylesheet()).toBe(stylesheet);
+ });
+
+ it("is light by default and says so to the browser", () => {
+ expect(stylesheet.startsWith(":root {\n color-scheme: light;\n")).toBe(true);
+ expect(stylesheet).toContain("--ekklesia-background-page: #f3f7f6;");
+ });
+
+ it("follows the system into dark unless light was chosen, and goes dark when dark was chosen", () => {
+ expect(stylesheet).toContain(
+ `@media (prefers-color-scheme: dark) {\n:root:not([${DESIGN_TOKEN_APPEARANCE_ATTRIBUTE}="light"]) {\n color-scheme: dark;\n`,
+ );
+ expect(stylesheet).toContain(
+ `:root[${DESIGN_TOKEN_APPEARANCE_ATTRIBUTE}="dark"] {\n color-scheme: dark;\n`,
+ );
+ const darkRule = stylesheet.slice(
+ stylesheet.indexOf(`:root[${DESIGN_TOKEN_APPEARANCE_ATTRIBUTE}="dark"]`),
+ );
+ expect(darkRule).toContain("--ekklesia-background-page: #0e1615;");
+ });
+
+ it("zeroes every motion duration when the system asks, unless motion was allowed, and when reduce was chosen", () => {
+ expect(stylesheet).toContain(
+ `@media (prefers-reduced-motion: reduce) {\n:root:not([${DESIGN_TOKEN_MOTION_ATTRIBUTE}="allow"]) {\n --ekklesia-motion-duration-short: 0ms;\n --ekklesia-motion-duration-ambient: 0s;\n}\n}`,
+ );
+ expect(stylesheet).toContain(
+ `:root[${DESIGN_TOKEN_MOTION_ATTRIBUTE}="reduce"] {\n --ekklesia-motion-duration-short: 0ms;\n --ekklesia-motion-duration-ambient: 0s;\n}`,
+ );
+ for (const token of ekklesiaDesignTokens.listByCategory("motion")) {
+ expect(token.reduced, token.name).toMatch(/^0m?s$/u);
+ }
+ });
+
+ it("uses the same rules on an isolated root's host element", () => {
+ const host = ekklesiaDesignTokenStylesheet("host");
+ expect(host.startsWith(":host {")).toBe(true);
+ expect(host).toContain(`:host(:not([${DESIGN_TOKEN_APPEARANCE_ATTRIBUTE}="light"]))`);
+ expect(host).toContain(`:host([${DESIGN_TOKEN_APPEARANCE_ATTRIBUTE}="dark"])`);
+ expect(host).toContain(`:host([${DESIGN_TOKEN_MOTION_ATTRIBUTE}="reduce"])`);
+ });
+});
+
+describe("radius", () => {
+ it("scales the small, medium, and large radii from one multiplier, keeping today's values", () => {
+ expect(ekklesiaDesignTokens.get("radius.scale")?.value).toBe("1");
+ expect(ekklesiaDesignTokens.get("radius.sm")?.value).toBe(
+ "calc(0.25rem * var(--ekklesia-radius-scale))",
+ );
+ expect(ekklesiaDesignTokens.get("radius.md")?.value).toBe(
+ "calc(0.625rem * var(--ekklesia-radius-scale))",
+ );
+ expect(ekklesiaDesignTokens.get("radius.lg")?.value).toBe(
+ "calc(0.875rem * var(--ekklesia-radius-scale))",
+ );
+ expect(ekklesiaDesignTokens.get("radius.pill")?.value).toBe("9999px");
+ expect(ekklesiaDesignTokens.get("radius.none")?.value).toBe("0");
+ });
+});
+
+describe("appearance and motion preferences", () => {
+ it("default to system, and read untrusted values safely", () => {
+ expect(parseEkklesiaAppearance(undefined)).toBe("system");
+ expect(parseEkklesiaAppearance("dark")).toBe("dark");
+ expect(parseEkklesiaAppearance("DARK")).toBe("system");
+ expect(parseEkklesiaAppearance({})).toBe("system");
+ expect(parseEkklesiaMotionPreference("allow")).toBe("allow");
+ expect(parseEkklesiaMotionPreference("off")).toBe("system");
+ });
+
+ it("resolve to what is on screen", () => {
+ expect(resolveEkklesiaColorScheme("system", false)).toBe("light");
+ expect(resolveEkklesiaColorScheme("system", true)).toBe("dark");
+ expect(resolveEkklesiaColorScheme("light", true)).toBe("light");
+ expect(resolveEkklesiaColorScheme("dark", false)).toBe("dark");
+ expect(resolveEkklesiaReducedMotion("system", true)).toBe(true);
+ expect(resolveEkklesiaReducedMotion("system", false)).toBe(false);
+ expect(resolveEkklesiaReducedMotion("reduce", false)).toBe(true);
+ expect(resolveEkklesiaReducedMotion("allow", true)).toBe(false);
+ });
+});
+
+describe("the theme controller", () => {
+ it("defaults to System and applies its attributes at once, before anything renders", () => {
+ const env = environment();
+ const controller = createEkklesiaThemeController({
+ matchMedia: env.matchMedia,
+ target: env.target,
+ });
+
+ expect(controller.appearance()).toBe("system");
+ expect(controller.motion()).toBe("system");
+ expect(env.attributes.get(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe("system");
+ expect(env.attributes.get(DESIGN_TOKEN_MOTION_ATTRIBUTE)).toBe("system");
+ });
+
+ it("resolves System to Light or Dark from the system, and follows it live", () => {
+ const env = environment({ dark: false });
+ const controller = createEkklesiaThemeController({
+ matchMedia: env.matchMedia,
+ target: env.target,
+ });
+ const listener = vi.fn();
+ controller.subscribe(listener);
+ expect(controller.colorScheme()).toBe("light");
+
+ env.dark.query.set(true);
+
+ expect(controller.colorScheme()).toBe("dark");
+ expect(listener).toHaveBeenCalledTimes(1);
+ });
+
+ it("lets an explicit Light or Dark ignore the system", () => {
+ for (const appearance of ["light", "dark"] as const) {
+ const env = environment({ dark: appearance === "light" });
+ const controller = createEkklesiaThemeController({
+ appearance,
+ matchMedia: env.matchMedia,
+ target: env.target,
+ });
+ expect(controller.colorScheme(), appearance).toBe(appearance);
+ env.dark.query.set(appearance !== "light");
+ expect(controller.colorScheme(), appearance).toBe(appearance);
+ expect(env.attributes.get(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe(appearance);
+ }
+ });
+
+ it("switches mode in place, without a reload, and only notifies on a real change", () => {
+ const env = environment({ dark: true });
+ const controller = createEkklesiaThemeController({
+ matchMedia: env.matchMedia,
+ target: env.target,
+ });
+ const listener = vi.fn();
+ controller.subscribe(listener);
+
+ controller.setAppearance("light");
+ expect(env.attributes.get(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe("light");
+ expect(controller.colorScheme()).toBe("light");
+ controller.setAppearance("light");
+ controller.setAppearance("dark");
+ controller.setAppearance("system");
+ expect(controller.colorScheme()).toBe("dark");
+ expect(listener).toHaveBeenCalledTimes(3);
+ });
+
+ it("follows the system's reduced-motion setting, and supports an explicit Reduce or Allow", () => {
+ const env = environment({ reduced: false });
+ const controller = createEkklesiaThemeController({
+ matchMedia: env.matchMedia,
+ target: env.target,
+ });
+ expect(controller.reducedMotion()).toBe(false);
+ env.reduced.query.set(true);
+ expect(controller.reducedMotion()).toBe(true);
+
+ controller.setMotion("allow");
+ expect(env.attributes.get(DESIGN_TOKEN_MOTION_ATTRIBUTE)).toBe("allow");
+ expect(controller.reducedMotion()).toBe(false);
+ controller.setMotion("reduce");
+ env.reduced.query.set(false);
+ expect(controller.reducedMotion()).toBe(true);
+ });
+
+ it("takes an untrusted stored preference straight in", () => {
+ const env = environment();
+ const controller = createEkklesiaThemeController({
+ appearance: "purple" as "dark",
+ matchMedia: env.matchMedia,
+ motion: 7 as unknown as "reduce",
+ target: env.target,
+ });
+ expect(controller.appearance()).toBe("system");
+ expect(controller.motion()).toBe("system");
+ });
+
+ it("listens to the system only while someone is subscribed", () => {
+ const env = environment();
+ const controller = createEkklesiaThemeController({
+ matchMedia: env.matchMedia,
+ target: env.target,
+ });
+ const unsubscribe = controller.subscribe(() => undefined);
+ expect(env.dark.listeners.size).toBe(1);
+ expect(env.reduced.listeners.size).toBe(1);
+ unsubscribe();
+ expect(env.dark.listeners.size).toBe(0);
+ expect(env.reduced.listeners.size).toBe(0);
+ });
+
+ it("works with no media queries at all, as light with full motion", () => {
+ const env = environment();
+ const controller = createEkklesiaThemeController({ target: env.target });
+ expect(controller.colorScheme()).toBe("light");
+ expect(controller.reducedMotion()).toBe(false);
+ });
+});
diff --git a/packages/ui/src/confirmation.tsx b/packages/ui/src/confirmation.tsx
index 7063019..070a46c 100644
--- a/packages/ui/src/confirmation.tsx
+++ b/packages/ui/src/confirmation.tsx
@@ -150,7 +150,7 @@ export function ConfirmationDialog(props: ConfirmationDialogProps): ReactElement
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
- className="ek-input ek-focusable"
+ className="ek-control ek-focusable"
disabled={busy}
id={phraseId}
onChange={(event) => setTyped(event.currentTarget.value)}
diff --git a/packages/ui/src/styles.ts b/packages/ui/src/styles.ts
index 935bebb..00fcc1d 100644
--- a/packages/ui/src/styles.ts
+++ b/packages/ui/src/styles.ts
@@ -162,7 +162,7 @@ export const EKKLESIA_UI_STYLESHEET = `
.ek-control {
background: ${designToken("background.surface")};
- border: ${designToken("border.width")} solid ${designToken("border.base")};
+ border: ${designToken("border.width")} solid ${designToken("border.control")};
border-radius: ${designToken("radius.md")};
box-sizing: border-box;
color: ${designToken("text.primary")};
@@ -587,7 +587,8 @@ export const EKKLESIA_UI_STYLESHEET = `
}
.ek-spinner {
- animation: ek-spin 1s linear infinite;
+ /* Spins at 0.625 of the ambient cycle (1s by default); not at all when motion is reduced. */
+ animation: ek-spin calc(${designToken("motion.duration-ambient")} * 0.625) linear infinite;
border: ${designToken("state.focus-visible-width")} solid ${designToken("border.base")};
border-block-start-color: ${designToken("accent.base")};
border-radius: ${designToken("radius.pill")};
@@ -656,7 +657,7 @@ export const EKKLESIA_UI_STYLESHEET = `
}
.ek-toast-region .ek-toast {
- animation: ek-toast-enter 160ms ease-out;
+ animation: ek-toast-enter ${designToken("motion.duration-short")} ease-out;
pointer-events: auto;
}
@@ -727,8 +728,8 @@ export const EKKLESIA_UI_STYLESHEET = `
}
.ek-skeleton {
- animation: ek-skeleton-pulse 1.6s ease-in-out infinite;
- background: ${designToken("background.inset")};
+ animation: ek-skeleton-pulse ${designToken("motion.duration-ambient")} ease-in-out infinite;
+ background: ${designToken("background.placeholder")};
border-radius: ${designToken("radius.sm")};
display: block;
}
@@ -768,15 +769,9 @@ export const EKKLESIA_UI_STYLESHEET = `
}
}
-/* Every animation in these elements stops for a person who asked for reduced motion; nothing in
- them depends on movement to be understood. */
-@media (prefers-reduced-motion: reduce) {
- .ek-spinner,
- .ek-skeleton,
- .ek-toast-region .ek-toast {
- animation: none;
- }
-}
+/* Every animation above takes its duration from a motion token, which is zero when motion is
+ reduced — by the system, or by a person's own preference (ADR 0060) — so none of them needs a
+ reduced-motion rule of its own, and nothing here depends on movement to be understood. */
`;
/** Marks a stylesheet this package installed, so installation is idempotent per root. */
diff --git a/packages/ui/tests/interaction.test.tsx b/packages/ui/tests/interaction.test.tsx
index d041dfc..e97b352 100644
--- a/packages/ui/tests/interaction.test.tsx
+++ b/packages/ui/tests/interaction.test.tsx
@@ -725,12 +725,13 @@ describe("Skeleton", () => {
expect(group.textContent).toBe("Loading displays");
});
- it("stops moving for a person who asked for reduced motion", () => {
- const reduced = EKKLESIA_UI_STYLESHEET.slice(
- EKKLESIA_UI_STYLESHEET.indexOf("@media (prefers-reduced-motion: reduce)"),
+ it("stops moving for a person who asked for reduced motion, through the shared motion tokens", () => {
+ // The pulse lasts one ambient cycle, which the token stylesheet zeroes when motion is reduced —
+ // by the system or by an explicit preference (ADR 0060) — so the element needs no rule of its own.
+ expect(EKKLESIA_UI_STYLESHEET).toMatch(
+ /\.ek-skeleton \{[^}]*animation: ek-skeleton-pulse var\(--ekklesia-motion-duration-ambient\)/su,
);
- expect(reduced).toMatch(/\.ek-skeleton[,\s]/u);
- expect(reduced).toContain("animation: none");
+ expect(EKKLESIA_UI_STYLESHEET).not.toContain("prefers-reduced-motion");
});
});
diff --git a/packages/ui/tests/tokens.test.ts b/packages/ui/tests/tokens.test.ts
index b81827c..941cacd 100644
--- a/packages/ui/tests/tokens.test.ts
+++ b/packages/ui/tests/tokens.test.ts
@@ -91,3 +91,31 @@ describe("the shared elements consume design tokens, never hardcoded theme value
expect(document.querySelectorAll(`style[${EKKLESIA_UI_STYLE_ATTRIBUTE}]`).length).toBe(0);
});
});
+
+describe("the element stylesheet takes every colour, radius, and duration from a token (ADR 0060)", () => {
+ // Comments are dropped: they may mention a value without using it.
+ const css = EKKLESIA_UI_STYLESHEET.replace(/\/\*[\s\S]*?\*\//gu, "");
+
+ it("contains no raw colour", () => {
+ expect(css).not.toMatch(/#[0-9a-f]{3,8}\b/iu);
+ expect(css).not.toMatch(/\b(?:rgba?|hsla?|oklch|lab|lch|hwb)\(/iu);
+ // A named colour is a raw colour too; `transparent` and `currentColor` are not colours.
+ expect(css).not.toMatch(/:\s*(?:white|black|red|green|blue|gray|grey)\s*;/iu);
+ });
+
+ it("rounds corners only with radius tokens", () => {
+ for (const match of css.matchAll(/border-radius:\s*([^;]+);/gu)) {
+ // Each corner is a radius token, or square.
+ for (const corner of (match[1] ?? "").split(/\s+/u)) {
+ expect(corner, match[0]).toMatch(/^(?:var\(--ekklesia-radius-[a-z]+\)|0)$/u);
+ }
+ }
+ });
+
+ it("animates only for motion-token durations", () => {
+ for (const match of css.matchAll(/(?:animation|transition):\s*([^;]+);/gu)) {
+ expect(match[1], match[0]).not.toMatch(/\b\d+(?:\.\d+)?m?s\b/u);
+ expect(match[1], match[0]).toContain("--ekklesia-motion-duration-");
+ }
+ });
+});
diff --git a/surfaces/shell/index.html b/surfaces/shell/index.html
index 5e76d50..f1437cc 100644
--- a/surfaces/shell/index.html
+++ b/surfaces/shell/index.html
@@ -7,9 +7,19 @@
this page's own origin, the same-origin topology. Never an inline script, so a strict
script-src needs no exception for it. -->
+
+
Ekklesia
-
+
diff --git a/surfaces/shell/src/browser/AuthCard.tsx b/surfaces/shell/src/browser/AuthCard.tsx
index 8d94971..5a6e72e 100644
--- a/surfaces/shell/src/browser/AuthCard.tsx
+++ b/surfaces/shell/src/browser/AuthCard.tsx
@@ -1,3 +1,4 @@
+import { designToken } from "@ekklesia/design-tokens";
import { type FormEvent, type ReactElement, useEffect, useRef, useState } from "react";
import { HostAuthError } from "../auth-protocol.js";
@@ -5,60 +6,68 @@ import { HostAuthError } from "../auth-protocol.js";
* The shared visual language of every host-owned auth screen.
*
* Exported so the login, recovery, reset, and verification screens are literally the same card
- * rather than four that drift apart. Modules never render any of them.
+ * rather than four that drift apart. Modules never render any of them. Every colour, radius, and
+ * size is a design token (ADR 0039, ADR 0060), so these screens follow the person's light, dark, or
+ * system appearance like the rest of the Dashboard; the action is the platform's primary button.
*/
export const authStyles = {
button: {
- background: "#1c1917",
- border: "1px solid #1c1917",
- borderRadius: "0.375rem",
- color: "#fafaf9",
+ background: designToken("accent.base"),
+ border: `${designToken("border.width")} solid ${designToken("accent.base")}`,
+ borderRadius: designToken("radius.md"),
+ color: designToken("accent.foreground"),
cursor: "pointer",
font: "inherit",
- fontWeight: 600,
- padding: "0.625rem 0.875rem",
+ fontWeight: designToken("typography.weight-bold"),
+ minHeight: designToken("control.height-md"),
+ padding: `${designToken("spacing.sm")} ${designToken("spacing.lg")}`,
},
card: {
- background: "#ffffff",
- border: "1px solid #e7e5e4",
- borderRadius: "0.75rem",
- boxShadow: "0 12px 32px rgb(28 25 23 / 0.08)",
+ background: designToken("background.raised"),
+ border: `${designToken("border.width")} solid ${designToken("border.base")}`,
+ borderRadius: designToken("radius.lg"),
+ boxShadow: designToken("shadow.md"),
display: "grid",
- gap: "1rem",
+ gap: designToken("spacing.lg"),
maxWidth: "24rem",
- padding: "1.5rem",
+ padding: designToken("spacing.xl"),
width: "100%",
},
- error: { color: "#9f1239", margin: 0 },
- field: { display: "grid", gap: "0.375rem" },
- form: { display: "grid", gap: "1rem" },
- heading: { fontSize: "1.5rem", margin: 0 },
+ error: { color: designToken("text.danger"), margin: 0 },
+ field: { display: "grid", gap: designToken("spacing.xs") },
+ form: { display: "grid", gap: designToken("spacing.lg") },
+ heading: { fontSize: designToken("typography.size-heading-2"), margin: 0 },
host: {
alignItems: "center",
- background: "#fafaf9",
- color: "#1c1917",
+ background: designToken("background.page"),
+ color: designToken("text.primary"),
display: "flex",
- fontFamily: "system-ui, sans-serif",
+ fontFamily: designToken("typography.family-sans"),
justifyContent: "center",
minHeight: "100vh",
- padding: "1rem",
+ padding: designToken("spacing.lg"),
},
input: {
- border: "1px solid #a8a29e",
- borderRadius: "0.375rem",
+ background: designToken("background.surface"),
+ border: `${designToken("border.width")} solid ${designToken("border.control")}`,
+ borderRadius: designToken("radius.md"),
+ boxSizing: "border-box",
+ color: designToken("text.primary"),
font: "inherit",
- padding: "0.625rem 0.75rem",
+ minHeight: designToken("control.height-md"),
+ padding: `${designToken("spacing.sm")} ${designToken("control.padding-inline")}`,
width: "100%",
},
- introduction: { color: "#57534e", margin: 0 },
- label: { fontWeight: 600 },
+ introduction: { color: designToken("text.secondary"), margin: 0 },
+ label: { fontWeight: designToken("typography.weight-bold") },
secondaryAction: {
background: "transparent",
border: "none",
- color: "#1c1917",
+ color: designToken("accent.strong"),
cursor: "pointer",
font: "inherit",
justifySelf: "start",
+ minHeight: designToken("control.height-sm"),
padding: 0,
textDecoration: "underline",
},
diff --git a/surfaces/shell/src/browser/ModuleUiFrame.tsx b/surfaces/shell/src/browser/ModuleUiFrame.tsx
index 3957769..80b175c 100644
--- a/surfaces/shell/src/browser/ModuleUiFrame.tsx
+++ b/surfaces/shell/src/browser/ModuleUiFrame.tsx
@@ -1,3 +1,4 @@
+import { designToken } from "@ekklesia/design-tokens";
import { loadModuleUi, type ModuleUiImporter } from "@ekklesia/module-ui-host";
import type { ModuleUiPrincipal } from "@ekklesia/module-ui-sdk";
import { type ReactElement, useEffect, useRef, useState } from "react";
@@ -121,12 +122,12 @@ export function ModuleUiFrame(props: ModuleUiFrameProps): ReactElement {
style={{ display: "grid", gap: "0.75rem", minWidth: 0, width: "100%" }}
>
{status === "loading" ? (
-
+
Loading module…
) : undefined}
{status === "failed" ? (
-
+
This module’s interface could not be loaded. Try another module, or ask an administrator
to check the installation.
diff --git a/surfaces/shell/src/browser/main.tsx b/surfaces/shell/src/browser/main.tsx
index 6463848..b451c9e 100644
--- a/surfaces/shell/src/browser/main.tsx
+++ b/surfaces/shell/src/browser/main.tsx
@@ -1,4 +1,7 @@
-import { installEkklesiaDesignTokens } from "@ekklesia/design-tokens";
+import {
+ createEkklesiaThemeController,
+ installEkklesiaDesignTokens,
+} from "@ekklesia/design-tokens";
import { EkklesiaUiStyles } from "@ekklesia/ui";
import { createRoot } from "react-dom/client";
import { consumeAuthLanding } from "../auth-protocol.js";
@@ -65,6 +68,25 @@ if (!container) {
*/
installEkklesiaDesignTokens(document);
+/**
+ * The Dashboard's appearance and motion (ADR 0060), applied to the document root before React
+ * renders anything.
+ *
+ * Both are `system` until a person chooses otherwise: the token stylesheet then follows the
+ * operating system's light/dark and reduced-motion settings live, by CSS alone, into every module
+ * root. This is the one place a remembered choice will be supplied: the account preferences work
+ * reads it synchronously and passes it here, so an explicit Light or Dark is in place before the
+ * first frame. Nothing is stored yet, and there is no second, temporary way to choose.
+ */
+createEkklesiaThemeController({
+ appearance: "system",
+ ...(typeof window.matchMedia === "function"
+ ? { matchMedia: (query: string) => window.matchMedia(query) }
+ : {}),
+ motion: "system",
+ target: document.documentElement,
+});
+
/**
* A page whose API origin is not a canonical origin fails closed: it makes no request at all, because
* every request it would make carries credentials to an address it cannot vouch for. The message is
diff --git a/surfaces/shell/src/browser/shell-styles.ts b/surfaces/shell/src/browser/shell-styles.ts
index 7dc725e..15c077f 100644
--- a/surfaces/shell/src/browser/shell-styles.ts
+++ b/surfaces/shell/src/browser/shell-styles.ts
@@ -1,9 +1,9 @@
import { designToken } from "@ekklesia/design-tokens";
/**
- * The Dashboard shell's own stylesheet (ADR 0058): what inline styles cannot express — hover,
- * `:focus-visible`, a reduced-motion override — for the shell's navigation, account menu, tooltip,
- * and drawer.
+ * The Dashboard shell's own stylesheet (ADR 0058): what inline styles cannot express — hover and
+ * `:focus-visible` — for the shell's navigation, account menu, tooltip, and drawer. Durations are
+ * motion tokens, so reduced motion needs no rule here (ADR 0060).
*
* Every value is a design token (ADR 0039), never a literal colour. Every class is `ek-shell-`
* prefixed, so nothing here can collide with `@ekklesia/ui`'s `ek-` elements, and it is installed
@@ -35,7 +35,7 @@ export const SHELL_STYLESHEET = `
padding: ${designToken("spacing.md")};
position: sticky;
top: 0;
- transition: width 160ms ease;
+ transition: width ${designToken("motion.duration-short")} ease;
}
.ek-shell-sidebar__top {
@@ -205,9 +205,5 @@ export const SHELL_STYLESHEET = `
z-index: 41;
}
-@media (prefers-reduced-motion: reduce) {
- .ek-shell-sidebar {
- transition: none;
- }
-}
+/* The sidebar's transition lasts a motion token, which is zero when motion is reduced (ADR 0060). */
`;
diff --git a/surfaces/shell/src/display/main.tsx b/surfaces/shell/src/display/main.tsx
index 345e03e..3aaf879 100644
--- a/surfaces/shell/src/display/main.tsx
+++ b/surfaces/shell/src/display/main.tsx
@@ -1,4 +1,8 @@
-import { designToken, installEkklesiaDesignTokens } from "@ekklesia/design-tokens";
+import {
+ createEkklesiaThemeController,
+ designToken,
+ installEkklesiaDesignTokens,
+} from "@ekklesia/design-tokens";
import { EkklesiaUiStyles } from "@ekklesia/ui";
import { createRoot } from "react-dom/client";
import type { EkklesiaHostConfig } from "../host-protocol.js";
@@ -52,6 +56,17 @@ try {
* module's display artifact then inherits the tokens into its isolated root (ADR 0039). */
installEkklesiaDesignTokens(document);
+/**
+ * A display stays light, whatever its operating system prefers (ADR 0060). A screen in a lobby or
+ * a sanctuary is configured once by whoever set it up, not by a person's appearance preference, and
+ * Stage 0 displays must look exactly as they did. Motion still follows the system.
+ */
+createEkklesiaThemeController({
+ appearance: "light",
+ motion: "system",
+ target: document.documentElement,
+});
+
createRoot(container).render(
<>
diff --git a/surfaces/shell/tests/dashboard-assets.test.ts b/surfaces/shell/tests/dashboard-assets.test.ts
index b84a094..a8fdd0b 100644
--- a/surfaces/shell/tests/dashboard-assets.test.ts
+++ b/surfaces/shell/tests/dashboard-assets.test.ts
@@ -34,6 +34,15 @@ describe("Dashboard host asset locator", () => {
expect(rootSource).not.toContain("dashboard-assets");
});
+ it("declares both colour schemes before any script runs, and takes its background from tokens", async () => {
+ // So a dark system never flashes a white page while the shell loads (ADR 0060).
+ const html = await readFile(path.join(packageDirectory, "index.html"), "utf8");
+
+ expect(html).toContain('');
+ expect(html).toContain("background: var(--ekklesia-background-page);");
+ expect(html).toContain("color: var(--ekklesia-text-primary);");
+ });
+
it("loads exactly one fixed, absolute, same-origin script and no inline script", async () => {
const html = await readFile(path.join(packageDirectory, "index.html"), "utf8");
diff --git a/tests/theme-guards.test.ts b/tests/theme-guards.test.ts
new file mode 100644
index 0000000..5fc0ad7
--- /dev/null
+++ b/tests/theme-guards.test.ts
@@ -0,0 +1,90 @@
+import { readdir, readFile } from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it } from "vitest";
+
+/**
+ * Every colour and corner in the Dashboard's own product UI comes from a design token (ADR 0039,
+ * ADR 0060), so Light, Dark, and System — and later a church's theme — reach all of it.
+ *
+ * The scan covers the shell's Dashboard source and `@ekklesia/ui`. It looks for what a design token
+ * would replace: a hex, `rgb()`, or `hsl()` colour, and a literal corner radius. Comments are ignored,
+ * and so is `@ekklesia/design-tokens` itself, which is where those values are meant to live.
+ */
+
+const repositoryRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
+
+const PRODUCT_SOURCE = [
+ path.join("surfaces", "shell", "src", "browser"),
+ path.join("packages", "ui", "src"),
+];
+
+/**
+ * Deliberate, named exceptions. The display host (`surfaces/shell/src/display`) is not scanned at
+ * all: it is pinned to the light scheme and its pairing and offline screens keep their Stage 0 look
+ * until displays move to Settings → Displays.
+ */
+const RAW_COLOUR = /#[0-9a-fA-F]{3,8}\b|\b(?:rgba?|hsla?)\(/u;
+const RAW_RADIUS = /\bborder-?[Rr]adius["']?\s*:\s*["'`]?\s*(?!var\(|\$\{)\d/u;
+
+function withoutComments(source: string): string {
+ return source.replace(/\/\*[\s\S]*?\*\//gu, "").replace(/(^|[^:])\/\/.*$/gmu, "$1");
+}
+
+async function files(directory: string): Promise {
+ const found: string[] = [];
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
+ const full = path.join(directory, entry.name);
+ if (entry.isDirectory()) {
+ found.push(...(await files(full)));
+ } else if (/\.tsx?$/u.test(entry.name)) {
+ found.push(full);
+ }
+ }
+ return found;
+}
+
+async function offenders(pattern: RegExp): Promise {
+ const found: string[] = [];
+ for (const root of PRODUCT_SOURCE) {
+ for (const file of await files(path.join(repositoryRoot, root))) {
+ withoutComments(await readFile(file, "utf8"))
+ .split("\n")
+ .forEach((line, index) => {
+ if (pattern.test(line)) {
+ found.push(`${path.relative(repositoryRoot, file)}:${index + 1}: ${line.trim()}`);
+ }
+ });
+ }
+ }
+ return found;
+}
+
+describe("the Dashboard's product UI is built from tokens", () => {
+ it("has no raw colour outside the token package", async () => {
+ expect(await offenders(RAW_COLOUR)).toEqual([]);
+ });
+
+ it("has no literal corner radius", async () => {
+ expect(await offenders(RAW_RADIUS)).toEqual([]);
+ });
+
+ it("would notice one", () => {
+ for (const line of [
+ 'color: "#9f1239",',
+ "background: rgb(28 25 23 / 0.08);",
+ 'borderRadius: "0.375rem",',
+ "border-radius: 4px;",
+ ]) {
+ expect(RAW_COLOUR.test(line) || RAW_RADIUS.test(line), line).toBe(true);
+ }
+ for (const line of [
+ 'borderRadius: designToken("radius.md"),',
+ `border-radius: \${designToken("radius.md")};`,
+ 'querySelector("#ekklesia-host")',
+ 'id="ekklesia-sidebar-navigation"',
+ ]) {
+ expect(RAW_COLOUR.test(line) || RAW_RADIUS.test(line), line).toBe(false);
+ }
+ });
+});