diff --git a/CLAUDE.md b/CLAUDE.md index 0cb48c7..a4e2c27 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,11 +54,11 @@ Rules from those documents that every change here must respect: (`@ekklesia/design-tokens`, ADR 0039) — never a hard-coded colour. - **No browser-native dialogs.** `window.alert`, `window.confirm`, and `window.prompt` are prohibited in product UI. Destructive actions use in-product confirmation proportional to their impact. -- **Displays are transitional.** Displays are platform-owned. Today's device pairing (the Dashboard - sidebar's *Display devices* panel) and Signage's own display roster are a transitional - implementation that Stage 1 migrates to **Settings → Displays**. Fix defects in them, but do not - build the Stage 1 Settings, global Displays, theme editor, module installer, or responsive shell - redesign early, and keep existing paired displays working across any change. +- **Displays are platform-owned.** Display pairing lives in **Settings → Displays** (ADR 0064); the + sidebar is navigation only. Signage's own display roster is still a transitional implementation + that Stage 1 replaces with the platform's displays. Keep existing paired displays working across + any change, and do not build a module installer, theme editor, or display features beyond what a + task asks for early. ## ADRs are authoritative diff --git a/README.md b/README.md index a2ccfad..62f90d6 100644 --- a/README.md +++ b/README.md @@ -925,9 +925,12 @@ The Dashboard's **shell** follows DESIGN.md §4 ([ADR 0058](docs/architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md)): a collapsible left sidebar — icons and labels expanded, icons with accessible tooltips collapsed, the choice remembered in this browser — with the Overview first, the server's groups and modules below it, -and an account menu anchored at the bottom (Sign out, for now). There is no top bar. On a phone the -same navigation opens in a modal drawer with focus trapped inside it, Escape and the backdrop closing -it, and focus returned to the menu button. Lucide is Ekklesia's icon vocabulary; until modules can +and an account menu anchored at the bottom (Sign out, for now). There is no top bar on a wide screen. +On a phone a restrained sticky header keeps the menu button and the page's name in reach below the +status bar, and the same navigation opens in a modal drawer with focus trapped inside it, Escape and +the backdrop closing it, and focus returned to the menu button +([ADR 0064](docs/architecture/decisions/0064-stage-1-shell-review-corrections.md)). The sidebar is +navigation only. Lucide is Ekklesia's icon vocabulary; until modules can declare their own icon (manifest v9), every module shows one generic icon. Church branding in the sidebar's identity slot is still to come. @@ -947,11 +950,15 @@ schemes, and corners scale from one `radius.scale` token. Displays stay light. **Settings** has its own place in the sidebar ([ADR 0061](docs/architecture/decisions/0061-settings-hierarchy-and-module-settings-routes.md)): a -landing page at `/settings`; the platform's General, Branding, Displays, Discover, and System; and each -module's settings at `/settings/`. Discover is where modules are found, installed, managed, -and updated (`/settings/discover/browse`, `/installed`, `/updates`); configuring a module is its own -Settings page. A person sees only what the server says they may administer, and no Settings link at all -when that is nothing. The pages are placeholders until each section is built. +landing page at `/settings` that summarises the installation — paired displays, installed modules — and +links to where each is changed; the platform's General, Branding, Displays, Discover, and System; and +each module's settings at `/settings/`. Discover is where modules are found and installed +(`/settings/discover`), with Installed and Updates beneath it (`/settings/discover/installed`, +`/updates`); configuring a module is its own Settings page. **Settings → Displays** pairs and revokes +display screens: the paired displays with when each was last seen, and an *Add display* flow for the +code a screen shows ([ADR 0064](docs/architecture/decisions/0064-stage-1-shell-review-corrections.md)). +A person sees only what the server says they may administer, and no Settings link at all when that is +nothing. General, Branding, System, Installed, and Updates are placeholders until each is built. **Modules are first-class parts of the Dashboard** ([ADR 0063](docs/architecture/decisions/0063-module-navigation-contract-manifest-v9.md)). A module diff --git a/docs/architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md b/docs/architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md index 3a1da63..a2fccb6 100644 --- a/docs/architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md +++ b/docs/architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md @@ -3,6 +3,7 @@ - Status: Accepted - Date: 2026-10-14 - Module-declared icons and pages decided by: [0063](0063-module-navigation-contract-manifest-v9.md) +- Device panel placement and the phone menu placement superseded by: [0064](0064-stage-1-shell-review-corrections.md) - Extends: [0026](0026-minimal-ekklesia-module-ui-host.md), [0027](0027-dashboard-navigation-and-flat-modules.md), [0039](0039-shared-design-tokens-and-elements.md), diff --git a/docs/architecture/decisions/0061-settings-hierarchy-and-module-settings-routes.md b/docs/architecture/decisions/0061-settings-hierarchy-and-module-settings-routes.md index 8a9af65..76eabf9 100644 --- a/docs/architecture/decisions/0061-settings-hierarchy-and-module-settings-routes.md +++ b/docs/architecture/decisions/0061-settings-hierarchy-and-module-settings-routes.md @@ -3,6 +3,7 @@ - Status: Accepted - Date: 2026-10-15 - Module → Settings return link decided by: [0063](0063-module-navigation-contract-manifest-v9.md) +- Discover's address, the landing page, and the device panel's move decided by: [0064](0064-stage-1-shell-review-corrections.md) - Extends: [0027](0027-dashboard-navigation-and-flat-modules.md), [0028](0028-module-operational-lifecycle.md), [0029](0029-module-configuration-and-secrets.md), diff --git a/docs/architecture/decisions/0064-stage-1-shell-review-corrections.md b/docs/architecture/decisions/0064-stage-1-shell-review-corrections.md new file mode 100644 index 0000000..e431bac --- /dev/null +++ b/docs/architecture/decisions/0064-stage-1-shell-review-corrections.md @@ -0,0 +1,241 @@ +# 0064: Discover is its own page, the sidebar is navigation only, Settings opens on an overview, and phones get a sticky header + +- Status: Accepted +- Date: 2026-10-15 +- Supersedes, in part: [0058](0058-dashboard-shell-navigation-and-lucide-icons.md) (the device panel + in the sidebar and drawer; "no application top bar" on phones), + [0061](0061-settings-hierarchy-and-module-settings-routes.md) (Discover entered at + `/settings/discover/browse`; the landing page as an index) +- Extends: [0036](0036-managed-device-identity-and-pairing.md), + [0039](0039-shared-design-tokens-and-elements.md), + [0057](0057-dashboard-urls-are-host-owned-routes.md), + [0060](0060-light-dark-system-theme-and-motion.md), + [0063](0063-module-navigation-contract-manifest-v9.md) + +## Context + +The assembled Stage 1 Dashboard was reviewed by hand on a real desktop, with the sidebar expanded and +collapsed, and on a real iPhone in Safari. The review found defects that no unit test had caught, +because each only shows in the assembled product: + +- **Discover and Browse were the same thing twice.** ADR 0061 made Discover a navigation item that + opened Browse, at `/settings/discover/browse`, with Browse, Installed, and Updates listed beneath it. + In use, "Discover" and "Browse" named one page, and the children looked as if they had simply + appeared, with no relationship to Discover. +- **The sidebar still held the Stage 0 device panel.** It showed a *Display devices* heading, a pairing + code field, and the list of paired screens beneath the navigation. A form in the navigation is not + navigation. The field also held focus as the page loaded on the review device, although nobody had + asked to pair anything. +- **Collapsed icons sat off centre.** `DashboardLink` set `justify-content: flex-start` inline. An + inline style outranks the stylesheet, so the collapsed rule's `justify-content: center` never + applied, and every glyph sat at the left of its 47px box, with 1px on its left and 26px on its + right. The toggle above the items had its own, different geometry. +- **The Settings landing page repeated the sidebar.** ADR 0061 called it "an index, not a dashboard": + each section was named and described in a line. It listed exactly what the sidebar beside it already + listed. +- **On the phone the menu scrolled away.** ADR 0058 put the menu button above the page as "the one + control there, not a bar". It scrolled out of reach with the page. It also did not respect the + status bar or notch. A page opened from the drawer could start part-way down, because focusing + `
` scrolled it to the very top of the viewport. +- **The light theme's selected radio looked wrong.** Radios were native, tinted with + `accent-color: accent.base`. Given an accent, each browser picks its own contrasting mark, and on + the light theme's pale teal (`#4fb3a8`) that mark was a near-black dot and ring. The shared + `Checkbox` in `@ekklesia/ui` used the same technique. + +ROADMAP §5.3 and DESIGN.md §9.4 still name `/settings/discover/browse` as canonical. This decision +reverses that at the maintainer's direction after the review, and the product documents need the +same correction. + +## Decision + +### Discover is its own page, at `/settings/discover` + +`/settings/discover` is where modules are found and installed. It is Discover's own page, titled +*Discover modules*, not the parent of a separate Browse page. Its children, beneath it, are: + +```text +/settings/discover Discover: find and install modules +/settings/discover/installed Installed: see and manage installed modules +/settings/discover/updates Updates: module and platform updates +``` + +- **No Browse item.** The navigation names Installed and Updates beneath Discover, and nothing else. +- **The old address is an alias, never a link.** `/settings/discover/browse` still opens Discover's + page, so a bookmark from before this decision keeps working. The shell then respells it in place to + `/settings/discover` (`replace`, keeping any `?return=`), exactly as it corrects a trailing slash + (ADR 0057). Nothing links to it. +- **Internal name.** The route keeps `view: "browse"` as the internal name of Discover's own page. + Only its address changes. The API already serves the Dashboard throughout `/settings/*`, so no server + or proxy change is needed. + +### Parents and children read as a hierarchy + +The same treatment applies to every parent in the navigation: Discover with Installed and Updates, +Settings with its sections, and a module with its declared pages (ADR 0063). + +- **Children are text only**, marked `data-nav-level="child"`. On desktop they use the small control + height and regular weight. In the drawer they keep the large touch target. +- **They hang from a guide line** that runs down from beneath the parent's icon. Their text starts past + the parent's label. +- **Exactly one item is current** (`aria-current="page"`, selected fill, and weight). The parent of the + open child is marked `data-nav-ancestor`: primary text, bold, and the strong accent on its icon, but + no fill. A parent and its child are therefore never both "selected", and the relationship reads at a + glance. +- **An item that opens in place carries a disclosure chevron.** It points right while closed and down + while open, and it rotates over `motion.duration-short`, which is zero when motion is reduced + (ADR 0060). It is decorative: the nested list, named by its parent, carries the structure for + assistive technology. +- **Collapsed, the rail shows parents only.** Discover is current for every Discover page, and its + children, which need the labels the rail has no room for, are not drawn. A module's pages were + already hidden in the rail. + +### The sidebar is navigation only; displays are paired in Settings → Displays + +The device panel leaves the sidebar and the drawer entirely. Its working functionality moves, rather +than being rebuilt, to **Settings → Displays** (`/settings/displays`). This is the move ADR 0061 +deferred, and the first platform Settings page that does real work. The page has: + +- a page header with **Add display**; +- **Paired displays:** one card per screen, with its name, a *Paired* or *Revoked* badge, when it was + paired, when it was last seen, and **Revoke**; +- an **Add display** dialog: the code shown on the screen, then **Find display**; then the display's + one name, then **Pair display**; +- the existing in-product confirmation before revoking; +- empty, loading, failed (with Retry), and "not available to you" states. + +It uses the same platform-owned display identity, the same endpoints (`GET /api/v1/devices`, +`POST /api/v1/device-pairing/lookups` and `/approvals`, `POST /api/v1/devices//revoke`), and the +same permission (`core.devices.manage`) as before (ADR 0036). No display concept is added. + +The device list already returned `createdAt` and `lastSeenAt`, and the client now reads them. +`lastSeenAt` is written at most every few minutes (`DEVICE_LAST_SEEN_THROTTLE_MS`), so the page says +when a screen was last in touch, coarsely ("within the last hour", "3 days ago"). It never says a +screen is online. + +**Nothing takes focus by itself.** No page load or navigation focuses a form field. Focus moves into +the code field only once someone has chosen **Add display**, because typing the code is then plainly +the next thing to do. It moves to the name field once a display is found. The dialog's focus trap +returns focus to **Add display** when the dialog closes. The button focuses itself before opening the +dialog, because Safari does not focus a button that is clicked. + +### `/settings` opens on an overview of the installation + +`/settings` stays a real landing page, never a redirect (ADR 0061). It now answers "what state is this +installation in?" instead of repeating the sidebar: + +- **Displays:** how many are paired, and which was most recently seen. The link is *Manage displays*, + or *Add a display* when none are paired. +- **Modules:** how many are installed, and whether all are running or how many are turned off or + cannot start. The links are *Manage modules* and each running module's * settings*. +- **General, Branding, and System:** one restrained line each about what is true today, such as + *Using the default name and colours*, with the one link that changes it. There are no large + *Not available yet* boxes. + +The overview reads only existing data, and only for what this person may see: + +- the Displays summary uses `GET /api/v1/devices`, read only when Displays is visible; +- the Modules summary uses `GET /api/v1/module-lifecycle`, read only when Discover is visible. That + endpoint requires `core.modules.lifecycle.manage`, the same permission Discover does. + +A refusal or failure shows as a quiet *Couldn't check …* line. No backend API was added. + +### Phones get a restrained sticky header + +Below the compact breakpoint, a `
` holds the menu trigger and the open page's name. It is +`position: sticky` at the top while the page scrolls, and the drawer opens from that stable trigger. +It is not a desktop top bar and holds nothing else. The page name is a visual echo of the page's own +`

