diff --git a/docs/architecture/decisions/0060-light-dark-system-theme-and-motion.md b/docs/architecture/decisions/0060-light-dark-system-theme-and-motion.md index 06ce24f..23bb9f6 100644 --- a/docs/architecture/decisions/0060-light-dark-system-theme-and-motion.md +++ b/docs/architecture/decisions/0060-light-dark-system-theme-and-motion.md @@ -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), diff --git a/docs/architecture/decisions/0065-display-setup-phone-forms-and-drawer-scroll-lock.md b/docs/architecture/decisions/0065-display-setup-phone-forms-and-drawer-scroll-lock.md index bf26c70..8cdb9bc 100644 --- a/docs/architecture/decisions/0065-display-setup-phone-forms-and-drawer-scroll-lock.md +++ b/docs/architecture/decisions/0065-display-setup-phone-forms-and-drawer-scroll-lock.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 diff --git a/docs/architecture/decisions/0066-default-purple-accent.md b/docs/architecture/decisions/0066-default-purple-accent.md new file mode 100644 index 0000000..d441ff6 --- /dev/null +++ b/docs/architecture/decisions/0066-default-purple-accent.md @@ -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. diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index 87aeece..0fae7b1 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -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/`](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 diff --git a/packages/design-tokens/src/registry.ts b/packages/design-tokens/src/registry.ts index 87f5a24..faeb6e4 100644 --- a/packages/design-tokens/src/registry.ts +++ b/packages/design-tokens/src/registry.ts @@ -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)", }, diff --git a/packages/design-tokens/tests/contrast.test.ts b/packages/design-tokens/tests/contrast.test.ts index 9bbc192..445d955 100644 --- a/packages/design-tokens/tests/contrast.test.ts +++ b/packages/design-tokens/tests/contrast.test.ts @@ -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); diff --git a/packages/design-tokens/tests/theme.test.ts b/packages/design-tokens/tests/theme.test.ts index 4310de4..b6d8c5a 100644 --- a/packages/design-tokens/tests/theme.test.ts +++ b/packages/design-tokens/tests/theme.test.ts @@ -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", () => { diff --git a/scripts/lib/deploy-smoke-browser.mjs b/scripts/lib/deploy-smoke-browser.mjs index 38df4b8..94cd347 100644 --- a/scripts/lib/deploy-smoke-browser.mjs +++ b/scripts/lib/deploy-smoke-browser.mjs @@ -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", ); } } diff --git a/surfaces/shell/src/browser/DisplaysSettingsPage.tsx b/surfaces/shell/src/browser/DisplaysSettingsPage.tsx index 0c60f0a..abcbf3a 100644 --- a/surfaces/shell/src/browser/DisplaysSettingsPage.tsx +++ b/surfaces/shell/src/browser/DisplaysSettingsPage.tsx @@ -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: { )} @@ -765,9 +766,9 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle ) : ( - + - {outcome.name} is revoked. Its screen returns to its pairing code. + {outcome.name} was removed. Its screen returns to its pairing code. )} @@ -787,7 +788,7 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle {list.kind === "loading" ? : undefined} {list.kind === "denied" ? ( ) : 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. */} (confirming ? void revoke(confirming) : undefined)} variant="danger" > - Revoke display + Remove display } onDismiss={dismissConfirmation} open={confirming !== undefined} - title={`Revoke ${confirming?.name ?? "this display"}?`} + title="Remove display?" >

- 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.

diff --git a/surfaces/shell/tests/displays-settings.test.tsx b/surfaces/shell/tests/displays-settings.test.tsx index f20f7f1..51c0eb6 100644 --- a/surfaces/shell/tests/displays-settings.test.tsx +++ b/surfaces/shell/tests/displays-settings.test.tsx @@ -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")); });