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
@@ -2,6 +2,7 @@
- Status: Accepted
- Date: 2026-10-15
- Teal accent and teal-tinted dark palette superseded by: [0066](0066-default-purple-accent.md)
- 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),
@@ -147,11 +147,17 @@ and a `role="listbox"` of options).
### The normal list shows active displays, current as they change
The Paired displays list shows **active** displays only. A revoked display leaves it at once — the
page asks for the list again right after revoking — and the page says so, with focus on that notice
because the display's own card is gone. The revoked device row and its permission rows stay in the
The Paired displays list shows **active** displays only. A removed display leaves it at once — the
page asks for the list again right after removing it — and the page says so, with focus on that
notice because the display's own card is gone. The device row and its permission rows stay in the
database, marked revoked, as security history; a history view can show them later.
**Administrators remove a display; the platform revokes its credential.** The interface says
*Remove*, *Remove display?*, and *Display removed*, and never shows "revoked". The domain does not
change for presentation: the device and its permissions are revoked, the endpoint is still
`POST /api/v1/devices/:id/revoke`, the fields are still `revoked`/`revokedAt`, and security and audit
history still describe a credential revocation.
A newly paired display appears without a reload. Approval does not create the display: the screen
does, when it collects its credential on its next poll, a few seconds later (ADR 0036). So after
approving, the page shows a "Waiting for the screen to connect…" placeholder and asks for the list
@@ -166,7 +172,7 @@ Each active display shows:
purpose: last-seen is recorded at most every 15 minutes;
- **Showing:** a module, or *Nothing yet*;
- last seen and paired dates;
- **Change** and **Revoke**.
- **Change** and **Remove**.
### Changing what a display shows: one target, the active display permission
@@ -0,0 +1,112 @@
# 0066: The default accent is a deep purple anchored on `#210050`
- Status: Accepted
- Date: 2026-10-16
- Supersedes, in part: [0060](0060-light-dark-system-theme-and-motion.md) (the teal accent and the
teal-tinted charcoal of its designed dark theme)
- Extends: [0039](0039-shared-design-tokens-and-elements.md),
[0060](0060-light-dark-system-theme-and-motion.md)
## Context
Ekklesia's default theme was accented in teal. The light primary action was a pale teal (`#4fb3a8`)
with dark text on it. ADR 0060's designed dark theme described "teal-tinted charcoal" backgrounds and
"the same teal accent, slightly lifted". The neutrals carried the same teal tint, so did the
selection and information washes, and the whole product read as teal.
The maintainer chose a purple default anchored on `#210050`. Branding settings will later let an
administrator choose the accent. This decision changes only the **default**.
A single hex cannot serve every place the accent is used. `#210050` is very dark: it is 17.8:1
against white, but about 1.2:1 against a dark-theme page, where it would all but vanish.
## Decision
### The accent is set through the semantic tokens only
The change is made in `@ekklesia/design-tokens`' registry, and nowhere else. No Dashboard component
names a purple. The shared `@ekklesia/ui` elements and the shell already draw from `accent.*`,
`state.selected-*`, and `status.info-*`, so they inherit the new values. Modules read the same
`--ekklesia-*` custom properties (ADR 0040), so their token inheritance is unchanged.
### `#210050` is the light primary action; every other value is a variant on its hue
Each variant keeps the anchor's OKLCH hue (≈291.5°). Lightness and chroma are chosen for contrast
where the variant is used.
| Token | Light | Dark | Used for |
| --- | --- | --- | --- |
| `accent.base` | **`#210050`** | `#ac9bf6` | primary button, tab underline, spinner, brand mark |
| `accent.hover` | `#340e71` | `#bbacff` | hovered primary button |
| `accent.active` | `#120033` | `#9a88e2` | pressed primary button |
| `accent.foreground` | `#ffffff` | **`#210050`** | text on the accent |
| `accent.strong` | `#4d2a99` | `#c8bcff` | accent text and icons; checked box and radio |
| `accent.subtle` | `#f0eeff` | `#2e2849` | selected tab wash |
| `accent.focus` | `#6a40c9` | `#bfb0ff` | focus ring |
| `state.selected-background` | `#eceaff` | `#342b53` | the open page in navigation, selected option |
| `state.selected-foreground` | `#48278e` | `#d7d0ff` | its label |
| `status.info-background` | `#f1f0ff` | `#292440` | information notice and badge |
| `status.info-border` | `#c7c0f2` | `#564b83` | its border |
**The light theme turns around.** A pale accent with dark text becomes the deep anchor with white
text: 17.8:1, or 14.5:1 hovered.
**Light accent text and the focus ring stay off the anchor itself.** In body copy `#210050` is
almost indistinguishable from near-black text, so accent text and icons use a brighter `#4d2a99`
(10.0:1 on a surface). The focus ring uses a vivid `#6a40c9` (6.1:1 on the page). Next to a dark
primary button, a ring in the anchor's own colour would merge into the button; this one is clearly
distinct.
**Dark keeps ADR 0060's principle: lifted, not glowing.** The accent is a lavender (`#ac9bf6`). The
anchor is drawn on it as its foreground, at 7.4:1, and at 5.9:1 pressed. Selected and information
washes are deep purple tints, never saturated fills.
### The neutrals lose their teal tint
Every teal-tinted neutral moves to the anchor's hue at the same OKLab lightness and chroma. This
covers the backgrounds, text, borders, hover and pressed washes, the placeholder, the shadows'
colour, and the dark overlay.
- **Light:** a faint violet-grey (page `#f6f5f9`, text `#201f28`).
- **Dark:** a violet-grey charcoal (page `#141319`, text `#eaeaf1`).
ADR 0060's other rules still hold: never pure black, never pure white, and an elevation ladder.
Because lightness is kept, contrast is kept. The tightest pairings move by less than 0.1. For
example, `border.control` on the light page goes from 3.24 to 3.28, and `text.muted` on the light
page from 5.04 to 5.10. Success, warning, and danger keep their own hues.
### Contrast stays tested
`packages/design-tokens/tests/contrast.test.ts` keeps all 41 pairings it already checked, in both
schemes. It adds six pairings this accent depends on:
- the accent as a non-text indicator on the page, the canvas, and a surface;
- the selected tab's label on its wash;
- accent text in a dialog;
- the mark inside a checked box or radio.
A test also pins the anchor: `accent.base` is `#210050` with white on it in light, and in dark it is
lifted, with the anchor as its foreground.
## Alternatives
- **The same hex everywhere.** Rejected: `#210050` fails as an indicator on dark surfaces at about
1.2:1. As light text, it is too close to the body copy to read as an accent.
- **Purple accent tokens, teal-tinted neutrals.** Rejected: the teal stays visible in every surface
and border, most of all in dark, and clashes with the accent.
- **Hue-rotating every accent token at its old lightness and chroma.** Rejected for the accent
tokens: teal's low chroma gives a dull grey-purple. It is used for the neutrals, where a faint
tint is the point.
- **Waiting for Branding settings.** Rejected by the maintainer: the default should be right before
anyone configures it.
## Consequences
- Every first-party surface turns purple from tokens alone: the Dashboard, the sign-in pages, and
the shared elements. The display host stays light (ADR 0060) and takes the light values.
- Modules that use the shared tokens, such as Signage, follow the new default with no change of
their own. A module that hard-coded the old teal would not, which is why no module should.
- A deployment that overrides tokens at its host root is unaffected.
- When Branding settings arrive, the accent becomes an administrator's choice. That feature must
derive the same set of variants and pass the same contrast tests. This decision is its default.
+2 -1
View File
@@ -68,9 +68,10 @@ 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 (device panel and phone menu placement superseded by 0064)
- [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
- [0060: Light, Dark, and System appearance; reduced motion; and one radius scale](0060-light-dark-system-theme-and-motion.md) — Accepted (teal accent and palette superseded by 0066)
- [0061: Settings is one host-owned hierarchy; module lifecycle is Discover, and a module's settings live at `/settings/<module-id>`](0061-settings-hierarchy-and-module-settings-routes.md) — Accepted (Discover's address and the landing page superseded by 0064)
- [0062: The account area, and appearance preferences that belong to the account](0062-account-area-and-synced-appearance-preferences.md) — Accepted
- [0063: Module navigation — manifest v9 icons and pages, real module routes, and return-to-origin](0063-module-navigation-contract-manifest-v9.md) — Accepted
- [0064: Discover is its own page, the sidebar is navigation only, Settings opens on an overview, and phones get a sticky header](0064-stage-1-shell-review-corrections.md) — Accepted
- [0065: Displays pair at the platform's own `/display/setup`; phone forms never zoom; the drawer locks the page](0065-display-setup-phone-forms-and-drawer-scroll-lock.md) — Accepted
- [0066: The default accent is a deep purple anchored on `#210050`](0066-default-purple-accent.md) — Accepted
+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", () => {
+19 -7
View File
@@ -755,24 +755,36 @@ await waitFor(`!${selector("add-display-dialog")}`, "the Add display dialog clos
);
}
// Revoked, it leaves the normal list at once, and its screen returns to a pairing code.
// Removed (its credential revoked), it leaves the normal list at once, and its screen returns
// to a pairing code. The interface says "remove" throughout; only the platform says "revoke".
if (cardId !== "") {
await evaluate(`${selector(cardId.replace("device-", "device-revoke-"))}.click()`);
await waitFor(selector("device-revoke-confirm"), "the revoke confirmation");
const removeId = cardId.replace("device-", "device-revoke-");
check(
(await evaluate(`${selector(removeId)}?.textContent`)) === "Remove",
"a paired display's card offers Remove",
);
await evaluate(`${selector(removeId)}.click()`);
await waitFor(selector("device-revoke-confirm"), "the remove confirmation");
check(
await evaluate(
`(() => { const d = document.querySelector('[role="dialog"]'); return d !== null && d.textContent.includes("Remove display?") && !/revok/i.test(d.textContent); })()`,
),
"the confirmation asks Remove display?, never Revoke",
);
await evaluate(`${selector("device-revoke-confirm")}.click()`);
check(
await waitFor(
`!${selector(cardId)} && ${selector("device-revoke-success")}`,
"the revoked display gone from the list",
`!${selector(cardId)} && ${selector("device-outcome")}?.textContent.includes("Display removed") && !/revok/i.test(document.querySelector("main")?.textContent ?? document.body.textContent)`,
"the removed display gone from the list",
),
"a revoked display leaves Paired displays at once, without a reload",
"a removed display leaves Paired displays at once, without a reload, and says Display removed",
);
check(
await kioskWaitFor(
`/^[0-9]{6}$/.test(${selector("display-pairing-code")}?.textContent ?? "")`,
75_000,
),
"the revoked screen returns to a pairing code on its own",
"the removed screen returns to a pairing code on its own",
);
}
}
@@ -419,7 +419,8 @@ function DisplaySetupPanel(props: {
/**
* One active display: its one name, whether it is online (coarsely — see {@link ONLINE_WITHIN_MS}),
* what it shows now, and the two things an administrator does with it — change what it shows, and
* revoke it. Revoked displays are not listed: they are history, not something to manage.
* remove it. Removing a display revokes its credential; revoked displays are not listed: they are
* history, not something to manage. The interface says "remove", the platform's domain "revoke".
*/
function DisplayCard(props: {
readonly device: HostDeviceSummary;
@@ -470,7 +471,7 @@ function DisplayCard(props: {
</Button>
)}
<Button data-testid={`device-revoke-${device.id}`} onClick={props.onRevoke} size="sm">
Revoke
Remove
</Button>
</div>
</li>
@@ -765,9 +766,9 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
</span>
</InlineNotice>
) : (
<InlineNotice title="Display revoked" tone="info">
<InlineNotice title="Display removed" tone="info">
<span data-testid="device-revoke-success">
{outcome.name} is revoked. Its screen returns to its pairing code.
{outcome.name} was removed. Its screen returns to its pairing code.
</span>
</InlineNotice>
)}
@@ -787,7 +788,7 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
{list.kind === "loading" ? <LoadingState label="Loading displays…" /> : undefined}
{list.kind === "denied" ? (
<EmptyState
description="If you need to pair or revoke displays, ask an administrator."
description="If you need to add or remove displays, ask an administrator."
title="Displays aren't available to you"
/>
) : undefined}
@@ -871,7 +872,7 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
/>
)}
{/* Revoking strands the screen until someone re-pairs it in person, so the consequence is
{/* Removing (revoking its credential) strands the screen until someone re-pairs it in person, so the consequence is
spelled out before it happens — never a browser-native confirm. */}
<Dialog
actions={
@@ -889,17 +890,18 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
onClick={() => (confirming ? void revoke(confirming) : undefined)}
variant="danger"
>
Revoke display
Remove display
</Button>
</>
}
onDismiss={dismissConfirmation}
open={confirming !== undefined}
title={`Revoke ${confirming?.name ?? "this display"}?`}
title="Remove display?"
>
<p style={{ margin: 0 }}>
The screen stops showing content within a few seconds and returns to its pairing code.
This cannot be undone: to use it again, pair it from the screen with a new code.
{confirming?.name ?? "This display"} stops showing content within a few seconds and its
screen returns to its pairing code. This cannot be undone: to use it again, pair it from
the screen with a new code.
</p>
</Dialog>
</div>
+17 -10
View File
@@ -259,7 +259,7 @@ describe("Settings → Displays", () => {
expect(required("device-device-1-showing").textContent).toBe("Showing: Nothing yet");
expect(card.textContent).toContain("Last seen within the last hour");
expect(query("device-change-device-1")?.textContent).toBe("Change");
expect(query("device-revoke-device-1")?.textContent).toBe("Revoke");
expect(query("device-revoke-device-1")?.textContent).toBe("Remove");
expect(container.querySelector("h1")?.textContent).toBe("Displays");
// Setting up a new screen comes first, in one place, at the platform's own setup address.
const setup = required("display-setup");
@@ -271,7 +271,7 @@ describe("Settings → Displays", () => {
expect(document.activeElement).toBe(before);
});
it("shows only active displays: revoked ones are history, not the normal list", async () => {
it("shows only active displays: removed ones are history, not the normal list", async () => {
await renderPage({
devices: [
{
@@ -300,7 +300,8 @@ describe("Settings → Displays", () => {
expect(query("device-a")).not.toBeNull();
expect(query("device-b")).toBeNull();
expect(query("device-c")).toBeNull();
expect(container.textContent).not.toContain("Revoked");
// Removed displays are never described as "revoked" in the normal interface.
expect(container.textContent).not.toMatch(/revok/iu);
expect(required("device-a").textContent).toContain("Not connected yet");
});
@@ -503,18 +504,22 @@ describe("changing what a display shows (ADR 0065)", () => {
});
/**
* Revoking a display strands that screen until someone re-pairs it in person, so the page explains
* the consequence in its own dialog first — never with a browser-native confirm (DESIGN §21).
* Removing a display (revoking its credential) strands that screen until someone re-pairs it in
* person, so the page explains the consequence in its own dialog first — never with a
* browser-native confirm (DESIGN §21). The interface says "remove"; only the platform says "revoke".
*/
describe("display revocation", () => {
it("asks in a product dialog before revoking, and does nothing on cancel", async () => {
describe("removing a display", () => {
it("asks in a product dialog before removing, and does nothing on cancel", async () => {
const confirm = vi.fn(() => true);
Object.assign(window, { confirm });
const platform = await renderPage();
await click("device-revoke-device-1");
const dialog = container.querySelector('[role="dialog"]');
expect(dialog?.textContent).toContain("Revoke Foyer TV?");
// Administrators remove a display; revoking its credential is the platform's word, not theirs.
expect(dialog?.textContent).toContain("Remove display?");
expect(dialog?.textContent).toContain("Foyer TV stops showing content");
expect(dialog?.textContent).not.toMatch(/revok/iu);
expect(dialog?.textContent).toContain("pairing code");
await click("device-revoke-cancel");
@@ -523,7 +528,7 @@ describe("display revocation", () => {
expect(confirm).not.toHaveBeenCalled();
});
it("removes a revoked display from the list at once, and says what happened", async () => {
it("takes a removed display off the list at once, and says what happened", async () => {
const platform = await renderPage();
await click("device-revoke-device-1");
@@ -536,7 +541,9 @@ describe("display revocation", () => {
expect(query("device-device-1")).toBeNull();
expect(platform.devices[0]?.revoked).toBe(true);
expect(required("device-list-empty")).not.toBeNull();
expect(required("device-revoke-success").textContent).toContain("Foyer TV is revoked");
expect(required("device-outcome").textContent).toContain("Display removed");
expect(required("device-revoke-success").textContent).toContain("Foyer TV was removed");
expect(container.textContent).not.toMatch(/revok/iu);
// Its card is gone, so focus lands on what happened rather than on the page.
expect(document.activeElement).toBe(required("device-outcome"));
});