`, so it is `aria-hidden`. Wide screens still have no top bar. + +- **Safe areas.** The Dashboard page declares `viewport-fit=cover`, so the shell lays itself out with + `env(safe-area-inset-*)`: + - the header clears the status bar or notch; + - the page, the drawer, and a sidebar shown on a wide landscape phone clear the notch and the home + indicator. +- **Viewport height.** The shell's minimum height is `100dvh`, falling back to `100vh`, so Safari's + collapsing toolbar never leaves the page a toolbar's height too tall. +- **Scrolling and focus.** + - A new page starts at its top, as a full page load would. Focus moves to `
` with + `preventScroll`, so the page's top is never scrolled under the header. + - Focus that returns to the trigger after the drawer closes does not scroll either. + - `scroll-padding-top` keeps anything else scrolled into view, such as a focused field, below the + header. +- **Page margin.** A phone's margin is `spacing.lg` instead of `spacing.xl`. + +### Collapsed geometry belongs to the stylesheet + +A navigation link's layout and its open-page look are classes in the shell stylesheet: `ek-shell-link`, +plus `[aria-current="page"]`. They are never inline styles. Collapsed, every navigation item has the +same geometry, whatever its level: + +- a `control.height-md` square; +- `display: flex`, centred on the rail with auto margins; +- no padding and no gap; +- its glyph centred. + +The rail scrolls without a visible scrollbar, which would otherwise take its width from one side. The +tooltip, the keyboard order, and each item's visually hidden name are unchanged. No icon is nudged by +a one-off offset. + +### Choice indicators are drawn from tokens, in `@ekklesia/ui` + +`@ekklesia/ui`'s stylesheet draws the checkbox (`ek-checkbox__input`) and a radio (`ek-radio__input`) +itself, as native inputs with `appearance: none`. They are never tinted with `accent-color`. + +- **Unselected:** a `border.control` ring on `background.surface`. +- **Selected:** `accent.strong` in both themes. The radio is a ring, a gap of the surface, and a dot. + The checkbox is a filled box with a surface-coloured check. +- **Other states:** hover strengthens the ring to `border.strong`, disabled uses + `state.disabled-opacity`, and forced colours use the system's own selection colours. +- **Size:** a larger control sets `--ek-choice-size`. + +The Appearance page's radios use this shared indicator. Its rows keep their own focus ring, so the +radio inside draws none of its own. + +No `Radio` component is added: ADR 0039 adds an element to `@ekklesia/ui` once two independent +consumers need it, and the shell is the only one. The change is to the existing stylesheet and the +existing `Checkbox`, whose selected state now matches. That is additive for modules: a module using +`Checkbox` gets the same, now consistent, selected state. + +## Alternatives + +- **Keep Browse and make it look more like a child.** Rejected: the review's finding was that + Discover and Browse are one concept. Two names for one page is the defect, not its styling. +- **Remove `/settings/discover/browse` outright.** Rejected: it shipped, and a bookmark should not + become *Page not found*. An alias respelled in place costs nothing and is never linked. +- **Pair displays from a route of its own (`/settings/displays/add`).** Deferred, not rejected. A dialog + keeps the routing contract unchanged and gets focus handling from the shared `Dialog`. A deep link + to *Add display*, for example from Signage, can add a route later without changing this page. +- **Keep the device panel, collapsed by default.** Rejected: the sidebar is navigation, and a form in + it is the defect. +- **Redirect `/settings` to General, or keep the index.** Rejected: the redirect, as in ADR 0061. The + index repeated the sidebar. +- **New backend endpoints for richer overview cards** (version, health, update counts). Rejected for + now: the overview uses what exists, and System, Updates, and Branding gain real summaries with the + work that builds them. +- **A full top bar on every screen size.** Rejected: DESIGN.md §4.2 keeps navigation in the sidebar. + Phones need only the menu kept within reach. +- **Fix the radio by changing `accent.base`, or with page-specific colours.** Rejected: a browser's + mark on an accent-tinted control is its own choice and differs between engines. Drawing the + indicator from tokens is the only way to get one result in every browser and both themes. +- **Offset individual icons to centre them.** Rejected: the cause was an inline style outranking the + collapsed rule, and one geometry for every item removes it. + +## Consequences + +- Discover's canonical address is `/settings/discover`. Nothing links to `/browse`, and it respells + itself away. +- The shell no longer asks for the device list on every page. Displays are read on Settings → + Displays and the Settings overview, for people allowed to see them. +- Released Signage 0.1.5 tells administrators to enter a screen's code "under Display devices in the + Dashboard sidebar". That text is now stale and must change in Signage. The pairing itself, and every + paired display, is unaffected. +- `@ekklesia/ui`'s checkbox looks different when checked: the strong accent instead of the browser's + accent-tinted control. Its behaviour and markup are unchanged. +- ROADMAP §5.3 and DESIGN.md §9.4, §12.1, and decision 98 still describe Browse at + `/settings/discover/browse` and need the same correction. + +## Deferred + +The real General, Branding, System, Installed, and Updates pages, and richer overview summaries with +them. Display rooms or locations, rename, and history (DESIGN.md §11). A routable *Add display* for +modules to link to. Signage consuming platform displays (ROADMAP §5.8). diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index d76ab87..0f16825 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -66,9 +66,10 @@ ADRs capture durable choices that affect more than one package or phase. - [0055: Native redistributable packages carry corresponding-source artifacts](0055-corresponding-source-artifacts.md) — Accepted - [0056: A device credential lasts while the device is in use](0056-device-credentials-last-while-in-use.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 +- [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 -- [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 +- [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 diff --git a/docs/architecture/platform-architecture.md b/docs/architecture/platform-architecture.md index 2c07f8a..dc2d013 100644 --- a/docs/architecture/platform-architecture.md +++ b/docs/architecture/platform-architecture.md @@ -602,9 +602,11 @@ durations when motion is reduced — in CSS alone, so the Dashboard and every mo operating system live; the host chooses an explicit appearance through a small controller, applied before React renders. Contrast is tested for every pairing the elements use, in both schemes ([ADR 0060](decisions/0060-light-dark-system-theme-and-motion.md)). Settings is one host-owned -hierarchy in the same sidebar: a landing page, the platform's General, Branding, Displays, Discover -(module lifecycle: Browse, Installed, Updates), and System, and each module's settings at -`/settings/`. Which of them a person sees is evaluated by the server +hierarchy in the same sidebar: a landing page that summarises the installation, the platform's +General, Branding, Displays (where display screens are paired and revoked), Discover (module +lifecycle: Discover's own page at `/settings/discover`, with Installed and Updates beneath it), and +System, and each module's settings at `/settings/`; the sidebar itself is navigation only +([ADR 0064](decisions/0064-stage-1-shell-review-corrections.md)). Which of them a person sees is evaluated by the server (`GET /api/v1/dashboard/settings`) as presentation hints, while every route behind them keeps its own enforcement; the Settings section names are reserved module ids ([ADR 0061](decisions/0061-settings-hierarchy-and-module-settings-routes.md)). Personal configuration diff --git a/docs/development/testing.md b/docs/development/testing.md index 85c3cb9..0c3f76e 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -462,6 +462,28 @@ proven at each layer: - The deploy smoke's real Chromium checks Light and Dark rendering, reload persistence, an emulated OS change, reduced motion, a module's Shadow DOM following the theme, and phone width. +The Stage 1 review corrections +([ADR 0064](../architecture/decisions/0064-stage-1-shell-review-corrections.md)) are proven here: + +- `surfaces/shell/tests/dashboard-routes.test.ts` reads Discover's own page at `/settings/discover`, + Installed and Updates beneath it, and `/settings/discover/browse` only as an alias that respells to + `/settings/discover`. +- `surfaces/shell/tests/settings-navigation.test.tsx` covers: + - the Settings overview's summaries, read only for what the person may see; + - Discover with no Browse child, its parent and child marking, and the collapsed rail showing + Discover alone; + - the alias respelled in place, keeping `?return=`; + - no display-pairing block in the sidebar or drawer, and no field focused on arrival; + - the collapsed geometry left to the stylesheet; + - the sticky phone header and the drawer opening from it. +- `surfaces/shell/tests/displays-settings.test.tsx` covers Settings → Displays: the list, empty and + refused states, the Add display flow focusing each next step, Cancel, and revocation behind a product + dialog. +- `packages/ui/tests/tokens.test.ts` holds the checkbox and radio indicators to tokens: never + `accent-color`, and the strong accent for the selected state. +- None of these defects showed until the product was assembled, so a change to the shell's layout + also gets a real-browser look at wide, collapsed, and phone widths in Light and Dark. + Unit suites (`core/tests/module-storage.test.ts`, `core/tests/module-storage-lifecycle.test.ts`) use an injected in-memory provider and never touch the network. They prove the parts the protocol cannot: binding determinism and collision-safety, scoping and cross-module isolation, key validation, lazy diff --git a/packages/ui/src/styles.ts b/packages/ui/src/styles.ts index 00fcc1d..ddbac25 100644 --- a/packages/ui/src/styles.ts +++ b/packages/ui/src/styles.ts @@ -198,17 +198,89 @@ export const EKKLESIA_UI_STYLESHEET = ` gap: ${designToken("spacing.sm")}; } -.ek-checkbox__input { - accent-color: ${designToken("accent.base")}; - height: ${designToken("spacing.lg")}; - width: ${designToken("spacing.lg")}; +/* + * Choice indicators — a checkbox, and a radio (ek-radio__input, on a native radio input) — drawn + * from tokens rather than the browser's own control tinted with accent-color. Given an accent, each + * browser picks its own contrasting mark: on the light theme's pale teal that was a near-black dot + * and ring, which matched neither theme (ADR 0064). Drawn here, a selected indicator is the strong + * accent in both themes and every browser, and it is still a native input: its keyboard behaviour, + * form association, and announcement stay the browser's. A larger control sets --ek-choice-size. + */ +.ek-checkbox__input, +.ek-radio__input { + --ek-choice-size: ${designToken("spacing.lg")}; + appearance: none; + background: ${designToken("background.surface")}; + border: calc(2 * ${designToken("border.width")}) solid ${designToken("border.control")}; + box-sizing: border-box; + cursor: pointer; + display: inline-grid; + flex: 0 0 auto; + height: var(--ek-choice-size); + margin: 0; + place-content: center; + width: var(--ek-choice-size); } -.ek-checkbox__input:disabled { +.ek-checkbox__input { + border-radius: ${designToken("radius.sm")}; +} + +.ek-radio__input { + border-radius: ${designToken("radius.pill")}; +} + +.ek-checkbox__input:hover:not(:disabled):not(:checked), +.ek-radio__input:hover:not(:disabled):not(:checked) { + border-color: ${designToken("border.strong")}; +} + +/* A ring, a gap of the surface, and a dot, all in proportion to the control. */ +.ek-radio__input:checked { + background: ${designToken("accent.strong")}; + border-color: ${designToken("accent.strong")}; + box-shadow: inset 0 0 0 calc(var(--ek-choice-size) * 0.18) ${designToken("background.surface")}; +} + +.ek-checkbox__input:checked { + background: ${designToken("accent.strong")}; + border-color: ${designToken("accent.strong")}; +} + +.ek-checkbox__input:checked::before { + aspect-ratio: 1; + background: ${designToken("background.surface")}; + clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%); + content: ""; + display: block; + width: calc(var(--ek-choice-size) * 0.6); +} + +.ek-checkbox__input:disabled, +.ek-radio__input:disabled { cursor: not-allowed; opacity: ${designToken("state.disabled-opacity")}; } +/* Forced colours drop backgrounds and shadows, so a checked indicator uses the system's own + selection colours instead. */ +@media (forced-colors: active) { + .ek-checkbox__input:checked, + .ek-radio__input:checked { + background: Highlight; + border-color: Highlight; + forced-color-adjust: none; + } + + .ek-radio__input:checked { + box-shadow: inset 0 0 0 calc(var(--ek-choice-size) * 0.18) Canvas; + } + + .ek-checkbox__input:checked::before { + background: Canvas; + } +} + .ek-panel { background: ${designToken("background.surface")}; border: ${designToken("border.width")} solid ${designToken("border.base")}; diff --git a/packages/ui/tests/tokens.test.ts b/packages/ui/tests/tokens.test.ts index 941cacd..dcdf1a4 100644 --- a/packages/ui/tests/tokens.test.ts +++ b/packages/ui/tests/tokens.test.ts @@ -119,3 +119,43 @@ describe("the element stylesheet takes every colour, radius, and duration from a } }); }); + +/** The body of the rule whose whole selector list is exactly `selector`. */ +function ruleBody(selector: string): string { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + const match = new RegExp(`(? { + it("never leaves a checkbox or radio to the browser's accent-tinted rendering", () => { + // `accent-color` lets each browser pick its own mark for the selected state — a near-black dot + // on the light theme's pale teal — so the selected state matched neither theme. + expect(EKKLESIA_UI_STYLESHEET).not.toMatch(/accent-color\s*:/u); + const base = ruleBody(".ek-checkbox__input,\n.ek-radio__input"); + expect(base).toContain("appearance: none"); + expect(base).toContain("var(--ekklesia-border-control)"); + expect(base).toContain("var(--ekklesia-background-surface)"); + }); + + it("marks the selected state with the strong accent, the same in light and dark", () => { + const radio = ruleBody(".ek-radio__input:checked"); + expect(radio).toContain("background: var(--ekklesia-accent-strong)"); + expect(radio).toContain("border-color: var(--ekklesia-accent-strong)"); + expect(radio).toContain("var(--ekklesia-background-surface)"); + const checkbox = ruleBody(".ek-checkbox__input:checked"); + expect(checkbox).toContain("background: var(--ekklesia-accent-strong)"); + expect(ruleBody(".ek-checkbox__input:checked::before")).toContain( + "background: var(--ekklesia-background-surface)", + ); + // A radio is round by the pill radius token, never a literal. + expect(ruleBody(".ek-radio__input")).toContain("var(--ekklesia-radius-pill)"); + }); + + it("keeps disabled and forced-colour states", () => { + expect(ruleBody(".ek-checkbox__input:disabled,\n.ek-radio__input:disabled")).toContain( + "var(--ekklesia-state-disabled-opacity)", + ); + expect(EKKLESIA_UI_STYLESHEET).toMatch(/@media \(forced-colors: active\)/u); + }); +}); diff --git a/scripts/lib/deploy-smoke-browser.mjs b/scripts/lib/deploy-smoke-browser.mjs index 3d74375..7c3fd2f 100644 --- a/scripts/lib/deploy-smoke-browser.mjs +++ b/scripts/lib/deploy-smoke-browser.mjs @@ -574,7 +574,8 @@ if (openedModuleId) { } } -// --- Settings (ADR 0061): a deep link, a refresh, Discover, a module's settings, the drawer ------- +// --- Settings (ADR 0061, ADR 0064): a deep link, a refresh, Displays, Discover, a module's settings, +// the drawer ---------------------------------------------------------------------------------------- await navigate(`${dashboardOrigin}/settings`); check( await waitFor( @@ -593,13 +594,37 @@ check( ), "refreshing a Settings page stays on it, marked in the navigation", ); +check( + await evaluate( + `!${selector("host-sidebar")}.querySelector("input, form") && document.activeElement?.tagName !== "INPUT"`, + ), + "the sidebar is navigation only, and arriving at Displays focuses no field", +); +await evaluate(`${selector("add-display")}.click()`); +check( + await waitFor( + `${selector("add-display-dialog")} && document.activeElement === ${selector("device-pairing-code-input")}`, + "the Add display dialog with its code field focused", + ), + "Add display opens a dialog that focuses the pairing code only once asked", +); +await evaluate(`${selector("add-display-cancel")}.click()`); +await waitFor(`!${selector("add-display-dialog")}`, "the Add display dialog closing"); await navigate(`${dashboardOrigin}/settings/discover`); check( await waitFor( - `location.pathname === "/settings/discover/browse" && ${selector("settings-discover-browse")}`, - "Discover opening at Browse", + `location.pathname === "/settings/discover" && ${selector("settings-discover-browse")} && ${selector("select-settings-discover")}?.getAttribute("aria-current") === "page"`, + "Discover's own page", ), - "entering Discover opens Browse at its one canonical address", + "Discover is its own page at /settings/discover", +); +await navigate(`${dashboardOrigin}/settings/discover/browse`); +check( + await waitFor( + `location.pathname === "/settings/discover" && ${selector("settings-discover-browse")}`, + "the earlier Browse address respelled", + ), + "the earlier /settings/discover/browse respells to /settings/discover", ); if (openedModuleId) { await navigate(`${dashboardOrigin}/settings/${openedModuleId}`); @@ -619,6 +644,14 @@ await send("Emulation.setDeviceMetricsOverride", { }); await navigate(`${dashboardOrigin}/settings`); await waitFor(selector("mobile-navigation-trigger"), "the phone navigation trigger in Settings"); +await evaluate("window.scrollTo(0, document.documentElement.scrollHeight)"); +check( + await waitFor( + `${selector("mobile-header")} && ${selector("mobile-navigation-trigger")}.getBoundingClientRect().top >= 0 && document.documentElement.scrollWidth <= innerWidth`, + "the phone header after scrolling", + ), + "the phone header keeps the menu in reach after scrolling, with no horizontal overflow", +); await evaluate(`${selector("mobile-navigation-trigger")}.click()`); await waitFor( `${selector("navigation-drawer")}?.querySelector('[data-testid="select-settings-system"]')`, diff --git a/surfaces/shell/index.html b/surfaces/shell/index.html index 28abacd..7c32262 100644 --- a/surfaces/shell/index.html +++ b/surfaces/shell/index.html @@ -2,7 +2,9 @@ - + + diff --git a/surfaces/shell/src/browser/AccountPages.tsx b/surfaces/shell/src/browser/AccountPages.tsx index 7420fa4..70da0b5 100644 --- a/surfaces/shell/src/browser/AccountPages.tsx +++ b/surfaces/shell/src/browser/AccountPages.tsx @@ -195,7 +195,7 @@ function ChoiceGroup(props: { > props.onChange(option.value)} type="radio" diff --git a/surfaces/shell/src/browser/DashboardLink.tsx b/surfaces/shell/src/browser/DashboardLink.tsx index 14a2133..ac410b9 100644 --- a/surfaces/shell/src/browser/DashboardLink.tsx +++ b/surfaces/shell/src/browser/DashboardLink.tsx @@ -1,4 +1,3 @@ -import { designToken } from "@ekklesia/design-tokens"; import type { CSSProperties, FocusEventHandler, @@ -17,7 +16,9 @@ export interface DashboardLinkProps { readonly children: ReactNode; /** Marks the link as the page currently open, for assistive technology and visually. */ readonly current?: boolean; - readonly variant?: "navigation" | "action"; + /** `navigation` for the sidebar and lists, `action` for a button-like link, `text` for a link in + * running text or a card, styled by its own `className`. */ + readonly variant?: "navigation" | "action" | "text"; /** Called after an in-place navigation, including to the page already open — for a drawer that * closes once a destination is chosen. Not called for a click the browser handles itself. */ readonly onNavigate?: () => void; @@ -29,6 +30,8 @@ export interface DashboardLinkProps { readonly onMouseEnter?: MouseEventHandler; readonly onMouseLeave?: MouseEventHandler; readonly "data-testid"?: string; + /** Presentation hooks for the shell stylesheet, such as `data-nav-ancestor`. */ + readonly dataAttributes?: Readonly>; /** For a link that is an item of a menu (the account menu): its role, its roving `tabIndex`, and * a ref so the menu can move focus to it. */ readonly role?: "menuitem"; @@ -36,24 +39,19 @@ export interface DashboardLinkProps { readonly ref?: Ref; } +/** + * A navigation link's layout and its open-page look are the shell stylesheet's `ek-shell-link` + * (ADR 0058), never inline styles: an inline `justify-content` once outranked the collapsed sidebar's + * centring and pushed every icon to one side (ADR 0064). The open page is shown by weight as well as + * colour, so it never depends on colour alone. + */ const variantClass = { action: "ek-button ek-focusable ek-button--secondary", - navigation: "ek-button ek-focusable ek-button--quiet", + navigation: "ek-button ek-focusable ek-button--quiet ek-shell-link", + text: "ek-focusable", } as const; -const navigationStyle: CSSProperties = { - justifyContent: "flex-start", - textDecoration: "none", - width: "100%", -}; - -/** The open page is shown by weight as well as colour, so it never depends on colour alone. */ -const currentStyle: CSSProperties = { - ...navigationStyle, - background: designToken("state.selected-background"), - color: designToken("state.selected-foreground"), - fontWeight: designToken("typography.weight-bold"), -}; +const actionStyle: CSSProperties = { textDecoration: "none" }; /** A click the browser should handle itself: a new tab or window, a download, or another button. */ export function isBrowserHandledClick(event: { @@ -85,12 +83,7 @@ export function isBrowserHandledClick(event: { export function DashboardLink(props: DashboardLinkProps): ReactElement { const href = dashboardPath(props.to); const variant = props.variant ?? "navigation"; - const baseStyle = - variant === "action" - ? { textDecoration: "none" } - : props.current - ? currentStyle - : navigationStyle; + const baseStyle = variant === "navigation" ? undefined : actionStyle; return ( {props.children} diff --git a/surfaces/shell/src/browser/DevicePanel.tsx b/surfaces/shell/src/browser/DevicePanel.tsx deleted file mode 100644 index 15a9555..0000000 --- a/surfaces/shell/src/browser/DevicePanel.tsx +++ /dev/null @@ -1,246 +0,0 @@ -import { designToken } from "@ekklesia/design-tokens"; -import { Button, Dialog, Field, TextInput } from "@ekklesia/ui"; -import { type FormEvent, type ReactElement, useCallback, useEffect, useState } from "react"; -import { - approveDevicePairing, - type EkklesiaHostConfig, - type HostDeviceSummary, - listHostDevices, - lookupDevicePairing, - revokeHostDevice, -} from "../host-protocol.js"; - -/** - * The administrator's whole device surface: type the code from a screen, name it, approve it, and - * later take it away again. - * - * Deliberately small. Device identity belongs to the platform rather than to any module, so it has - * to live in the host — but this is infrastructure plumbing, not a fleet-management product, and - * there is no grouping, filtering, bulk action, targeting, health, or remote control here on - * purpose (ADR 0036). It renders only for an administrator who actually holds `core.devices.manage`: - * a viewer without it is told nothing about which devices exist. - */ -const panelStyles = { - meta: { - color: designToken("text.muted"), - fontSize: designToken("typography.size-xs"), - margin: 0, - }, - row: { - alignItems: "center", - borderTop: `${designToken("border.width")} solid ${designToken("border.divider")}`, - display: "flex", - gap: designToken("spacing.sm"), - justifyContent: "space-between", - padding: `${designToken("spacing.sm")} 0`, - }, - section: { display: "grid", gap: designToken("spacing.sm") }, - title: { - color: designToken("text.secondary"), - fontSize: designToken("typography.size-xs"), - fontWeight: designToken("typography.weight-bold"), - letterSpacing: "0.05em", - margin: 0, - }, -} as const; - -type PairingStep = - | { readonly step: "idle" } - | { readonly step: "busy" } - | { readonly step: "found"; readonly moduleId: string; readonly permissionId: string } - | { readonly step: "error"; readonly message: string }; - -export interface DevicePanelProps { - readonly config: EkklesiaHostConfig; -} - -export function DevicePanel(props: DevicePanelProps): ReactElement | undefined { - const { config } = props; - const [devices, setDevices] = useState(undefined); - const [permitted, setPermitted] = useState(true); - const [code, setCode] = useState(""); - const [name, setName] = useState(""); - const [pairing, setPairing] = useState({ step: "idle" }); - /** The device an administrator asked to revoke and has not yet confirmed. */ - const [confirming, setConfirming] = useState(undefined); - const [revoking, setRevoking] = useState(false); - const dismissConfirmation = useCallback(() => setConfirming(undefined), []); - - const refresh = useCallback(async (): Promise => { - try { - setDevices(await listHostDevices(config)); - } catch { - // The panel simply does not render for someone who may not manage devices; it never explains - // what it would have shown. - setPermitted(false); - } - }, [config]); - - useEffect(() => { - void refresh(); - }, [refresh]); - - if (!permitted) { - return undefined; - } - - const lookUp = async (event: FormEvent): Promise => { - event.preventDefault(); - setPairing({ step: "busy" }); - try { - const found = await lookupDevicePairing(config, code.trim()); - setPairing({ moduleId: found.moduleId, permissionId: found.permissionId, step: "found" }); - } catch { - // One message for expired, consumed, mistyped, and never-existed alike. - setPairing({ message: "That code is not waiting for approval.", step: "error" }); - } - }; - - const approve = async (): Promise => { - setPairing({ step: "busy" }); - try { - await approveDevicePairing(config, code.trim(), name.trim()); - setCode(""); - setName(""); - setPairing({ step: "idle" }); - await refresh(); - } catch { - setPairing({ message: "That code could not be approved.", step: "error" }); - } - }; - - const revoke = async (deviceId: string): Promise => { - setRevoking(true); - try { - await revokeHostDevice(config, deviceId); - } catch { - // Left to the next refresh rather than asserting a failure the operator cannot act on. - } - await refresh(); - setRevoking(false); - setConfirming(undefined); - }; - - return ( -
-

DISPLAY DEVICES

- -
void lookUp(event)} - style={{ display: "grid", gap: designToken("spacing.sm") }} - > - - {(control) => ( - setCode(event.target.value)} - value={code} - /> - )} - - -
- - {pairing.step === "found" ? ( -
-

- A display is asking to show {pairing.moduleId}. -

- - {(control) => ( - setName(event.target.value)} - placeholder="Foyer TV" - value={name} - /> - )} - - -
- ) : undefined} - - {pairing.step === "error" ? ( -

- {pairing.message} -

- ) : undefined} - - {devices !== undefined && devices.length === 0 ? ( -

- No displays are paired. -

- ) : undefined} - - {devices?.map((device) => ( -
- - {device.name} - {device.revoked ? " (revoked)" : ""} - - {device.revoked ? undefined : ( - - )} -
- ))} - - {/* Revoking strands the screen until someone re-pairs it in person, so the consequence is - spelled out before it happens — never a browser-native confirm. */} - - - - - } - onDismiss={dismissConfirmation} - open={confirming !== undefined} - title={`Revoke ${confirming?.name ?? "this 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. -

-
-
- ); -} diff --git a/surfaces/shell/src/browser/DisplaysSettingsPage.tsx b/surfaces/shell/src/browser/DisplaysSettingsPage.tsx new file mode 100644 index 0000000..bca2338 --- /dev/null +++ b/surfaces/shell/src/browser/DisplaysSettingsPage.tsx @@ -0,0 +1,454 @@ +import { designToken } from "@ekklesia/design-tokens"; +import { + Badge, + Button, + Dialog, + EmptyState, + ErrorState, + Field, + InlineNotice, + LoadingState, + PageHeader, + type PageHeaderBack, + Panel, + TextInput, +} from "@ekklesia/ui"; +import { + type FormEvent, + type ReactElement, + useCallback, + useEffect, + useId, + useRef, + useState, +} from "react"; +import { + approveDevicePairing, + type EkklesiaHostConfig, + type HostDeviceSummary, + HostProtocolError, + listHostDevices, + lookupDevicePairing, + revokeHostDevice, +} from "../host-protocol.js"; +import { calendarDate, lastSeenPhrase } from "./display-time.js"; +import { ShellIcon } from "./shell-icons.js"; + +/** + * Settings → Displays (ADR 0064): the platform's own administration page for the screens paired + * with this installation — the paired displays, adding one with the code its screen shows, and + * revoking one. + * + * It is the device surface that used to sit in the sidebar, moved rather than rebuilt: the same + * platform-owned identity, the same endpoints, and the same rules (ADR 0036). One record per + * physical screen, named once here, never a second name kept by a module. Deliberately still small — + * no grouping, filtering, bulk action, health, or remote control — and only reachable by someone who + * holds `core.devices.manage`; the server refuses everyone else whatever this page shows. + * + * Nothing on this page takes focus by itself. The code field is focused only once the administrator + * has chosen **Add display**, because typing the code is then plainly the next thing to do. + */ + +type ListState = + | { readonly kind: "loading" } + | { readonly kind: "ready"; readonly devices: readonly HostDeviceSummary[] } + | { readonly kind: "denied" } + | { readonly kind: "failed" }; + +type PairingStep = + | { readonly step: "code"; readonly error?: string } + | { readonly step: "finding" } + | { readonly step: "found"; readonly moduleId: string; readonly error?: string } + | { readonly step: "approving"; readonly moduleId: string }; + +export interface DisplaysSettingsPageProps { + readonly config: EkklesiaHostConfig; + /** Back to Settings, or to where a validated return target says (ADR 0063). */ + readonly back: PageHeaderBack; + /** A module's name, from the navigation, for "a display is asking to show Signage". */ + readonly moduleName: (moduleId: string) => string | undefined; + /** For tests: the clock "last seen" is measured against. */ + readonly now?: () => Date; +} + +/** The "Add display" flow: the code from the screen, then the display's one name, then approval. */ +function AddDisplayDialog(props: { + readonly config: EkklesiaHostConfig; + readonly open: boolean; + readonly moduleName: (moduleId: string) => string | undefined; + readonly onDismiss: () => void; + readonly onPaired: (name: string) => void; +}): ReactElement { + const { config, onPaired } = props; + const formId = useId(); + const codeRef = useRef(null); + const nameRef = useRef(null); + const [code, setCode] = useState(""); + const [name, setName] = useState(""); + const [pairing, setPairing] = useState({ step: "code" }); + + // A fresh flow every time it opens. + useEffect(() => { + if (props.open) { + setCode(""); + setName(""); + setPairing({ step: "code" }); + } + }, [props.open]); + + // Once the display is found, naming it is the next thing to do. + const found = pairing.step === "found"; + useEffect(() => { + if (found) { + nameRef.current?.focus(); + } + }, [found]); + + const busy = pairing.step === "finding" || pairing.step === "approving"; + + const submit = async (event: FormEvent): Promise => { + event.preventDefault(); + if (pairing.step === "code") { + setPairing({ step: "finding" }); + try { + const lookup = await lookupDevicePairing(config, code.trim()); + setPairing({ moduleId: lookup.moduleId, step: "found" }); + } catch { + // One message for expired, consumed, mistyped, and never-existed alike. + setPairing({ + error: "That code is not waiting for approval. Check the code on the screen.", + step: "code", + }); + codeRef.current?.focus(); + } + return; + } + if (pairing.step === "found") { + const displayName = name.trim(); + setPairing({ moduleId: pairing.moduleId, step: "approving" }); + try { + await approveDevicePairing(config, code.trim(), displayName); + onPaired(displayName); + } catch { + setPairing({ + error: "That display could not be approved. Its code may have expired.", + moduleId: pairing.moduleId, + step: "found", + }); + } + } + }; + + const moduleId = + pairing.step === "found" || pairing.step === "approving" ? pairing.moduleId : undefined; + const naming = moduleId !== undefined; + const error = pairing.step === "code" || pairing.step === "found" ? pairing.error : undefined; + return ( + + + {naming ? ( + + ) : ( + + )} + + } + data-testid="add-display-dialog" + description={ + naming + ? "Give it the name everyone will know it by. Every module that uses this screen shows the same name." + : "Open the display page on the screen you want to add. It shows a six-digit pairing code." + } + dismissible={!busy} + initialFocusRef={codeRef} + onDismiss={props.onDismiss} + open={props.open} + title="Add display" + > +
void submit(event)} + style={{ display: "grid", gap: designToken("spacing.md") }} + > + + {(control) => ( + setCode(event.target.value)} + ref={codeRef} + value={code} + /> + )} + + {naming ? ( + <> +

+ A display is asking to show {props.moduleName(moduleId) ?? moduleId}. +

+ + {(control) => ( + setName(event.target.value)} + placeholder="Foyer TV" + ref={nameRef} + value={name} + /> + )} + + + ) : undefined} + {error === undefined ? undefined : ( + + {error} + + )} +
+
+ ); +} + +function DisplayCard(props: { + readonly device: HostDeviceSummary; + readonly now: Date; + readonly onRevoke: () => void; +}): ReactElement { + const { device, now } = props; + const facts: string[] = []; + if (device.revoked) { + // The badge says so; nothing about when it was last seen is worth acting on any more. + } else if (device.lastSeenAt !== undefined) { + facts.push(`Last seen ${lastSeenPhrase(device.lastSeenAt, now)}`); + } else { + facts.push("Not seen since it was paired"); + } + if (device.pairedAt !== undefined) { + facts.push(`Paired ${calendarDate(device.pairedAt)}`); + } + return ( +
  • + +
    +
    + {device.name} + + {device.revoked ? "Revoked" : "Paired"} + +
    +

    {facts.join(" · ")}

    +
    + {device.revoked ? undefined : ( + + )} +
  • + ); +} + +export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactElement { + const { config } = props; + const [list, setList] = useState({ kind: "loading" }); + const [adding, setAdding] = useState(false); + const [paired, setPaired] = useState(undefined); + /** The display an administrator asked to revoke and has not yet confirmed. */ + const [confirming, setConfirming] = useState(undefined); + const [revoking, setRevoking] = useState(false); + const dismissConfirmation = useCallback(() => setConfirming(undefined), []); + const dismissAdding = useCallback(() => setAdding(false), []); + + const refresh = useCallback(async (): Promise => { + try { + setList({ devices: await listHostDevices(config), kind: "ready" }); + } catch (error) { + setList({ + kind: + error instanceof HostProtocolError && error.code === "HOST_ACCESS_DENIED" + ? "denied" + : "failed", + }); + } + }, [config]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const revoke = async (deviceId: string): Promise => { + setRevoking(true); + try { + await revokeHostDevice(config, deviceId); + } catch { + // Left to the refresh below rather than asserting a failure the operator cannot act on. + } + await refresh(); + setRevoking(false); + setConfirming(undefined); + }; + + const onPaired = (name: string): void => { + setAdding(false); + setPaired(name); + // The new display appears without a page refresh (DESIGN.md §11.4). + void refresh(); + }; + + const now = props.now?.() ?? new Date(); + const canAdd = list.kind === "ready"; + return ( +
    + { + // Focus returns here when the dialog closes. Some browsers (Safari) do not focus a + // button that is clicked, so it is focused deliberately before the dialog opens. + event.currentTarget.focus(); + setPaired(undefined); + setAdding(true); + }} + variant="primary" + > + + Add display + + ) : undefined + } + back={props.back} + description="The screens paired with this installation. Each has one name, used by every module that shows content on it." + title="Displays" + /> + + {paired === undefined ? undefined : ( + + + {paired} is paired and can be used by your modules. + + + )} + + {list.kind === "loading" ? : undefined} + {list.kind === "denied" ? ( + + ) : undefined} + {list.kind === "failed" ? ( + { + setList({ kind: "loading" }); + void refresh(); + }} + > + Retry + + } + description="Try again in a moment." + title="The displays could not be loaded" + /> + ) : undefined} + {list.kind === "ready" ? ( + + {list.devices.length === 0 ? ( +
    + +
    + ) : ( +
      + {list.devices.map((device) => ( + setConfirming(device)} + /> + ))} +
    + )} +
    + ) : undefined} + + + + {/* Revoking strands the screen until someone re-pairs it in person, so the consequence is + spelled out before it happens — never a browser-native confirm. */} + + + + + } + onDismiss={dismissConfirmation} + open={confirming !== undefined} + title={`Revoke ${confirming?.name ?? "this 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. +

    +
    +
    + ); +} diff --git a/surfaces/shell/src/browser/HostApp.tsx b/surfaces/shell/src/browser/HostApp.tsx index 88803ed..2636f9f 100644 --- a/surfaces/shell/src/browser/HostApp.tsx +++ b/surfaces/shell/src/browser/HostApp.tsx @@ -45,6 +45,7 @@ import { } from "./appearance-preference.js"; import { ForgotPasswordForm } from "./ForgotPasswordForm.js"; import type { HostHistory } from "./host-history.js"; +import { DisplaysSettingsPage } from "./DisplaysSettingsPage.js"; import { createBrowserHostViewport, type HostViewport } from "./host-viewport.js"; import { ModuleUnavailablePage, NotFoundPage, OverviewPage } from "./HostPages.js"; import { LoginForm } from "./LoginForm.js"; @@ -59,6 +60,7 @@ import { SettingsHomePage, SettingsLoadFailedPage, SettingsSectionPage, + settingsBack, type SettingsReturn, SettingsUnavailablePage, } from "./SettingsPages.js"; @@ -200,17 +202,26 @@ function allEntries(bootstrap: HostBootstrap): readonly DashboardNavigationEntry } function SettingsPageView(props: { + readonly config: EkklesiaHostConfig; readonly page: SettingsPage; readonly history: HostHistory; readonly onRetry: () => void; readonly returnTo: SettingsReturn; }): ReactElement { - const { page, history, returnTo } = props; + const { config, page, history, returnTo } = props; switch (page.kind) { case "home": - return ; + return ; case "section": - return ; + return page.section === "displays" ? ( + + ) : ( + + ); case "discover": return ; case "module": @@ -661,7 +672,12 @@ export function HostApp(props: HostAppProps): ReactElement { return; } if (shownRouteRef.current !== undefined && shownRouteRef.current !== currentRouteKey) { - mainRef.current?.focus(); + // A new page starts at its top, as a full page load would, and focusing it must not scroll + // it: focus() alone would scroll the page's top to the very top of the viewport, beneath + // the phone's sticky header, so every page opened from the drawer started under it. + const main = mainRef.current; + main?.focus({ preventScroll: true }); + main?.ownerDocument.scrollingElement?.scrollTo?.({ left: 0, top: 0 }); } shownRouteRef.current = currentRouteKey; }, [currentRouteKey, state.status]); @@ -757,7 +773,6 @@ export function HostApp(props: HostAppProps): ReactElement { return ( {settingsPage !== undefined ? ( { @@ -56,106 +64,302 @@ function settingsBack(history: HostHistory, returnTo?: SettingsReturn) { }); } -function SettingsLinkList(props: { - readonly history: HostHistory; - readonly items: readonly { +/** One card of the Settings overview: an area, its state in a line or two, and where to change it. */ +function SummaryCard(props: { + readonly testId: string; + readonly icon: ShellIconName; + readonly title: string; + readonly status: ReactNode; + readonly detail?: ReactNode; + readonly links: readonly { readonly key: string; - readonly destination: DashboardDestination; readonly label: string; - readonly description?: string; - readonly icon: ShellIconName; + readonly to: DashboardDestination; readonly testId: string; }[]; + readonly history: HostHistory; + readonly compact?: boolean; }): ReactElement { + const headingId = useId(); return ( -
      - {props.items.map((item) => ( -
    • - - - - {item.label} - {item.description === undefined ? undefined : ( - {item.description} - )} - - -
    • - ))} -
    +

    + + {props.title} +

    +
    +

    + {props.status} +

    + {props.detail === undefined ? undefined : ( +

    {props.detail}

    + )} +
    + {props.links.length === 0 ? undefined : ( +
      + {props.links.map((link) => ( +
    • + + {link.label} + +
    • + ))} +
    + )} + ); } +type Loaded = + | { readonly kind: "loading" } + | { readonly kind: "ready"; readonly value: T } + | { readonly kind: "failed" }; + +/** Reads one summary's data once, only when `enabled`; a refusal or failure is just "failed". */ +function useSummary(enabled: boolean, load: (signal: AbortSignal) => Promise): Loaded { + const [state, setState] = useState>({ kind: "loading" }); + useEffect(() => { + if (!enabled) { + return; + } + const controller = new AbortController(); + load(controller.signal).then( + (value) => { + if (!controller.signal.aborted) { + setState({ kind: "ready", value }); + } + }, + () => { + if (!controller.signal.aborted) { + setState({ kind: "failed" }); + } + }, + ); + return () => controller.abort(); + }, [enabled, load]); + return state; +} + +function plural(count: number, one: string, many: string): string { + return `${count} ${count === 1 ? one : many}`; +} + +function DisplaysSummary(props: { + readonly config: EkklesiaHostConfig; + readonly history: HostHistory; + readonly now: Date; +}): ReactElement { + const { config } = props; + const load = useCallback((signal: AbortSignal) => listHostDevices(config, signal), [config]); + const devices = useSummary(true, load); + const active = + devices.kind === "ready" ? devices.value.filter((device) => !device.revoked) : undefined; + const latest = active + ?.filter((device) => device.lastSeenAt !== undefined) + .sort((a, b) => (b.lastSeenAt?.getTime() ?? 0) - (a.lastSeenAt?.getTime() ?? 0))[0]; + return ( + + ); +} + +function ModulesSummary(props: { + readonly config: EkklesiaHostConfig; + readonly history: HostHistory; + readonly lifecycle: boolean; + readonly modules: readonly DashboardSettingsModule[]; +}): ReactElement { + const { config } = props; + const load = useCallback( + (signal: AbortSignal) => listHostModuleLifecycle(config, signal), + [config], + ); + const lifecycle = useSummary(props.lifecycle, load); + const installed = lifecycle.kind === "ready" ? lifecycle.value : undefined; + const off = installed?.filter((module) => module.state === "disabled").length ?? 0; + const blocked = installed?.filter((module) => module.state === "blocked").length ?? 0; + const links = [ + ...(props.lifecycle + ? [ + { + key: "installed", + label: "Manage modules", + testId: "settings-home-discover", + to: { kind: "discover", view: "installed" } as const, + }, + ] + : []), + ...props.modules.map((module) => ({ + key: `module-${module.id}`, + label: `${module.name} settings`, + testId: `settings-home-module-${module.id}`, + to: { kind: "module-settings", moduleId: module.id } as const, + })), + ]; + return ( + part !== undefined) + .join(" · ") + } + history={props.history} + icon="module" + links={links} + status={ + !props.lifecycle + ? "Module settings" + : lifecycle.kind === "loading" + ? "Checking…" + : lifecycle.kind === "failed" + ? "Couldn't check modules" + : installed?.length === 0 + ? "No modules installed" + : `${plural(installed?.length ?? 0, "module", "modules")} installed` + } + testId="settings-summary-modules" + title="Modules" + /> + ); +} + +/** What General, Branding, and System can honestly say today: each is not built yet, so its card is + * one restrained line rather than a "Not available yet" box (ADR 0064). */ +const SECTION_STATE: Readonly> = Object.freeze({ + branding: "Using the default name and colours", + general: "Using the defaults", + system: "Details aren't shown here yet", +}); + /** - * `/settings`: a real landing page, not a redirect to the first section — the two groups the - * person may open, each item named and described in a line. Not a dashboard of its own. + * `/settings`: a real landing page, never a redirect to the first section (ADR 0061) — and, since + * the sidebar already lists every section, not a second copy of that list either (ADR 0064). It + * answers "what state is this installation in?": how many displays are paired, which modules are + * installed and running, and a line for each other area this person may open, each with the one + * link that changes it. It reads only what already exists — the device list and module lifecycle — + * and only when this person may see that area. */ export function SettingsHomePage(props: { + readonly config: EkklesiaHostConfig; readonly history: HostHistory; readonly settings: DashboardSettings; + /** For tests: the clock "last seen" is measured against. */ + readonly now?: () => Date; }): ReactElement { - const modules = navigableModuleSettings(props.settings); - const empty = props.settings.sections.length === 0 && modules.length === 0; + const { settings } = props; + const modules = navigableModuleSettings(settings); + const empty = settings.sections.length === 0 && modules.length === 0; + const visible = (section: SettingsSection) => settings.sections.includes(section); + const others = (["general", "branding", "system"] as const).filter(visible); + const showModules = visible("discover") || modules.length > 0; return (
    - + {empty ? ( ) : undefined} - {props.settings.sections.length > 0 ? ( - - ({ - description: SETTINGS_SECTION_COPY[section].description, - destination: settingsSectionDestination(section), - icon: SETTINGS_SECTION_COPY[section].icon, - key: section, - label: SETTINGS_SECTION_COPY[section].label, - testId: `settings-home-${section}`, - }))} - /> - + {visible("displays") || showModules ? ( +
    + {visible("displays") ? ( + + ) : undefined} + {showModules ? ( + + ) : undefined} +
    ) : undefined} - {modules.length > 0 ? ( - - ({ - destination: { kind: "module-settings", moduleId: module.id }, - icon: "module", - key: module.id, - label: module.name, - testId: `settings-home-module-${module.id}`, - }))} - /> - + {others.length > 0 ? ( +
    + {others.map((section) => ( + + ))} +
    ) : undefined}
    ); } -const SECTION_NOT_YET: Readonly> = Object.freeze({ +/** The sections that are still placeholders. Displays is a real page (`DisplaysSettingsPage`). */ +export type PlaceholderSettingsSection = Exclude; + +const SECTION_NOT_YET: Readonly> = Object.freeze({ branding: "Your church's name, logo, and colours will be managed here.", - displays: - "Paired screens will be managed here. Until then, pair and revoke displays from Display devices in the navigation.", general: "Settings that apply across this installation will be managed here.", system: "Version, health, and diagnostics for this installation will appear here.", }); @@ -187,7 +391,7 @@ export function DiscoverPage(props: { /** `/settings/
    `: a platform section the person may see, not built yet. */ export function SettingsSectionPage(props: { readonly history: HostHistory; - readonly section: SettingsPageSection; + readonly section: PlaceholderSettingsSection; readonly returnTo?: SettingsReturn; }): ReactElement { const copy = SETTINGS_SECTION_COPY[props.section]; diff --git a/surfaces/shell/src/browser/ShellLayout.tsx b/surfaces/shell/src/browser/ShellLayout.tsx index 19dc7b4..698da7c 100644 --- a/surfaces/shell/src/browser/ShellLayout.tsx +++ b/surfaces/shell/src/browser/ShellLayout.tsx @@ -1,4 +1,3 @@ -import { designToken } from "@ekklesia/design-tokens"; import { type KeyboardEvent, type ReactElement, @@ -12,14 +11,8 @@ import { useSyncExternalStore, } from "react"; import { type DashboardRoute, dashboardPath } from "../dashboard-routes.js"; -import type { - DashboardNavigation, - DashboardSettings, - EkklesiaHostConfig, - HostAccount, -} from "../host-protocol.js"; +import type { DashboardNavigation, DashboardSettings, HostAccount } from "../host-protocol.js"; import { AccountMenu } from "./AccountMenu.js"; -import { DevicePanel } from "./DevicePanel.js"; import type { HostHistory } from "./host-history.js"; import type { HostViewport } from "./host-viewport.js"; import { useNavTooltip } from "./NavTooltip.js"; @@ -42,7 +35,6 @@ const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; export interface ShellLayoutProps { - readonly config: EkklesiaHostConfig; readonly history: HostHistory; readonly navigation: DashboardNavigation; readonly settings: DashboardSettings | "unavailable"; @@ -85,9 +77,10 @@ function SidebarToggle(props: { /** * The desktop and tablet sidebar (DESIGN.md §4.2): identity at the top, the navigation, and the * account area anchored at the bottom. Collapsed, it is icons only, each named by a tooltip. + * + * It is navigation and nothing else (ADR 0064): pairing a display is Settings → Displays. */ function Sidebar(props: { - readonly config: EkklesiaHostConfig; readonly history: HostHistory; readonly navigation: DashboardNavigation; readonly settings: DashboardSettings | "unavailable"; @@ -121,9 +114,6 @@ function Sidebar(props: { route={props.route} settings={props.settings} /> - {/* The transitional device panel (ADR 0036) needs room for its form; collapsed, expanding - the sidebar reveals it. Stage 1 moves it to Settings → Displays. */} - {presentation.collapsed ? undefined : }
    -
    {compact ? ( -
    +
    -
    + {/* The page's own

    names it for assistive technology; this is its visual echo + while that heading is scrolled away, so it is not announced twice. */} + {props.pageTitle === undefined ? undefined : ( + + )} +

    ) : undefined}
    @@ -393,7 +395,6 @@ export function ShellLayout(props: ShellLayoutProps): ReactElement { {showDrawer ? ( closeDrawer("trigger")} diff --git a/surfaces/shell/src/browser/ShellNavigation.tsx b/surfaces/shell/src/browser/ShellNavigation.tsx index 84e8e12..d81af10 100644 --- a/surfaces/shell/src/browser/ShellNavigation.tsx +++ b/surfaces/shell/src/browser/ShellNavigation.tsx @@ -11,9 +11,9 @@ import { DashboardLink } from "./DashboardLink.js"; import { ModuleIcon } from "./module-icon.js"; import type { HostHistory } from "./host-history.js"; import { useNavTooltip } from "./NavTooltip.js"; -import { DISCOVER_VIEWS } from "../dashboard-routes.js"; +import { DISCOVER_CHILD_VIEWS } from "../dashboard-routes.js"; import { - DISCOVER_VIEW_COPY, + DISCOVER_CHILD_LABEL, hasVisibleSettings, isSettingsRoute, navigableModuleSettings, @@ -55,24 +55,54 @@ export function ShellBrand(props: { readonly collapsed: boolean }): ReactElement ); } +/** + * One navigation item: a real link (ADR 0057) laid out by the shell stylesheet. + * + * - `current` is the open page: `aria-current="page"`, a selected background, and weight. + * - `ancestor` is the parent of the open page — Discover while Installed is open, a module while one + * of its pages is — drawn in the primary text colour with an accent icon but no fill, so parent and + * child are never both "selected" and the relationship still reads at a glance. + * - `child` is an item beneath a parent: text only, indented past the parent's label, and — on + * desktop — a smaller control, so it is clearly subordinate. In the drawer it keeps the large touch + * target. + * - `disclosure` marks an item that opens in place into the items beneath it, with a chevron that + * points down while they are showing. It is decorative: the nested list itself, named by its + * parent, carries the structure for assistive technology. + * + * Collapsed, every item has one square geometry, centred on the rail by the stylesheet, whatever its + * level. + */ function NavigationItem(props: { readonly destination: DashboardDestination; readonly label: string; - /** A shell icon by name, a module's own icon, or none — a module's pages are text beneath it. */ + /** A shell icon by name, a module's own icon, or none — a child item is text beneath its parent. */ readonly icon?: ShellIconName | ReactElement; readonly current: boolean; + readonly ancestor?: boolean; + readonly child?: boolean; + readonly disclosure?: "open" | "closed"; readonly history: HostHistory; readonly presentation: NavigationPresentation; readonly onNavigate?: () => void; readonly testId: string; }): ReactElement { - const { tooltip, triggerProps } = useNavTooltip(props.label, props.presentation.collapsed); - const style: CSSProperties = { minHeight: controlHeight(props.presentation) }; + const { presentation } = props; + const { tooltip, triggerProps } = useNavTooltip(props.label, presentation.collapsed); + const style: CSSProperties = { + minHeight: + props.child && !presentation.compact + ? designToken("control.height-sm") + : controlHeight(presentation), + }; return (
  • {props.label} + {props.disclosure === undefined || presentation.collapsed ? undefined : ( + + + + )} {tooltip}
  • @@ -138,8 +177,10 @@ function ModuleItems(props: { return ( 0 ? { disclosure: showPages ? "open" : "closed" } : {})} history={props.history} icon={} label={entry.name} @@ -151,14 +192,15 @@ function ModuleItems(props: {
    • {entry.pages.map((page) => ( void; }): ReactElement { const onNavigate = props.onNavigate ? { onNavigate: props.onNavigate } : {}; - const open = props.route.kind === "discover"; + const { route, presentation } = props; + const openView = route.kind === "discover" ? route.view : undefined; + const showChildren = openView !== undefined && !presentation.collapsed; const copy = SETTINGS_SECTION_COPY.discover; return ( <> - {open ? ( + {showChildren ? (
      • - {DISCOVER_VIEWS.map((view) => ( + {DISCOVER_CHILD_VIEWS.map((view) => ( ))} @@ -282,8 +327,10 @@ function SettingsNavigation(props: { {collapsed ?