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
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:
co-authored by
Claude Opus 5.5
parent
d9dafcb0f7
commit
0009f62fbd
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
- Status: Accepted
|
- Status: Accepted
|
||||||
- Date: 2026-10-15
|
- 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),
|
- Extends: [0039](0039-shared-design-tokens-and-elements.md),
|
||||||
[0040](0040-module-stylesheets-and-artifact-isolation.md),
|
[0040](0040-module-stylesheets-and-artifact-isolation.md),
|
||||||
[0058](0058-dashboard-shell-navigation-and-lucide-icons.md),
|
[0058](0058-dashboard-shell-navigation-and-lucide-icons.md),
|
||||||
|
|||||||
+10
-4
@@ -147,11 +147,17 @@ and a `role="listbox"` of options).
|
|||||||
|
|
||||||
### The normal list shows active displays, current as they change
|
### 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
|
The Paired displays list shows **active** displays only. A removed 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
|
page asks for the list again right after removing it — and the page says so, with focus on that
|
||||||
because the display's own card is gone. The revoked device row and its permission rows stay in the
|
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.
|
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
|
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
|
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
|
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;
|
purpose: last-seen is recorded at most every 15 minutes;
|
||||||
- **Showing:** a module, or *Nothing yet*;
|
- **Showing:** a module, or *Nothing yet*;
|
||||||
- last seen and paired dates;
|
- last seen and paired dates;
|
||||||
- **Change** and **Revoke**.
|
- **Change** and **Remove**.
|
||||||
|
|
||||||
### Changing what a display shows: one target, the active display permission
|
### 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.
|
||||||
@@ -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
|
- [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)
|
- [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
|
- [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)
|
- [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
|
- [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
|
- [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
|
- [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
|
- [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
|
||||||
|
|||||||
@@ -12,12 +12,16 @@ import {
|
|||||||
* every consumer's meaning changing with it: `background.raised` stays "the surface that sits above
|
* 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.
|
* 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
|
* The default values are Ekklesia's calm light theme, accented with a deep purple anchored on
|
||||||
* `dark` value: a designed dark theme of the same product — teal-tinted charcoal rather than black,
|
* `#210050` (ADR 0066), and each colour carries a `dark` value: a designed dark theme of the same
|
||||||
* text that is never pure white, an accent that stays the same teal rather than glowing — selected by
|
* product — violet-grey charcoal rather than black, text that is never pure white, the same purple
|
||||||
* `prefers-color-scheme` or by the host's explicit appearance (ADR 0060). Every text and focus
|
* lifted to a lavender with the anchor drawn on it rather than a glowing control — selected by
|
||||||
* pairing a component uses meets WCAG 2.2 AA in both, and `tests/contrast.test.ts` holds that.
|
* `prefers-color-scheme` or by the host's explicit appearance (ADR 0060). The anchor itself is the
|
||||||
* There is still no theme editor or brand builder; a deployment overrides values at its host root.
|
* 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
|
* ## 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.",
|
description: "The furthest-back background of a whole page or a full-viewport display.",
|
||||||
label: "Page background",
|
label: "Page background",
|
||||||
name: "background.page",
|
name: "background.page",
|
||||||
value: "#f3f7f6",
|
value: "#f6f5f9",
|
||||||
dark: "#0e1615",
|
dark: "#141319",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"A large working area inside a page — a scrolling list region, a preview area, an editor body.",
|
"A large working area inside a page — a scrolling list region, a preview area, an editor body.",
|
||||||
label: "Canvas background",
|
label: "Canvas background",
|
||||||
name: "background.canvas",
|
name: "background.canvas",
|
||||||
value: "#f8faf9",
|
value: "#f9f9fb",
|
||||||
dark: "#121b1a",
|
dark: "#19181f",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: "An ordinary panel, card, table, or dialog body sitting on the page.",
|
description: "An ordinary panel, card, table, or dialog body sitting on the page.",
|
||||||
label: "Surface background",
|
label: "Surface background",
|
||||||
name: "background.surface",
|
name: "background.surface",
|
||||||
value: "#ffffff",
|
value: "#ffffff",
|
||||||
dark: "#162120",
|
dark: "#1e1d26",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: "A surface deliberately lifted above an ordinary surface, such as a popover.",
|
description: "A surface deliberately lifted above an ordinary surface, such as a popover.",
|
||||||
label: "Raised surface background",
|
label: "Raised surface background",
|
||||||
name: "background.raised",
|
name: "background.raised",
|
||||||
value: "#ffffff",
|
value: "#ffffff",
|
||||||
dark: "#1c2826",
|
dark: "#25242d",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: "The scrim drawn behind a modal dialog to separate it from the page beneath.",
|
description: "The scrim drawn behind a modal dialog to separate it from the page beneath.",
|
||||||
label: "Overlay background",
|
label: "Overlay background",
|
||||||
name: "background.overlay",
|
name: "background.overlay",
|
||||||
value: "rgba(23, 23, 21, 0.55)",
|
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.",
|
description: "A recessed area such as a text input, a code block, or a well.",
|
||||||
label: "Inset background",
|
label: "Inset background",
|
||||||
name: "background.inset",
|
name: "background.inset",
|
||||||
value: "#edf3f1",
|
value: "#f1f1f6",
|
||||||
dark: "#0b1211",
|
dark: "#101015",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"The shape of content that is still loading — a skeleton placeholder. Visibly distinct from the page and from a surface in both schemes.",
|
"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",
|
label: "Placeholder background",
|
||||||
name: "background.placeholder",
|
name: "background.placeholder",
|
||||||
value: "#e3ebe9",
|
value: "#e9e8ef",
|
||||||
dark: "#243230",
|
dark: "#2f2d38",
|
||||||
},
|
},
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------------
|
||||||
@@ -89,29 +93,29 @@ const declarations = [
|
|||||||
description: "Body copy and headings — the default foreground for readable content.",
|
description: "Body copy and headings — the default foreground for readable content.",
|
||||||
label: "Primary text",
|
label: "Primary text",
|
||||||
name: "text.primary",
|
name: "text.primary",
|
||||||
value: "#182321",
|
value: "#201f28",
|
||||||
dark: "#e4edeb",
|
dark: "#eaeaf1",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: "Supporting text that is still content: captions, secondary columns, help text.",
|
description: "Supporting text that is still content: captions, secondary columns, help text.",
|
||||||
label: "Secondary text",
|
label: "Secondary text",
|
||||||
name: "text.secondary",
|
name: "text.secondary",
|
||||||
value: "#4f5f5b",
|
value: "#5b5966",
|
||||||
dark: "#b4c3bf",
|
dark: "#bfbdca",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: "De-emphasised text such as placeholders, timestamps, and disabled labels.",
|
description: "De-emphasised text such as placeholders, timestamps, and disabled labels.",
|
||||||
label: "Muted text",
|
label: "Muted text",
|
||||||
name: "text.muted",
|
name: "text.muted",
|
||||||
value: "#5d6d69",
|
value: "#696774",
|
||||||
dark: "#93a5a0",
|
dark: "#a09ead",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: "Text drawn on a strongly coloured background, such as a primary button.",
|
description: "Text drawn on a strongly coloured background, such as a primary button.",
|
||||||
label: "Inverse text",
|
label: "Inverse text",
|
||||||
name: "text.inverse",
|
name: "text.inverse",
|
||||||
value: "#ffffff",
|
value: "#ffffff",
|
||||||
dark: "#0e1615",
|
dark: "#141319",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: "Text reporting a successful outcome, paired with the success symbol.",
|
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.",
|
description: "The primary action colour: primary buttons, selected tabs, active links.",
|
||||||
label: "Accent",
|
label: "Accent",
|
||||||
name: "accent.base",
|
name: "accent.base",
|
||||||
value: "#4fb3a8",
|
value: "#210050",
|
||||||
dark: "#5bbcb1",
|
dark: "#ac9bf6",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: "The accent background under a hovered primary action.",
|
description: "The accent background under a hovered primary action.",
|
||||||
label: "Accent hover",
|
label: "Accent hover",
|
||||||
name: "accent.hover",
|
name: "accent.hover",
|
||||||
value: "#5ab9ae",
|
value: "#340e71",
|
||||||
dark: "#6cc5bb",
|
dark: "#bbacff",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: "The accent background while a primary action is pressed.",
|
description: "The accent background while a primary action is pressed.",
|
||||||
label: "Accent active",
|
label: "Accent active",
|
||||||
name: "accent.active",
|
name: "accent.active",
|
||||||
value: "#45a59b",
|
value: "#120033",
|
||||||
dark: "#4ea99f",
|
dark: "#9a88e2",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: "A deeper accent for pressed primary actions and high-emphasis emphasis.",
|
description: "A deeper accent for pressed primary actions and high-emphasis emphasis.",
|
||||||
label: "Strong accent",
|
label: "Strong accent",
|
||||||
name: "accent.strong",
|
name: "accent.strong",
|
||||||
value: "#246b64",
|
value: "#4d2a99",
|
||||||
dark: "#82d3c8",
|
dark: "#c8bcff",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: "A faint accent wash for selected rows and subtle accent backgrounds.",
|
description: "A faint accent wash for selected rows and subtle accent backgrounds.",
|
||||||
label: "Subtle accent",
|
label: "Subtle accent",
|
||||||
name: "accent.subtle",
|
name: "accent.subtle",
|
||||||
value: "#def2ef",
|
value: "#f0eeff",
|
||||||
dark: "#163330",
|
dark: "#2e2849",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: "Foreground drawn on top of the accent background.",
|
description: "Foreground drawn on top of the accent background.",
|
||||||
label: "Accent foreground",
|
label: "Accent foreground",
|
||||||
name: "accent.foreground",
|
name: "accent.foreground",
|
||||||
value: "#102a27",
|
value: "#ffffff",
|
||||||
dark: "#04201d",
|
dark: "#210050",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: "The focus ring colour. Never the only signal that something has focus.",
|
description: "The focus ring colour. Never the only signal that something has focus.",
|
||||||
label: "Focus ring",
|
label: "Focus ring",
|
||||||
name: "accent.focus",
|
name: "accent.focus",
|
||||||
value: "#287a72",
|
value: "#6a40c9",
|
||||||
dark: "#7fd4c9",
|
dark: "#bfb0ff",
|
||||||
},
|
},
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------------
|
||||||
@@ -195,30 +199,30 @@ const declarations = [
|
|||||||
description: "The default border around panels, inputs, and table cells.",
|
description: "The default border around panels, inputs, and table cells.",
|
||||||
label: "Border",
|
label: "Border",
|
||||||
name: "border.base",
|
name: "border.base",
|
||||||
value: "#d3dedb",
|
value: "#dbdae3",
|
||||||
dark: "#2b3836",
|
dark: "#35333d",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: "A heavier border for emphasis, active inputs, and selected outlines.",
|
description: "A heavier border for emphasis, active inputs, and selected outlines.",
|
||||||
label: "Strong border",
|
label: "Strong border",
|
||||||
name: "border.strong",
|
name: "border.strong",
|
||||||
value: "#637571",
|
value: "#706e7d",
|
||||||
dark: "#8a9c98",
|
dark: "#9795a4",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: "A hairline rule separating rows or sections inside one surface.",
|
description: "A hairline rule separating rows or sections inside one surface.",
|
||||||
label: "Divider",
|
label: "Divider",
|
||||||
name: "border.divider",
|
name: "border.divider",
|
||||||
value: "#e2eae8",
|
value: "#e8e7ee",
|
||||||
dark: "#222e2c",
|
dark: "#2b2a33",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description:
|
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).",
|
"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",
|
label: "Control border",
|
||||||
name: "border.control",
|
name: "border.control",
|
||||||
value: "#7a8d89",
|
value: "#888695",
|
||||||
dark: "#667a76",
|
dark: "#757383",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: "The border width used by controls and panels.",
|
description: "The border width used by controls and panels.",
|
||||||
@@ -294,15 +298,15 @@ const declarations = [
|
|||||||
description: "Background of a neutral informational badge or banner.",
|
description: "Background of a neutral informational badge or banner.",
|
||||||
label: "Info background",
|
label: "Info background",
|
||||||
name: "status.info-background",
|
name: "status.info-background",
|
||||||
value: "#def2ef",
|
value: "#f1f0ff",
|
||||||
dark: "#14322e",
|
dark: "#292440",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: "Border of a neutral informational badge or banner.",
|
description: "Border of a neutral informational badge or banner.",
|
||||||
label: "Info border",
|
label: "Info border",
|
||||||
name: "status.info-border",
|
name: "status.info-border",
|
||||||
value: "#9bd5ce",
|
value: "#c7c0f2",
|
||||||
dark: "#2f6c63",
|
dark: "#564b83",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: "The non-colour signal for information, rendered beside the label.",
|
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.",
|
description: "Background wash under a hovered control or row.",
|
||||||
label: "Hover background",
|
label: "Hover background",
|
||||||
name: "state.hover-background",
|
name: "state.hover-background",
|
||||||
value: "#edf3f1",
|
value: "#f1f1f6",
|
||||||
dark: "#1f2c2a",
|
dark: "#292831",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: "Background wash under a control being pressed.",
|
description: "Background wash under a control being pressed.",
|
||||||
label: "Pressed background",
|
label: "Pressed background",
|
||||||
name: "state.pressed-background",
|
name: "state.pressed-background",
|
||||||
value: "#e1ebe8",
|
value: "#e8e7f0",
|
||||||
dark: "#273634",
|
dark: "#32313c",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: "Background of a selected row, tab, or list item.",
|
description: "Background of a selected row, tab, or list item.",
|
||||||
label: "Selected background",
|
label: "Selected background",
|
||||||
name: "state.selected-background",
|
name: "state.selected-background",
|
||||||
value: "#def2ef",
|
value: "#eceaff",
|
||||||
dark: "#183b37",
|
dark: "#342b53",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: "Foreground of a selected row, tab, or list item.",
|
description: "Foreground of a selected row, tab, or list item.",
|
||||||
label: "Selected foreground",
|
label: "Selected foreground",
|
||||||
name: "state.selected-foreground",
|
name: "state.selected-foreground",
|
||||||
value: "#205f59",
|
value: "#48278e",
|
||||||
dark: "#a3e6dc",
|
dark: "#d7d0ff",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: "Opacity applied to a disabled control. Disabled state is never colour alone.",
|
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.",
|
description: "Subtle lift for a resting panel.",
|
||||||
label: "Small shadow",
|
label: "Small shadow",
|
||||||
name: "shadow.sm",
|
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)",
|
dark: "0 1px 3px rgba(0, 0, 0, 0.45)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: "Shadow for a popover or a raised surface.",
|
description: "Shadow for a popover or a raised surface.",
|
||||||
label: "Medium shadow",
|
label: "Medium shadow",
|
||||||
name: "shadow.md",
|
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)",
|
dark: "0 8px 24px rgba(0, 0, 0, 0.5)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: "Shadow for a modal dialog.",
|
description: "Shadow for a modal dialog.",
|
||||||
label: "Large shadow",
|
label: "Large shadow",
|
||||||
name: "shadow.lg",
|
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)",
|
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.canvas", NON_TEXT, "focus ring in the sidebar"],
|
||||||
["accent.focus", "background.surface", NON_TEXT, "focus ring in a panel"],
|
["accent.focus", "background.surface", NON_TEXT, "focus ring in a panel"],
|
||||||
["accent.focus", "background.raised", NON_TEXT, "focus ring in a dialog"],
|
["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([
|
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", () => {
|
describe("the contrast calculator", () => {
|
||||||
it("agrees with WCAG's reference values", () => {
|
it("agrees with WCAG's reference values", () => {
|
||||||
expect(contrastRatio("#000000", "#ffffff")).toBeCloseTo(21, 5);
|
expect(contrastRatio("#000000", "#ffffff")).toBeCloseTo(21, 5);
|
||||||
|
|||||||
@@ -99,8 +99,8 @@ describe("light and dark token sets", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("answers a token's value per scheme, falling back to its single value", () => {
|
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", "light")).toBe("#f6f5f9");
|
||||||
expect(ekklesiaDesignTokens.valueFor("background.page", "dark")).toBe("#0e1615");
|
expect(ekklesiaDesignTokens.valueFor("background.page", "dark")).toBe("#141319");
|
||||||
expect(ekklesiaDesignTokens.valueFor("spacing.md", "dark")).toBe("0.75rem");
|
expect(ekklesiaDesignTokens.valueFor("spacing.md", "dark")).toBe("0.75rem");
|
||||||
expect(() => ekklesiaDesignTokens.valueFor("nope" as "spacing.md", "dark")).toThrow(
|
expect(() => ekklesiaDesignTokens.valueFor("nope" as "spacing.md", "dark")).toThrow(
|
||||||
DesignTokenError,
|
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", () => {
|
it("is light by default and says so to the browser", () => {
|
||||||
expect(stylesheet.startsWith(":root {\n color-scheme: light;\n")).toBe(true);
|
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", () => {
|
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(
|
const darkRule = stylesheet.slice(
|
||||||
stylesheet.indexOf(`:root[${DESIGN_TOKEN_APPEARANCE_ATTRIBUTE}="dark"]`),
|
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", () => {
|
it("zeroes every motion duration when the system asks, unless motion was allowed, and when reduce was chosen", () => {
|
||||||
|
|||||||
@@ -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 !== "") {
|
if (cardId !== "") {
|
||||||
await evaluate(`${selector(cardId.replace("device-", "device-revoke-"))}.click()`);
|
const removeId = cardId.replace("device-", "device-revoke-");
|
||||||
await waitFor(selector("device-revoke-confirm"), "the revoke confirmation");
|
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()`);
|
await evaluate(`${selector("device-revoke-confirm")}.click()`);
|
||||||
check(
|
check(
|
||||||
await waitFor(
|
await waitFor(
|
||||||
`!${selector(cardId)} && ${selector("device-revoke-success")}`,
|
`!${selector(cardId)} && ${selector("device-outcome")}?.textContent.includes("Display removed") && !/revok/i.test(document.querySelector("main")?.textContent ?? document.body.textContent)`,
|
||||||
"the revoked display gone from the list",
|
"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(
|
check(
|
||||||
await kioskWaitFor(
|
await kioskWaitFor(
|
||||||
`/^[0-9]{6}$/.test(${selector("display-pairing-code")}?.textContent ?? "")`,
|
`/^[0-9]{6}$/.test(${selector("display-pairing-code")}?.textContent ?? "")`,
|
||||||
75_000,
|
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}),
|
* 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
|
* 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: {
|
function DisplayCard(props: {
|
||||||
readonly device: HostDeviceSummary;
|
readonly device: HostDeviceSummary;
|
||||||
@@ -470,7 +471,7 @@ function DisplayCard(props: {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button data-testid={`device-revoke-${device.id}`} onClick={props.onRevoke} size="sm">
|
<Button data-testid={`device-revoke-${device.id}`} onClick={props.onRevoke} size="sm">
|
||||||
Revoke
|
Remove
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -765,9 +766,9 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
|
|||||||
</span>
|
</span>
|
||||||
</InlineNotice>
|
</InlineNotice>
|
||||||
) : (
|
) : (
|
||||||
<InlineNotice title="Display revoked" tone="info">
|
<InlineNotice title="Display removed" tone="info">
|
||||||
<span data-testid="device-revoke-success">
|
<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>
|
</span>
|
||||||
</InlineNotice>
|
</InlineNotice>
|
||||||
)}
|
)}
|
||||||
@@ -787,7 +788,7 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
|
|||||||
{list.kind === "loading" ? <LoadingState label="Loading displays…" /> : undefined}
|
{list.kind === "loading" ? <LoadingState label="Loading displays…" /> : undefined}
|
||||||
{list.kind === "denied" ? (
|
{list.kind === "denied" ? (
|
||||||
<EmptyState
|
<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"
|
title="Displays aren't available to you"
|
||||||
/>
|
/>
|
||||||
) : undefined}
|
) : 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. */}
|
spelled out before it happens — never a browser-native confirm. */}
|
||||||
<Dialog
|
<Dialog
|
||||||
actions={
|
actions={
|
||||||
@@ -889,17 +890,18 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
|
|||||||
onClick={() => (confirming ? void revoke(confirming) : undefined)}
|
onClick={() => (confirming ? void revoke(confirming) : undefined)}
|
||||||
variant="danger"
|
variant="danger"
|
||||||
>
|
>
|
||||||
Revoke display
|
Remove display
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
onDismiss={dismissConfirmation}
|
onDismiss={dismissConfirmation}
|
||||||
open={confirming !== undefined}
|
open={confirming !== undefined}
|
||||||
title={`Revoke ${confirming?.name ?? "this display"}?`}
|
title="Remove display?"
|
||||||
>
|
>
|
||||||
<p style={{ margin: 0 }}>
|
<p style={{ margin: 0 }}>
|
||||||
The screen stops showing content within a few seconds and returns to its pairing code.
|
{confirming?.name ?? "This display"} stops showing content within a few seconds and its
|
||||||
This cannot be undone: to use it again, pair it from the screen with a new code.
|
screen returns to its pairing code. This cannot be undone: to use it again, pair it from
|
||||||
|
the screen with a new code.
|
||||||
</p>
|
</p>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ describe("Settings → Displays", () => {
|
|||||||
expect(required("device-device-1-showing").textContent).toBe("Showing: Nothing yet");
|
expect(required("device-device-1-showing").textContent).toBe("Showing: Nothing yet");
|
||||||
expect(card.textContent).toContain("Last seen within the last hour");
|
expect(card.textContent).toContain("Last seen within the last hour");
|
||||||
expect(query("device-change-device-1")?.textContent).toBe("Change");
|
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");
|
expect(container.querySelector("h1")?.textContent).toBe("Displays");
|
||||||
// Setting up a new screen comes first, in one place, at the platform's own setup address.
|
// Setting up a new screen comes first, in one place, at the platform's own setup address.
|
||||||
const setup = required("display-setup");
|
const setup = required("display-setup");
|
||||||
@@ -271,7 +271,7 @@ describe("Settings → Displays", () => {
|
|||||||
expect(document.activeElement).toBe(before);
|
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({
|
await renderPage({
|
||||||
devices: [
|
devices: [
|
||||||
{
|
{
|
||||||
@@ -300,7 +300,8 @@ describe("Settings → Displays", () => {
|
|||||||
expect(query("device-a")).not.toBeNull();
|
expect(query("device-a")).not.toBeNull();
|
||||||
expect(query("device-b")).toBeNull();
|
expect(query("device-b")).toBeNull();
|
||||||
expect(query("device-c")).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");
|
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
|
* Removing a display (revoking its credential) strands that screen until someone re-pairs it in
|
||||||
* the consequence in its own dialog first — never with a browser-native confirm (DESIGN §21).
|
* 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", () => {
|
describe("removing a display", () => {
|
||||||
it("asks in a product dialog before revoking, and does nothing on cancel", async () => {
|
it("asks in a product dialog before removing, and does nothing on cancel", async () => {
|
||||||
const confirm = vi.fn(() => true);
|
const confirm = vi.fn(() => true);
|
||||||
Object.assign(window, { confirm });
|
Object.assign(window, { confirm });
|
||||||
const platform = await renderPage();
|
const platform = await renderPage();
|
||||||
|
|
||||||
await click("device-revoke-device-1");
|
await click("device-revoke-device-1");
|
||||||
const dialog = container.querySelector('[role="dialog"]');
|
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");
|
expect(dialog?.textContent).toContain("pairing code");
|
||||||
|
|
||||||
await click("device-revoke-cancel");
|
await click("device-revoke-cancel");
|
||||||
@@ -523,7 +528,7 @@ describe("display revocation", () => {
|
|||||||
expect(confirm).not.toHaveBeenCalled();
|
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();
|
const platform = await renderPage();
|
||||||
|
|
||||||
await click("device-revoke-device-1");
|
await click("device-revoke-device-1");
|
||||||
@@ -536,7 +541,9 @@ describe("display revocation", () => {
|
|||||||
expect(query("device-device-1")).toBeNull();
|
expect(query("device-device-1")).toBeNull();
|
||||||
expect(platform.devices[0]?.revoked).toBe(true);
|
expect(platform.devices[0]?.revoked).toBe(true);
|
||||||
expect(required("device-list-empty")).not.toBeNull();
|
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.
|
// Its card is gone, so focus lands on what happened rather than on the page.
|
||||||
expect(document.activeElement).toBe(required("device-outcome"));
|
expect(document.activeElement).toBe(required("device-outcome"));
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user