Say Remove for displays, and make the default accent purple (#210050)
CI / verify (pull_request) Successful in 1m14s
CI / infrastructure-smoke (pull_request) Successful in 42s
CI / package-release (pull_request) Successful in 49s
CI / migration-smoke (pull_request) Successful in 2m30s
CI / image-build (pull_request) Successful in 1m8s
CI / deploy-smoke (same-origin) (pull_request) Successful in 45s
CI / package-publication (pull_request) Successful in 56s
CI / deploy-smoke (split) (pull_request) Successful in 8m55s

Administrators now remove a display: the card's Remove, "Remove display?",
and "Display removed". The platform still revokes the device's credential;
the endpoint, fields, and security history keep revoke/revoked (ADR 0065).

The default accent is a deep purple anchored on #210050, set in the shared
design tokens only (ADR 0066, superseding ADR 0060's teal in part). The
anchor is the light primary action with white on it; the light accent text,
focus ring, and selection, and the dark theme's lavender accent, are
variants on its hue chosen for contrast. The teal-tinted neutrals move to a
faint violet-grey at the same lightness, so every contrast pairing holds.
The contrast test gains six pairings the accent depends on, and pins the
anchor.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-16 19:14:46 -05:00
co-authored by Claude Opus 5.5
parent d9dafcb0f7
commit 0009f62fbd
10 changed files with 261 additions and 97 deletions
+65 -61
View File
@@ -12,12 +12,16 @@ 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, 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.
* The default values are Ekklesia's calm light theme, accented with a deep purple anchored on
* `#210050` (ADR 0066), and each colour carries a `dark` value: a designed dark theme of the same
* product — violet-grey charcoal rather than black, text that is never pure white, the same purple
* lifted to a lavender with the anchor drawn on it rather than a glowing control — selected by
* `prefers-color-scheme` or by the host's explicit appearance (ADR 0060). The anchor itself is the
* light primary action; every other accent value is a variant on its hue, chosen for contrast where
* it is used, not the same hex forced everywhere. The neutrals carry a faint tint of that hue.
* 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
*
@@ -34,52 +38,52 @@ const declarations = [
description: "The furthest-back background of a whole page or a full-viewport display.",
label: "Page background",
name: "background.page",
value: "#f3f7f6",
dark: "#0e1615",
value: "#f6f5f9",
dark: "#141319",
},
{
description:
"A large working area inside a page — a scrolling list region, a preview area, an editor body.",
label: "Canvas background",
name: "background.canvas",
value: "#f8faf9",
dark: "#121b1a",
value: "#f9f9fb",
dark: "#19181f",
},
{
description: "An ordinary panel, card, table, or dialog body sitting on the page.",
label: "Surface background",
name: "background.surface",
value: "#ffffff",
dark: "#162120",
dark: "#1e1d26",
},
{
description: "A surface deliberately lifted above an ordinary surface, such as a popover.",
label: "Raised surface background",
name: "background.raised",
value: "#ffffff",
dark: "#1c2826",
dark: "#25242d",
},
{
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)",
dark: "rgba(7, 6, 11, 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",
value: "#f1f1f6",
dark: "#101015",
},
{
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",
value: "#e9e8ef",
dark: "#2f2d38",
},
// ---------------------------------------------------------------------------------------------
@@ -89,29 +93,29 @@ const declarations = [
description: "Body copy and headings — the default foreground for readable content.",
label: "Primary text",
name: "text.primary",
value: "#182321",
dark: "#e4edeb",
value: "#201f28",
dark: "#eaeaf1",
},
{
description: "Supporting text that is still content: captions, secondary columns, help text.",
label: "Secondary text",
name: "text.secondary",
value: "#4f5f5b",
dark: "#b4c3bf",
value: "#5b5966",
dark: "#bfbdca",
},
{
description: "De-emphasised text such as placeholders, timestamps, and disabled labels.",
label: "Muted text",
name: "text.muted",
value: "#5d6d69",
dark: "#93a5a0",
value: "#696774",
dark: "#a09ead",
},
{
description: "Text drawn on a strongly coloured background, such as a primary button.",
label: "Inverse text",
name: "text.inverse",
value: "#ffffff",
dark: "#0e1615",
dark: "#141319",
},
{
description: "Text reporting a successful outcome, paired with the success symbol.",
@@ -142,50 +146,50 @@ const declarations = [
description: "The primary action colour: primary buttons, selected tabs, active links.",
label: "Accent",
name: "accent.base",
value: "#4fb3a8",
dark: "#5bbcb1",
value: "#210050",
dark: "#ac9bf6",
},
{
description: "The accent background under a hovered primary action.",
label: "Accent hover",
name: "accent.hover",
value: "#5ab9ae",
dark: "#6cc5bb",
value: "#340e71",
dark: "#bbacff",
},
{
description: "The accent background while a primary action is pressed.",
label: "Accent active",
name: "accent.active",
value: "#45a59b",
dark: "#4ea99f",
value: "#120033",
dark: "#9a88e2",
},
{
description: "A deeper accent for pressed primary actions and high-emphasis emphasis.",
label: "Strong accent",
name: "accent.strong",
value: "#246b64",
dark: "#82d3c8",
value: "#4d2a99",
dark: "#c8bcff",
},
{
description: "A faint accent wash for selected rows and subtle accent backgrounds.",
label: "Subtle accent",
name: "accent.subtle",
value: "#def2ef",
dark: "#163330",
value: "#f0eeff",
dark: "#2e2849",
},
{
description: "Foreground drawn on top of the accent background.",
label: "Accent foreground",
name: "accent.foreground",
value: "#102a27",
dark: "#04201d",
value: "#ffffff",
dark: "#210050",
},
{
description: "The focus ring colour. Never the only signal that something has focus.",
label: "Focus ring",
name: "accent.focus",
value: "#287a72",
dark: "#7fd4c9",
value: "#6a40c9",
dark: "#bfb0ff",
},
// ---------------------------------------------------------------------------------------------
@@ -195,30 +199,30 @@ const declarations = [
description: "The default border around panels, inputs, and table cells.",
label: "Border",
name: "border.base",
value: "#d3dedb",
dark: "#2b3836",
value: "#dbdae3",
dark: "#35333d",
},
{
description: "A heavier border for emphasis, active inputs, and selected outlines.",
label: "Strong border",
name: "border.strong",
value: "#637571",
dark: "#8a9c98",
value: "#706e7d",
dark: "#9795a4",
},
{
description: "A hairline rule separating rows or sections inside one surface.",
label: "Divider",
name: "border.divider",
value: "#e2eae8",
dark: "#222e2c",
value: "#e8e7ee",
dark: "#2b2a33",
},
{
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",
value: "#888695",
dark: "#757383",
},
{
description: "The border width used by controls and panels.",
@@ -294,15 +298,15 @@ const declarations = [
description: "Background of a neutral informational badge or banner.",
label: "Info background",
name: "status.info-background",
value: "#def2ef",
dark: "#14322e",
value: "#f1f0ff",
dark: "#292440",
},
{
description: "Border of a neutral informational badge or banner.",
label: "Info border",
name: "status.info-border",
value: "#9bd5ce",
dark: "#2f6c63",
value: "#c7c0f2",
dark: "#564b83",
},
{
description: "The non-colour signal for information, rendered beside the label.",
@@ -318,29 +322,29 @@ const declarations = [
description: "Background wash under a hovered control or row.",
label: "Hover background",
name: "state.hover-background",
value: "#edf3f1",
dark: "#1f2c2a",
value: "#f1f1f6",
dark: "#292831",
},
{
description: "Background wash under a control being pressed.",
label: "Pressed background",
name: "state.pressed-background",
value: "#e1ebe8",
dark: "#273634",
value: "#e8e7f0",
dark: "#32313c",
},
{
description: "Background of a selected row, tab, or list item.",
label: "Selected background",
name: "state.selected-background",
value: "#def2ef",
dark: "#183b37",
value: "#eceaff",
dark: "#342b53",
},
{
description: "Foreground of a selected row, tab, or list item.",
label: "Selected foreground",
name: "state.selected-foreground",
value: "#205f59",
dark: "#a3e6dc",
value: "#48278e",
dark: "#d7d0ff",
},
{
description: "Opacity applied to a disabled control. Disabled state is never colour alone.",
@@ -557,21 +561,21 @@ const declarations = [
description: "Subtle lift for a resting panel.",
label: "Small shadow",
name: "shadow.sm",
value: "0 1px 3px rgba(24, 35, 33, 0.08)",
value: "0 1px 3px rgba(32, 31, 40, 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)",
value: "0 8px 24px rgba(32, 31, 40, 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)",
value: "0 18px 48px rgba(32, 31, 40, 0.2)",
dark: "0 18px 48px rgba(0, 0, 0, 0.6)",
},
@@ -87,6 +87,12 @@ const PAIRINGS: readonly Pairing[] = [
["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"],
["accent.base", "background.page", NON_TEXT, "the brand mark and a spinner on the page"],
["accent.base", "background.canvas", NON_TEXT, "the brand mark in the sidebar"],
["accent.base", "background.surface", NON_TEXT, "a selected tab's underline in a panel"],
["state.selected-foreground", "accent.subtle", TEXT, "a selected tab's label"],
["accent.strong", "background.raised", TEXT, "accent text in a dialog"],
["background.surface", "accent.strong", NON_TEXT, "the mark inside a checked box or radio"],
];
describe.each([
@@ -102,6 +108,19 @@ describe.each([
});
});
describe("the default accent (ADR 0066)", () => {
it("is anchored on #210050: the light primary action, with white on it", () => {
expect(ekklesiaDesignTokens.valueFor("accent.base", "light")).toBe("#210050");
expect(ekklesiaDesignTokens.valueFor("accent.foreground", "light")).toBe("#ffffff");
});
it("is lifted in dark rather than forced, and draws the anchor on it", () => {
// #210050 on a dark surface would all but vanish; the dark accent is a lavender of its hue.
expect(ekklesiaDesignTokens.valueFor("accent.base", "dark")).not.toBe("#210050");
expect(ekklesiaDesignTokens.valueFor("accent.foreground", "dark")).toBe("#210050");
});
});
describe("the contrast calculator", () => {
it("agrees with WCAG's reference values", () => {
expect(contrastRatio("#000000", "#ffffff")).toBeCloseTo(21, 5);
+4 -4
View File
@@ -99,8 +99,8 @@ describe("light and dark token sets", () => {
});
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("background.page", "light")).toBe("#f6f5f9");
expect(ekklesiaDesignTokens.valueFor("background.page", "dark")).toBe("#141319");
expect(ekklesiaDesignTokens.valueFor("spacing.md", "dark")).toBe("0.75rem");
expect(() => ekklesiaDesignTokens.valueFor("nope" as "spacing.md", "dark")).toThrow(
DesignTokenError,
@@ -132,7 +132,7 @@ describe("the theme stylesheet resolves appearance and motion in CSS", () => {
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;");
expect(stylesheet).toContain("--ekklesia-background-page: #f6f5f9;");
});
it("follows the system into dark unless light was chosen, and goes dark when dark was chosen", () => {
@@ -145,7 +145,7 @@ describe("the theme stylesheet resolves appearance and motion in CSS", () => {
const darkRule = stylesheet.slice(
stylesheet.indexOf(`:root[${DESIGN_TOKEN_APPEARANCE_ATTRIBUTE}="dark"]`),
);
expect(darkRule).toContain("--ekklesia-background-page: #0e1615;");
expect(darkRule).toContain("--ekklesia-background-page: #141319;");
});
it("zeroes every motion duration when the system asks, unless motion was allowed, and when reduce was chosen", () => {