Fix the Stage 1 Dashboard UX defects found in assembled review
CI / verify (pull_request) Successful in 1m13s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 48s
CI / migration-smoke (pull_request) Successful in 2m22s
CI / image-build (pull_request) Successful in 1m8s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / package-publication (pull_request) Successful in 57s
CI / deploy-smoke (split) (pull_request) Successful in 3m38s

The assembled Stage 1 Dashboard was reviewed by hand on a desktop, with
the sidebar collapsed, and on an iPhone. This fixes what that review
found, and records the decisions in ADR 0064:

- Discover is its own page at /settings/discover, with Installed and
  Updates beneath it as children. There is no Browse item any more.
  /settings/discover/browse is still read, as an alias, and is respelled
  in place. Parents and children now read as a hierarchy: a guide line,
  text-only children, a disclosure chevron, and parent and current
  states that look different.
- The sidebar is navigation only. Display pairing moves from the sidebar
  and drawer to Settings -> Displays: the paired displays, a deliberate
  Add display dialog, and revocation. Nothing takes focus on arrival;
  the code field is focused only once Add display is chosen.
- Collapsed icons are centred. Nav-link layout moves from inline styles
  to the shell stylesheet, and every collapsed item is one square
  centred on the rail.
- /settings is an overview of the installation: displays paired,
  modules installed and running, and a line for each other area. It
  reads only existing endpoints.
- Phones get a sticky, safe-area-aware header holding the menu trigger
  and the page name. New pages start at the top, and focus no longer
  scrolls content under the header.
- @ekklesia/ui draws checkbox and radio indicators from tokens rather
  than accent-color, so the selected state is the strong accent in
  Light and Dark alike.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-15 20:37:32 -05:00
co-authored by Claude Opus 5.5
parent 186668915b
commit 405e509bcb
31 changed files with 2346 additions and 611 deletions
+3 -1
View File
@@ -2,7 +2,9 @@
<html lang="en" style="min-width: 0; scrollbar-gutter: stable">
<head>
<meta charset="utf-8" />
<meta content="width=device-width, initial-scale=1" name="viewport" />
<!-- viewport-fit=cover lets the shell lay itself out around a notch, a status bar, and a home
indicator with env(safe-area-inset-*) (ADR 0064), rather than the browser letterboxing it. -->
<meta content="width=device-width, initial-scale=1, viewport-fit=cover" name="viewport" />
<!-- The API's origin (ADR 0045). The API serving this page fills it in at startup; empty means
this page's own origin, the same-origin topology. Never an inline script, so a strict
script-src needs no exception for it. -->
+1 -1
View File
@@ -195,7 +195,7 @@ function ChoiceGroup<Value extends string>(props: {
>
<input
checked={checked}
className="ek-shell-choice__input ek-focusable"
className="ek-radio__input ek-shell-choice__input ek-focusable"
name={props.name}
onChange={() => props.onChange(option.value)}
type="radio"
+16 -22
View File
@@ -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<HTMLAnchorElement>;
readonly onMouseLeave?: MouseEventHandler<HTMLAnchorElement>;
readonly "data-testid"?: string;
/** Presentation hooks for the shell stylesheet, such as `data-nav-ancestor`. */
readonly dataAttributes?: Readonly<Record<`data-${string}`, string | undefined>>;
/** 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<HTMLAnchorElement>;
}
/**
* 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 (
<a
aria-current={props.current ? "page" : undefined}
@@ -115,6 +108,7 @@ export function DashboardLink(props: DashboardLinkProps): ReactElement {
ref={props.ref}
role={props.role}
style={props.style ? { ...baseStyle, ...props.style } : baseStyle}
{...(props.dataAttributes ?? {})}
tabIndex={props.tabIndex}
>
{props.children}
-246
View File
@@ -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<readonly HostDeviceSummary[] | undefined>(undefined);
const [permitted, setPermitted] = useState(true);
const [code, setCode] = useState("");
const [name, setName] = useState("");
const [pairing, setPairing] = useState<PairingStep>({ step: "idle" });
/** The device an administrator asked to revoke and has not yet confirmed. */
const [confirming, setConfirming] = useState<HostDeviceSummary | undefined>(undefined);
const [revoking, setRevoking] = useState(false);
const dismissConfirmation = useCallback(() => setConfirming(undefined), []);
const refresh = useCallback(async (): Promise<void> => {
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<void> => {
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<void> => {
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<void> => {
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 (
<section data-testid="host-devices" style={panelStyles.section}>
<h2 style={panelStyles.title}>DISPLAY DEVICES</h2>
<form
onSubmit={(event) => void lookUp(event)}
style={{ display: "grid", gap: designToken("spacing.sm") }}
>
<Field label="Pairing code shown on the screen">
{(control) => (
<TextInput
{...control}
autoComplete="off"
data-testid="device-pairing-code-input"
inputMode="numeric"
maxLength={6}
onChange={(event) => setCode(event.target.value)}
value={code}
/>
)}
</Field>
<Button data-testid="device-pairing-lookup" size="sm" type="submit">
Find display
</Button>
</form>
{pairing.step === "found" ? (
<div
data-testid="device-pairing-found"
style={{ display: "grid", gap: designToken("spacing.sm") }}
>
<p style={panelStyles.meta}>
A display is asking to show <strong>{pairing.moduleId}</strong>.
</p>
<Field label="Name this display">
{(control) => (
<TextInput
{...control}
autoComplete="off"
data-testid="device-pairing-name"
maxLength={128}
onChange={(event) => setName(event.target.value)}
placeholder="Foyer TV"
value={name}
/>
)}
</Field>
<Button
data-testid="device-pairing-approve"
disabled={name.trim().length === 0}
onClick={() => void approve()}
size="sm"
variant="primary"
>
Approve
</Button>
</div>
) : undefined}
{pairing.step === "error" ? (
<p data-testid="device-pairing-error" style={panelStyles.meta}>
{pairing.message}
</p>
) : undefined}
{devices !== undefined && devices.length === 0 ? (
<p data-testid="device-list-empty" style={panelStyles.meta}>
No displays are paired.
</p>
) : undefined}
{devices?.map((device) => (
<div data-testid={`device-${device.id}`} key={device.id} style={panelStyles.row}>
<span>
{device.name}
{device.revoked ? " (revoked)" : ""}
</span>
{device.revoked ? undefined : (
<Button
data-testid={`device-revoke-${device.id}`}
onClick={() => setConfirming(device)}
size="sm"
>
Revoke
</Button>
)}
</div>
))}
{/* 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. */}
<Dialog
actions={
<>
<Button
data-testid="device-revoke-cancel"
disabled={revoking}
onClick={dismissConfirmation}
>
Cancel
</Button>
<Button
data-testid="device-revoke-confirm"
disabled={revoking}
onClick={() => (confirming ? void revoke(confirming.id) : undefined)}
variant="danger"
>
Revoke display
</Button>
</>
}
onDismiss={dismissConfirmation}
open={confirming !== undefined}
title={`Revoke ${confirming?.name ?? "this display"}?`}
>
<p style={{ margin: 0 }}>
The screen stops showing content within a few seconds and returns to its pairing code.
This cannot be undone: to use it again, pair it from the screen with a new code.
</p>
</Dialog>
</section>
);
}
@@ -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<HTMLInputElement>(null);
const nameRef = useRef<HTMLInputElement>(null);
const [code, setCode] = useState("");
const [name, setName] = useState("");
const [pairing, setPairing] = useState<PairingStep>({ 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<void> => {
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 (
<Dialog
actions={
<>
<Button data-testid="add-display-cancel" disabled={busy} onClick={props.onDismiss}>
Cancel
</Button>
{naming ? (
<Button
data-testid="device-pairing-approve"
disabled={busy || name.trim().length === 0}
form={formId}
type="submit"
variant="primary"
>
Pair display
</Button>
) : (
<Button
data-testid="device-pairing-lookup"
disabled={busy || code.trim().length === 0}
form={formId}
type="submit"
variant="primary"
>
Find display
</Button>
)}
</>
}
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"
>
<form
id={formId}
onSubmit={(event) => void submit(event)}
style={{ display: "grid", gap: designToken("spacing.md") }}
>
<Field label="Pairing code">
{(control) => (
<TextInput
{...control}
autoComplete="off"
data-testid="device-pairing-code-input"
disabled={naming || busy}
inputMode="numeric"
maxLength={6}
onChange={(event) => setCode(event.target.value)}
ref={codeRef}
value={code}
/>
)}
</Field>
{naming ? (
<>
<p data-testid="device-pairing-found" style={{ margin: 0 }}>
A display is asking to show <strong>{props.moduleName(moduleId) ?? moduleId}</strong>.
</p>
<Field label="Display name">
{(control) => (
<TextInput
{...control}
autoComplete="off"
data-testid="device-pairing-name"
disabled={busy}
maxLength={128}
onChange={(event) => setName(event.target.value)}
placeholder="Foyer TV"
ref={nameRef}
value={name}
/>
)}
</Field>
</>
) : undefined}
{error === undefined ? undefined : (
<InlineNotice tone="danger">
<span data-testid="device-pairing-error">{error}</span>
</InlineNotice>
)}
</form>
</Dialog>
);
}
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 (
<li className="ek-shell-display-card" data-testid={`device-${device.id}`}>
<span aria-hidden="true" className="ek-shell-display-card__icon">
<ShellIcon name="settingsDisplays" />
</span>
<div className="ek-shell-display-card__copy">
<div className="ek-shell-display-card__title">
<span className="ek-shell-display-card__name">{device.name}</span>
<Badge tone={device.revoked ? "neutral" : "success"}>
{device.revoked ? "Revoked" : "Paired"}
</Badge>
</div>
<p className="ek-shell-display-card__facts">{facts.join(" · ")}</p>
</div>
{device.revoked ? undefined : (
<Button data-testid={`device-revoke-${device.id}`} onClick={props.onRevoke} size="sm">
Revoke
</Button>
)}
</li>
);
}
export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactElement {
const { config } = props;
const [list, setList] = useState<ListState>({ kind: "loading" });
const [adding, setAdding] = useState(false);
const [paired, setPaired] = useState<string | undefined>(undefined);
/** The display an administrator asked to revoke and has not yet confirmed. */
const [confirming, setConfirming] = useState<HostDeviceSummary | undefined>(undefined);
const [revoking, setRevoking] = useState(false);
const dismissConfirmation = useCallback(() => setConfirming(undefined), []);
const dismissAdding = useCallback(() => setAdding(false), []);
const refresh = useCallback(async (): Promise<void> => {
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<void> => {
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 (
<div
data-testid="settings-section-displays"
style={{ display: "grid", gap: designToken("spacing.xl") }}
>
<PageHeader
actions={
canAdd ? (
<Button
data-testid="add-display"
onClick={(event) => {
// 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"
>
<ShellIcon name="addDisplay" size={18} />
Add display
</Button>
) : 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 : (
<InlineNotice title="Display paired" tone="success">
<span data-testid="device-pairing-success">
{paired} is paired and can be used by your modules.
</span>
</InlineNotice>
)}
{list.kind === "loading" ? <LoadingState label="Loading displays…" /> : undefined}
{list.kind === "denied" ? (
<EmptyState
description="If you need to pair or revoke displays, ask an administrator."
title="Displays aren't available to you"
/>
) : undefined}
{list.kind === "failed" ? (
<ErrorState
actions={
<Button
onClick={() => {
setList({ kind: "loading" });
void refresh();
}}
>
Retry
</Button>
}
description="Try again in a moment."
title="The displays could not be loaded"
/>
) : undefined}
{list.kind === "ready" ? (
<Panel title="Paired displays">
{list.devices.length === 0 ? (
<div data-testid="device-list-empty">
<EmptyState
description="Choose Add display, then enter the code shown on the screen."
title="No displays are paired yet"
/>
</div>
) : (
<ul className="ek-shell-display-list" data-testid="device-list">
{list.devices.map((device) => (
<DisplayCard
device={device}
key={device.id}
now={now}
onRevoke={() => setConfirming(device)}
/>
))}
</ul>
)}
</Panel>
) : undefined}
<AddDisplayDialog
config={config}
moduleName={props.moduleName}
onDismiss={dismissAdding}
onPaired={onPaired}
open={adding}
/>
{/* 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. */}
<Dialog
actions={
<>
<Button
data-testid="device-revoke-cancel"
disabled={revoking}
onClick={dismissConfirmation}
>
Cancel
</Button>
<Button
data-testid="device-revoke-confirm"
disabled={revoking}
onClick={() => (confirming ? void revoke(confirming.id) : undefined)}
variant="danger"
>
Revoke display
</Button>
</>
}
onDismiss={dismissConfirmation}
open={confirming !== undefined}
title={`Revoke ${confirming?.name ?? "this display"}?`}
>
<p style={{ margin: 0 }}>
The screen stops showing content within a few seconds and returns to its pairing code.
This cannot be undone: to use it again, pair it from the screen with a new code.
</p>
</Dialog>
</div>
);
}
+21 -5
View File
@@ -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 <SettingsHomePage history={history} settings={page.settings} />;
return <SettingsHomePage config={config} history={history} settings={page.settings} />;
case "section":
return <SettingsSectionPage history={history} returnTo={returnTo} section={page.section} />;
return page.section === "displays" ? (
<DisplaysSettingsPage
back={settingsBack(history, returnTo)}
config={config}
moduleName={returnTo.moduleName}
/>
) : (
<SettingsSectionPage history={history} returnTo={returnTo} section={page.section} />
);
case "discover":
return <DiscoverPage history={history} returnTo={returnTo} view={page.view} />;
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 (
<ShellLayout
config={config}
history={history}
mainRef={mainRef}
account={bootstrap.account}
@@ -771,6 +786,7 @@ export function HostApp(props: HostAppProps): ReactElement {
>
{settingsPage !== undefined ? (
<SettingsPageView
config={config}
history={history}
onRetry={retryBootstrap}
page={settingsPage}
+281 -77
View File
@@ -1,14 +1,22 @@
import { designToken } from "@ekklesia/design-tokens";
import { Button, EmptyState, ErrorState, PageHeader, Panel } from "@ekklesia/ui";
import type { ReactElement } from "react";
import { Button, EmptyState, ErrorState, PageHeader } from "@ekklesia/ui";
import { type ReactElement, type ReactNode, useCallback, useEffect, useId, useState } from "react";
import type {
DashboardDestination,
DiscoverView,
SettingsPageSection,
SettingsSection,
} from "../dashboard-routes.js";
import type { DashboardSettings, DashboardSettingsModule } from "../host-protocol.js";
import {
type DashboardSettings,
type DashboardSettingsModule,
type EkklesiaHostConfig,
listHostDevices,
listHostModuleLifecycle,
} from "../host-protocol.js";
import { dashboardBack } from "./dashboard-back.js";
import { DashboardLink } from "./DashboardLink.js";
import { lastSeenPhrase } from "./display-time.js";
import type { HostHistory } from "./host-history.js";
import {
DISCOVER_VIEW_COPY,
@@ -21,9 +29,9 @@ import { ShellIcon, type ShellIconName } from "./shell-icons.js";
/**
* The Dashboard's Settings pages (ADR 0061).
*
* This is the foundation only: the landing page, and a page at every Settings address, each honest
* about what exists. No section's real controls exist yet, so each says so plainly rather than
* showing fake fields; the work that builds a section replaces its placeholder.
* The landing page is an overview of the installation, and Displays is a real page of its own
* (`DisplaysSettingsPage`). Every other section's controls do not exist yet, so each says so plainly
* rather than showing fake fields; the work that builds a section replaces its placeholder.
*/
const BACK_TO_SETTINGS: DashboardDestination = { kind: "settings" };
@@ -41,7 +49,7 @@ export interface SettingsReturn {
/** Back plus title for a page inside Settings (DESIGN.md §4.8). Back goes to Settings — or, when a
* module opened this page, to exactly the page it was opened from, if that is a real Dashboard page.
* Never the browser's own `history.back()`. */
function settingsBack(history: HostHistory, returnTo?: SettingsReturn) {
export function settingsBack(history: HostHistory, returnTo?: SettingsReturn) {
return dashboardBack(history, {
fallback: BACK_TO_SETTINGS,
label: (destination) => {
@@ -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 (
<ul
style={{
display: "grid",
gap: designToken("spacing.xs"),
listStyle: "none",
margin: 0,
padding: 0,
}}
<section
aria-labelledby={headingId}
className={props.compact ? "ek-shell-summary ek-shell-summary--compact" : "ek-shell-summary"}
data-testid={props.testId}
>
{props.items.map((item) => (
<li key={item.key}>
<DashboardLink
className="ek-shell-settings-link"
data-testid={item.testId}
history={props.history}
style={{ minHeight: designToken("control.height-lg") }}
to={item.destination}
>
<ShellIcon name={item.icon} />
<span className="ek-shell-settings-link__copy">
<span className="ek-shell-settings-link__label">{item.label}</span>
{item.description === undefined ? undefined : (
<span className="ek-shell-settings-link__description">{item.description}</span>
)}
</span>
</DashboardLink>
</li>
))}
</ul>
<h2 className="ek-shell-summary__heading" id={headingId}>
<ShellIcon name={props.icon} />
{props.title}
</h2>
<div style={{ display: "grid", gap: designToken("spacing.2xs") }}>
<p className="ek-shell-summary__status" data-testid={`${props.testId}-status`}>
{props.status}
</p>
{props.detail === undefined ? undefined : (
<p className="ek-shell-summary__detail">{props.detail}</p>
)}
</div>
{props.links.length === 0 ? undefined : (
<ul className="ek-shell-summary__links">
{props.links.map((link) => (
<li key={link.key}>
<DashboardLink
className="ek-shell-summary__link"
data-testid={link.testId}
history={props.history}
to={link.to}
variant="text"
>
{link.label}
</DashboardLink>
</li>
))}
</ul>
)}
</section>
);
}
type Loaded<T> =
| { 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<T>(enabled: boolean, load: (signal: AbortSignal) => Promise<T>): Loaded<T> {
const [state, setState] = useState<Loaded<T>>({ 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 (
<SummaryCard
detail={
latest?.lastSeenAt === undefined
? active !== undefined && active.length === 0
? "Pair a screen with the code it shows."
: undefined
: `${latest.name} was last seen ${lastSeenPhrase(latest.lastSeenAt, props.now)}.`
}
history={props.history}
icon="settingsDisplays"
links={[
{
key: "displays",
label: active !== undefined && active.length === 0 ? "Add a display" : "Manage displays",
testId: "settings-home-displays",
to: settingsSectionDestination("displays"),
},
]}
status={
devices.kind === "loading"
? "Checking…"
: devices.kind === "failed"
? "Couldn't check displays"
: active?.length === 0
? "No displays paired yet"
: `${plural(active?.length ?? 0, "display", "displays")} paired`
}
testId="settings-summary-displays"
title={SETTINGS_SECTION_COPY.displays.label}
/>
);
}
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 (
<SummaryCard
detail={
installed === undefined || installed.length === 0
? undefined
: off === 0 && blocked === 0
? "All of them are running."
: [
off === 0 ? undefined : `${off} turned off`,
blocked === 0 ? undefined : `${blocked} can't start`,
]
.filter((part) => 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<Record<"general" | "branding" | "system", string>> = 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 (
<div data-testid="settings-home" style={{ display: "grid", gap: designToken("spacing.xl") }}>
<PageHeader description="How this installation and its modules work." title="Settings" />
<PageHeader
description="The state of this installation, and where to change it."
title="Settings"
/>
{empty ? (
<EmptyState
description="Ask an administrator if you need to change how something works."
title="There are no settings for you to change"
/>
) : undefined}
{props.settings.sections.length > 0 ? (
<Panel title="Platform">
<SettingsLinkList
history={props.history}
items={props.settings.sections.map((section) => ({
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}`,
}))}
/>
</Panel>
{visible("displays") || showModules ? (
<div className="ek-shell-overview">
{visible("displays") ? (
<DisplaysSummary
config={props.config}
history={props.history}
now={props.now?.() ?? new Date()}
/>
) : undefined}
{showModules ? (
<ModulesSummary
config={props.config}
history={props.history}
lifecycle={visible("discover")}
modules={modules}
/>
) : undefined}
</div>
) : undefined}
{modules.length > 0 ? (
<Panel title="Modules">
<SettingsLinkList
history={props.history}
items={modules.map((module) => ({
destination: { kind: "module-settings", moduleId: module.id },
icon: "module",
key: module.id,
label: module.name,
testId: `settings-home-module-${module.id}`,
}))}
/>
</Panel>
{others.length > 0 ? (
<div className="ek-shell-overview ek-shell-overview--compact">
{others.map((section) => (
<SummaryCard
compact
history={props.history}
icon={SETTINGS_SECTION_COPY[section].icon}
key={section}
links={[
{
key: section,
label: `Open ${SETTINGS_SECTION_COPY[section].label}`,
testId: `settings-home-${section}`,
to: settingsSectionDestination(section),
},
]}
status={SECTION_STATE[section]}
testId={`settings-summary-${section}`}
title={SETTINGS_SECTION_COPY[section].label}
/>
))}
</div>
) : undefined}
</div>
);
}
const SECTION_NOT_YET: Readonly<Record<SettingsPageSection, string>> = Object.freeze({
/** The sections that are still placeholders. Displays is a real page (`DisplaysSettingsPage`). */
export type PlaceholderSettingsSection = Exclude<SettingsPageSection, "displays">;
const SECTION_NOT_YET: Readonly<Record<PlaceholderSettingsSection, string>> = 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/<section>`: 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];
+24 -23
View File
@@ -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 : <DevicePanel config={props.config} />}
</div>
<div className="ek-shell-sidebar__bottom">
<AccountMenu
@@ -147,7 +137,6 @@ function Sidebar(props: {
* layout returns focus to the trigger.
*/
function NavigationDrawer(props: {
readonly config: EkklesiaHostConfig;
readonly history: HostHistory;
readonly navigation: DashboardNavigation;
readonly settings: DashboardSettings | "unavailable";
@@ -232,7 +221,6 @@ function NavigationDrawer(props: {
route={props.route}
settings={props.settings}
/>
<DevicePanel config={props.config} />
</div>
<div className="ek-shell-sidebar__bottom">
<AccountMenu
@@ -262,7 +250,9 @@ function routeKey(route: DashboardRoute): string {
* layout — rotating a tablet, resizing a window — never remounts the open module. The route comes
* from the URL (ADR 0057) and the desktop mode from the sidebar preference, so neither is touched by
* a layout change; only an open drawer is dropped, and focus that was inside it lands on the page.
* There is no application top bar: on a phone the one control above the page is the menu trigger.
* There is no application top bar on a wide screen. On a phone a restrained header stays at the top
* while the page scrolls — the menu trigger and the open page's name, below the status bar or notch
* — so the navigation is always one tap away (ADR 0064).
*/
export function ShellLayout(props: ShellLayoutProps): ReactElement {
const { viewport, sidebarPreference, mainRef, route } = props;
@@ -319,7 +309,9 @@ export function ShellLayout(props: ShellLayoutProps): ReactElement {
}
const target = pendingFocus.current === "trigger" ? triggerRef.current : mainRef.current;
pendingFocus.current = undefined;
target?.focus();
// Never scrolls: the trigger sits in the phone's sticky header, which is always in view, and
// the browser would otherwise scroll the page to clear the header's scroll padding.
target?.focus({ preventScroll: true });
}, [drawerOpen, mainRef]);
const showDrawer = compact && drawerOpen;
@@ -327,12 +319,12 @@ export function ShellLayout(props: ShellLayoutProps): ReactElement {
return (
<div
className="ek-shell-root"
data-layout={compact ? "compact" : "wide"}
data-testid="host-shell"
style={{
display: compact ? "block" : "grid",
gridTemplateColumns: compact ? undefined : "auto minmax(0, 1fr)",
minHeight: "100vh",
minWidth: 0,
width: "100%",
}}
@@ -341,7 +333,6 @@ export function ShellLayout(props: ShellLayoutProps): ReactElement {
{compact ? undefined : (
<Sidebar
account={props.account}
config={props.config}
history={props.history}
mode={mode}
navigation={props.navigation}
@@ -353,7 +344,7 @@ export function ShellLayout(props: ShellLayoutProps): ReactElement {
)}
<div style={{ minWidth: 0 }}>
{compact ? (
<div className="ek-shell-mobile-bar" inert={pageInert}>
<header className="ek-shell-mobile-bar" data-testid="mobile-header" inert={pageInert}>
<button
aria-controls={DRAWER_ID}
aria-expanded={showDrawer}
@@ -370,10 +361,22 @@ export function ShellLayout(props: ShellLayoutProps): ReactElement {
>
<ShellIcon name="openNavigation" size={24} />
</button>
</div>
{/* The page's own <h1> 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 : (
<span
aria-hidden="true"
className="ek-shell-mobile-bar__title"
data-testid="mobile-header-title"
>
{props.pageTitle}
</span>
)}
</header>
) : undefined}
<main
aria-label={props.pageTitle}
className="ek-shell-main"
data-testid="host-main-region"
inert={pageInert}
ref={mainRef}
@@ -383,7 +386,6 @@ export function ShellLayout(props: ShellLayoutProps): ReactElement {
// A programmatic focus target after a page change (never in the tab order).
outline: "none",
overflowX: "auto",
padding: designToken("spacing.xl"),
}}
tabIndex={-1}
>
@@ -393,7 +395,6 @@ export function ShellLayout(props: ShellLayoutProps): ReactElement {
{showDrawer ? (
<NavigationDrawer
account={props.account}
config={props.config}
history={props.history}
navigation={props.navigation}
onDismiss={() => closeDrawer("trigger")}
+74 -27
View File
@@ -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 (
<li>
<DashboardLink
className="ek-shell-nav-item"
current={props.current}
dataAttributes={{
"data-nav-ancestor": props.ancestor && !props.current ? "" : undefined,
"data-nav-level": props.child ? "child" : undefined,
}}
data-testid={props.testId}
history={props.history}
{...(props.onNavigate ? { onNavigate: props.onNavigate } : {})}
@@ -87,12 +117,21 @@ function NavigationItem(props: {
)}
<span
className={
props.presentation.collapsed ? "ek-shell-visually-hidden" : "ek-shell-nav-item__label"
presentation.collapsed ? "ek-shell-visually-hidden" : "ek-shell-nav-item__label"
}
data-nav-label=""
>
{props.label}
</span>
{props.disclosure === undefined || presentation.collapsed ? undefined : (
<span
className="ek-shell-nav-item__disclosure"
data-open={props.disclosure === "open" ? "true" : "false"}
data-testid={`${props.testId}-disclosure`}
>
<ShellIcon name="disclosure" size={16} />
</span>
)}
</DashboardLink>
{tooltip}
</li>
@@ -138,8 +177,10 @@ function ModuleItems(props: {
return (
<Fragment key={`${moduleId}@${entry.descriptor.version}`}>
<NavigationItem
ancestor={showPages && openPage !== undefined}
current={resolution !== undefined && (openPage === undefined || !showPages)}
destination={moduleDestination(moduleId)}
{...(entry.pages.length > 0 ? { disclosure: showPages ? "open" : "closed" } : {})}
history={props.history}
icon={<ModuleIcon icon={entry.icon} />}
label={entry.name}
@@ -151,14 +192,15 @@ function ModuleItems(props: {
<li>
<ul
aria-label={entry.name}
className="ek-shell-nav__group ek-shell-nav__nested"
className="ek-shell-nav__group ek-shell-nav__children"
data-testid={`module-subnavigation-${moduleId}`}
// The shared nested indentation (as Settings uses) comes from the class; only the
// list marker is reset here.
// The children's indentation and guide line (as Discover's use) come from the
// class; only the list marker is reset here.
style={{ listStyle: "none" }}
>
{entry.pages.map((page) => (
<NavigationItem
child
current={openPage?.id === page.id}
destination={moduleDestination(moduleId, page.path)}
history={props.history}
@@ -192,9 +234,12 @@ export interface NavigationListProps {
}
/**
* Discover in the Settings navigation: one item that opens Browse, which — while a Discover view is
* open — shows its three views beneath it. The open view carries `aria-current`; Discover itself does
* not, so exactly one item is the current page.
* Discover in the Settings navigation (ADR 0064): one item that opens Discover's own page, where
* modules are found and installed, and — while any Discover page is open — shows Installed and
* Updates beneath it as its children. Exactly one item is the current page: Discover on its own
* page, or the open child, with Discover marked as that child's parent. Collapsed, the rail shows
* Discover alone, current for every Discover page; its children need the labels the rail has no
* room for.
*/
function DiscoverNavigation(props: {
readonly id: string;
@@ -204,42 +249,42 @@ function DiscoverNavigation(props: {
readonly onNavigate?: () => 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 (
<>
<NavigationItem
current={false}
ancestor={showChildren && openView !== "browse"}
current={openView !== undefined && (openView === "browse" || !showChildren)}
destination={settingsSectionDestination("discover")}
disclosure={showChildren ? "open" : "closed"}
history={props.history}
icon={copy.icon}
label={copy.label}
{...onNavigate}
presentation={props.presentation}
presentation={presentation}
testId="select-settings-discover"
/>
{open ? (
{showChildren ? (
<li>
<ul
aria-label={copy.label}
className={
props.presentation.collapsed
? "ek-shell-nav__group"
: "ek-shell-nav__group ek-shell-nav__nested"
}
className="ek-shell-nav__group ek-shell-nav__children"
data-testid="discover-subnavigation"
style={{ listStyle: "none", margin: 0, padding: 0 }}
style={{ listStyle: "none" }}
>
{DISCOVER_VIEWS.map((view) => (
{DISCOVER_CHILD_VIEWS.map((view) => (
<NavigationItem
current={props.route.kind === "discover" && props.route.view === view}
child
current={openView === view}
destination={{ kind: "discover", view }}
history={props.history}
icon={DISCOVER_VIEW_COPY[view].icon}
key={view}
label={DISCOVER_VIEW_COPY[view].label}
label={DISCOVER_CHILD_LABEL[view]}
{...onNavigate}
presentation={props.presentation}
presentation={presentation}
testId={`select-discover-${view}`}
/>
))}
@@ -282,8 +327,10 @@ function SettingsNavigation(props: {
{collapsed ? <div aria-hidden="true" className="ek-shell-nav__divider" /> : undefined}
<ul className="ek-shell-nav__group" style={listStyle}>
<NavigationItem
ancestor={open && route.kind !== "settings"}
current={route.kind === "settings"}
destination={{ kind: "settings" }}
disclosure={open ? "open" : "closed"}
history={props.history}
icon="settings"
label="Settings"
@@ -0,0 +1,30 @@
/**
* How Settings words a display's dates. Coarse on purpose: the server records when a display was last
* in touch at most every few minutes (`DEVICE_LAST_SEEN_THROTTLE_MS`), so "within the last hour" is
* the finest honest answer, and nothing here ever claims a screen is online right now.
*/
const HOUR_MS = 60 * 60_000;
const DAY_MS = 24 * HOUR_MS;
/** "within the last hour", "3 hours ago", "yesterday", "5 days ago", or the date for anything older
* than a week. */
export function lastSeenPhrase(lastSeenAt: Date, now: Date, locale?: string): string {
const elapsed = Math.max(0, now.getTime() - lastSeenAt.getTime());
if (elapsed < HOUR_MS) {
return "within the last hour";
}
const relative = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
if (elapsed < DAY_MS) {
return relative.format(-Math.floor(elapsed / HOUR_MS), "hour");
}
if (elapsed < 7 * DAY_MS) {
return relative.format(-Math.floor(elapsed / DAY_MS), "day");
}
return `on ${calendarDate(lastSeenAt, locale)}`;
}
/** A plain calendar date, such as "1 Oct 2026" or "Oct 1, 2026", in the reader's own locale. */
export function calendarDate(date: Date, locale?: string): string {
return new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(date);
}
+14 -12
View File
@@ -1,5 +1,6 @@
import type {
DashboardDestination,
DiscoverChildView,
DiscoverView,
SettingsPageSection,
SettingsSection,
@@ -46,48 +47,49 @@ export const SETTINGS_SECTION_COPY: Readonly<
});
/**
* Discover's views (ADR 0061). "Discover" is where module lifecycle lives in the navigation; inside
* it the pages speak of modules — "Discover modules", "Installed modules", "Module updates" —
* because Modules is the product's word for them.
* Discover's views (ADR 0061, ADR 0064). "Discover" is where module lifecycle lives in the
* navigation, and its own page — `browse` — is where modules are found and installed; inside it the
* pages speak of modules — "Discover modules", "Installed modules", "Module updates" — because
* Modules is the product's word for them. Only Installed and Updates are named in the navigation,
* beneath Discover; Discover's own page is Discover's item itself.
*/
export const DISCOVER_VIEW_COPY: Readonly<
Record<
DiscoverView,
{
readonly label: string;
readonly title: string;
readonly description: string;
readonly notYet: string;
readonly icon: ShellIconName;
}
>
> = Object.freeze({
browse: {
description:
"Modules add capabilities to your church's Dashboard. Install and manage the modules your church uses.",
icon: "discoverBrowse",
label: "Browse",
notYet: "Finding and installing modules will happen here.",
title: "Discover modules",
},
installed: {
description: "The modules installed for your church.",
icon: "discoverInstalled",
label: "Installed",
notYet:
"Seeing your installed modules, turning them on or off, inspecting them, and removing them will happen here.",
title: "Installed modules",
},
updates: {
description: "Updates for your modules and for this installation.",
icon: "discoverUpdates",
label: "Updates",
notYet: "Checking for and applying module and platform updates will happen here.",
title: "Module updates",
},
});
/** Where a platform section's navigation item leads: its page, or — for Discover — Browse. */
/** How Discover's children are named beneath it in the navigation. */
export const DISCOVER_CHILD_LABEL: Readonly<Record<DiscoverChildView, string>> = Object.freeze({
installed: "Installed",
updates: "Updates",
});
/** Where a platform section's navigation item leads: its page — for Discover, Discover's own page,
* `/settings/discover`. */
export function settingsSectionDestination(section: SettingsSection): DashboardDestination {
return section === "discover"
? { kind: "discover", view: "browse" }
+10 -7
View File
@@ -1,5 +1,6 @@
import {
Blocks,
ChevronRight,
CircleUserRound,
Compass,
LayoutDashboard,
@@ -8,11 +9,9 @@ import {
Menu,
MonitorSmartphone,
Palette,
PackageCheck,
PanelLeftClose,
PanelLeftOpen,
RefreshCw,
Search,
Plus,
Server,
Settings,
SlidersHorizontal,
@@ -35,12 +34,13 @@ import type { ReactElement } from "react";
*/
export const SHELL_ICONS = Object.freeze({
account: CircleUserRound,
addDisplay: Plus,
appearance: SunMoon,
closeNavigation: X,
collapseSidebar: PanelLeftClose,
discoverBrowse: Search,
discoverInstalled: PackageCheck,
discoverUpdates: RefreshCw,
/** A navigation item that opens in place into the pages beneath it: points right while closed,
* down while open. */
disclosure: ChevronRight,
expandSidebar: PanelLeftOpen,
module: Blocks,
openNavigation: Menu,
@@ -56,6 +56,9 @@ export const SHELL_ICONS = Object.freeze({
export type ShellIconName = keyof typeof SHELL_ICONS;
/** The size every navigation icon is drawn at, which the stylesheet lines nested items up against. */
export const SHELL_ICON_SIZE = 20;
/**
* A decorative icon. Always hidden from assistive technology: every control that shows one also
* has a text name, visible or not, so an icon never carries meaning on its own. The stroke follows
@@ -71,7 +74,7 @@ export function ShellIcon(props: {
aria-hidden="true"
data-icon={props.name}
focusable="false"
size={props.size ?? 20}
size={props.size ?? SHELL_ICON_SIZE}
strokeWidth={1.75}
/>
);
+284 -7
View File
@@ -1,4 +1,8 @@
import { designToken } from "@ekklesia/design-tokens";
import { SHELL_ICON_SIZE } from "./shell-icons.js";
/** The phone header's own height: one large control plus a little room above and below it. */
const MOBILE_BAR_HEIGHT = `calc(${designToken("control.height-lg")} + 2 * ${designToken("spacing.xs")})`;
/**
* The Dashboard shell's own stylesheet (ADR 0058): what inline styles cannot express — hover and
@@ -23,6 +27,28 @@ export const SHELL_STYLESHEET = `
width: 1px;
}
/* The authenticated shell's frame. dvh where the browser has it, so a phone's collapsing browser
chrome never leaves the page a toolbar's height too tall. */
.ek-shell-root {
min-height: 100vh;
min-height: 100dvh;
}
/* The page's own region. A phone gets a slimmer margin than a desktop, and on a device with a notch
or a home indicator the page's edges keep clear of them. */
.ek-shell-main {
padding: ${designToken("spacing.xl")};
padding-bottom: calc(${designToken("spacing.xl")} + env(safe-area-inset-bottom, 0px));
padding-right: calc(${designToken("spacing.xl")} + env(safe-area-inset-right, 0px));
}
.ek-shell-root[data-layout="compact"] .ek-shell-main {
padding: ${designToken("spacing.lg")};
padding-bottom: calc(${designToken("spacing.lg")} + env(safe-area-inset-bottom, 0px));
padding-left: calc(${designToken("spacing.lg")} + env(safe-area-inset-left, 0px));
padding-right: calc(${designToken("spacing.lg")} + env(safe-area-inset-right, 0px));
}
.ek-shell-sidebar {
background: ${designToken("background.canvas")};
border-right: ${designToken("border.width")} solid ${designToken("border.divider")};
@@ -33,6 +59,10 @@ export const SHELL_STYLESHEET = `
height: 100vh;
height: 100dvh;
padding: ${designToken("spacing.md")};
/* A landscape phone wide enough for the sidebar puts its left edge beside the notch, and its
account area above the home indicator. */
padding-bottom: calc(${designToken("spacing.md")} + env(safe-area-inset-bottom, 0px));
padding-left: calc(${designToken("spacing.md")} + env(safe-area-inset-left, 0px));
position: sticky;
top: 0;
transition: width ${designToken("motion.duration-short")} ease;
@@ -59,6 +89,17 @@ export const SHELL_STYLESHEET = `
overflow-y: auto;
}
/* The rail is exactly as wide as its items. A scrollbar would take its width from one side and push
every icon off centre, so the rail scrolls without one (wheel, touch, and keyboard focus still
scroll it). */
.ek-shell-sidebar[data-sidebar="collapsed"] .ek-shell-sidebar__scroll {
scrollbar-width: none;
}
.ek-shell-sidebar[data-sidebar="collapsed"] .ek-shell-sidebar__scroll::-webkit-scrollbar {
display: none;
}
.ek-shell-sidebar__bottom {
border-top: ${designToken("border.width")} solid ${designToken("border.divider")};
padding-top: ${designToken("spacing.sm")};
@@ -114,11 +155,49 @@ export const SHELL_STYLESHEET = `
padding-inline-start: ${designToken("spacing.xs")};
}
/* A parent's children — a module's pages, Discover's Installed and Updates (ADR 0064). The guide
line runs down from beneath the parent's icon, and the children's text starts past the parent's
label, so the relationship reads at a glance. */
.ek-shell-nav__children {
border-inline-start: ${designToken("border.width")} solid ${designToken("border.divider")};
display: grid;
gap: ${designToken("spacing.2xs")};
margin: ${designToken("spacing.2xs")} 0 ${designToken("spacing.xs")}
calc(${designToken("control.padding-inline")} + ${SHELL_ICON_SIZE / 2}px);
padding: 0 0 0 ${designToken("spacing.lg")};
}
.ek-shell-nav__divider {
border-top: ${designToken("border.width")} solid ${designToken("border.divider")};
margin: 0 ${designToken("spacing.xs")};
}
/* A navigation link (DashboardLink). Its layout and open-page look live here, never inline, so
the collapsed rail below can override them (ADR 0064). */
.ek-shell-link {
justify-content: flex-start;
text-decoration: none;
width: 100%;
}
.ek-shell-link[aria-current="page"],
.ek-shell-link[aria-current="page"]:hover:not(:disabled) {
background: ${designToken("state.selected-background")};
color: ${designToken("state.selected-foreground")};
font-weight: ${designToken("typography.weight-bold")};
}
/* The parent of the open page: named in the primary colour, its icon in the accent, and no fill —
the fill belongs to the open page alone. */
.ek-shell-link[data-nav-ancestor] {
color: ${designToken("text.primary")};
font-weight: ${designToken("typography.weight-bold")};
}
.ek-shell-link[data-nav-ancestor] > svg {
color: ${designToken("accent.strong")};
}
.ek-shell-nav-item {
box-sizing: border-box;
gap: ${designToken("spacing.sm")};
@@ -131,16 +210,40 @@ export const SHELL_STYLESHEET = `
flex: 0 0 auto;
}
.ek-shell-nav-item[data-nav-level="child"]:not([aria-current="page"]) {
font-weight: ${designToken("typography.weight-regular")};
}
.ek-shell-nav-item__label {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ek-shell-nav-item__disclosure {
color: ${designToken("text.muted")};
display: inline-flex;
flex: 0 0 auto;
transition: transform ${designToken("motion.duration-short")} ease;
}
.ek-shell-nav-item__disclosure[data-open="true"] {
transform: rotate(90deg);
}
/* Collapsed, every item is the same square, centred on the rail, with its glyph centred in it —
whatever its level, and whatever the expanded layout's padding, gap, or alignment would say. */
.ek-shell-sidebar[data-sidebar="collapsed"] .ek-shell-nav-item {
/* A block-level flex box, so the auto margins centre it on the rail. */
display: flex;
gap: 0;
height: ${designToken("control.height-md")};
justify-content: center;
padding-inline: 0;
margin-inline: auto;
padding: 0;
width: ${designToken("control.height-md")};
}
.ek-shell-settings-link {
@@ -183,6 +286,142 @@ export const SHELL_STYLESHEET = `
font-size: ${designToken("typography.size-sm")};
}
/* Settings → Displays: one card per paired screen, wrapping onto two lines on a phone. */
.ek-shell-display-list {
display: grid;
gap: ${designToken("spacing.sm")};
list-style: none;
margin: 0;
padding: 0;
}
.ek-shell-display-card {
align-items: center;
border: ${designToken("border.width")} solid ${designToken("border.base")};
border-radius: ${designToken("radius.md")};
box-sizing: border-box;
display: flex;
flex-wrap: wrap;
gap: ${designToken("spacing.sm")} ${designToken("spacing.md")};
padding: ${designToken("spacing.md")};
}
.ek-shell-display-card__icon {
align-items: center;
background: ${designToken("background.inset")};
border-radius: ${designToken("radius.md")};
color: ${designToken("text.secondary")};
display: inline-flex;
flex: 0 0 auto;
height: ${designToken("control.height-md")};
justify-content: center;
width: ${designToken("control.height-md")};
}
.ek-shell-display-card__copy {
display: grid;
flex: 1 1 12rem;
gap: ${designToken("spacing.2xs")};
min-width: 0;
}
.ek-shell-display-card__title {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: ${designToken("spacing.sm")};
}
.ek-shell-display-card__name {
color: ${designToken("text.primary")};
font-weight: ${designToken("typography.weight-bold")};
overflow-wrap: anywhere;
}
.ek-shell-display-card__facts {
color: ${designToken("text.secondary")};
font-size: ${designToken("typography.size-sm")};
margin: 0;
}
/* The Settings overview (ADR 0064): what state the installation is in, a card per area. */
.ek-shell-overview {
display: grid;
gap: ${designToken("spacing.md")};
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
}
.ek-shell-overview--compact {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
}
.ek-shell-summary {
background: ${designToken("background.surface")};
border: ${designToken("border.width")} solid ${designToken("border.base")};
border-radius: ${designToken("radius.lg")};
box-shadow: ${designToken("shadow.sm")};
box-sizing: border-box;
display: grid;
gap: ${designToken("spacing.sm")};
grid-template-rows: auto 1fr auto;
min-width: 0;
padding: ${designToken("spacing.lg")};
}
.ek-shell-summary__heading {
align-items: center;
color: ${designToken("text.secondary")};
display: flex;
font-size: ${designToken("typography.size-sm")};
font-weight: ${designToken("typography.weight-medium")};
gap: ${designToken("spacing.sm")};
margin: 0;
}
.ek-shell-summary__status {
color: ${designToken("text.primary")};
font-size: ${designToken("typography.size-lg")};
font-weight: ${designToken("typography.weight-bold")};
margin: 0;
}
.ek-shell-summary--compact .ek-shell-summary__status {
font-size: ${designToken("typography.size-md")};
font-weight: ${designToken("typography.weight-medium")};
}
.ek-shell-summary__detail {
color: ${designToken("text.secondary")};
font-size: ${designToken("typography.size-sm")};
margin: 0;
}
.ek-shell-summary__links {
display: flex;
flex-wrap: wrap;
gap: ${designToken("spacing.xs")} ${designToken("spacing.md")};
list-style: none;
margin: 0;
padding: 0;
}
.ek-shell-summary__link {
border-radius: ${designToken("radius.sm")};
color: ${designToken("accent.strong")};
font-size: ${designToken("typography.size-sm")};
font-weight: ${designToken("typography.weight-medium")};
text-decoration: none;
}
.ek-shell-summary__link:hover {
text-decoration: underline;
}
.ek-shell-summary__link:focus-visible {
outline: ${designToken("state.focus-visible-width")} solid ${designToken("accent.focus")};
outline-offset: ${designToken("state.focus-visible-offset")};
}
.ek-shell-tooltip {
background: ${designToken("background.raised")};
border: ${designToken("border.width")} solid ${designToken("border.base")};
@@ -314,12 +553,16 @@ export const SHELL_STYLESHEET = `
color: ${designToken("state.selected-foreground")};
}
.ek-shell-choice__input {
accent-color: ${designToken("accent.base")};
flex: 0 0 auto;
height: 1.25rem;
/* The radio itself is @ekklesia/ui's shared indicator (ek-radio__input), larger here because the
whole row is the target and the radio is its one visual anchor. */
.ek-shell-choice .ek-shell-choice__input {
--ek-choice-size: 1.25rem;
margin: 0.125rem 0 0;
width: 1.25rem;
}
/* The whole row carries the focus ring (above), so the radio inside it draws none of its own. */
.ek-shell-choice .ek-shell-choice__input:focus-visible {
outline: none;
}
.ek-shell-choice__copy {
@@ -350,8 +593,39 @@ export const SHELL_STYLESHEET = `
font-size: ${designToken("typography.size-xs")};
}
/* The phone header (ADR 0064): the menu trigger and the open page's name, kept at the top while the
page scrolls, below the status bar or notch (viewport-fit=cover). Not a desktop top bar: it
holds nothing else. */
.ek-shell-mobile-bar {
padding: ${designToken("spacing.sm")} ${designToken("spacing.md")} 0;
align-items: center;
background: ${designToken("background.canvas")};
border-bottom: ${designToken("border.width")} solid ${designToken("border.divider")};
box-sizing: border-box;
display: flex;
gap: ${designToken("spacing.sm")};
min-height: calc(${MOBILE_BAR_HEIGHT} + env(safe-area-inset-top, 0px));
padding: calc(${designToken("spacing.xs")} + env(safe-area-inset-top, 0px))
calc(${designToken("spacing.sm")} + env(safe-area-inset-right, 0px))
${designToken("spacing.xs")}
calc(${designToken("spacing.sm")} + env(safe-area-inset-left, 0px));
position: sticky;
top: 0;
z-index: 10;
}
.ek-shell-mobile-bar__title {
color: ${designToken("text.primary")};
font-size: ${designToken("typography.size-md")};
font-weight: ${designToken("typography.weight-medium")};
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Something scrolled into view — a focused field, a fragment — lands below the header, not under it. */
html:has(.ek-shell-mobile-bar) {
scroll-padding-top: calc(${MOBILE_BAR_HEIGHT} + env(safe-area-inset-top, 0px) + ${designToken("spacing.sm")});
}
.ek-shell-drawer-backdrop {
@@ -372,6 +646,9 @@ export const SHELL_STYLESHEET = `
max-width: 85vw;
outline: none;
padding: ${designToken("spacing.md")};
padding-bottom: calc(${designToken("spacing.md")} + env(safe-area-inset-bottom, 0px));
padding-left: calc(${designToken("spacing.md")} + env(safe-area-inset-left, 0px));
padding-top: calc(${designToken("spacing.md")} + env(safe-area-inset-top, 0px));
position: fixed;
width: 20rem;
z-index: 41;
+34 -15
View File
@@ -11,7 +11,8 @@
* / Overview
* /settings Settings
* /settings/<section> a platform Settings section: general, branding, displays, system
* /settings/discover/<view> finding, installing, and managing modules: browse, installed, updates
* /settings/discover Discover itself: finding and installing modules (ADR 0064)
* /settings/discover/<view> Discover's other views: installed, updates
* /settings/<module-id> a module's settings (and later its own deeper settings pages)
* /account your account
* /account/<section> a personal settings page: appearance
@@ -71,14 +72,27 @@ export const ACCOUNT_SECTIONS = Object.freeze(["appearance"] as const);
export type AccountSection = (typeof ACCOUNT_SECTIONS)[number];
/**
* Discover's views, in navigation order: Browse (find and install modules), Installed (see and
* manage installed modules), and Updates (module and platform updates). Browse is where Discover
* opens. They live beneath `/settings/discover/`, so none can collide with a module id.
* Discover's views (ADR 0064). `browse` — finding and installing modules — is Discover's own page,
* at `/settings/discover` itself; it is never a child of Discover, so it is never listed beneath it.
* Installed (see and manage installed modules) and Updates (module and platform updates) are
* Discover's children, in navigation order, beneath `/settings/discover/`, so none can collide with
* a module id.
*/
export const DISCOVER_VIEWS = Object.freeze(["browse", "installed", "updates"] as const);
export type DiscoverView = (typeof DISCOVER_VIEWS)[number];
/** Discover's children: the views shown beneath it in the navigation, in order. */
export const DISCOVER_CHILD_VIEWS = Object.freeze(["installed", "updates"] as const);
export type DiscoverChildView = (typeof DISCOVER_CHILD_VIEWS)[number];
/**
* The earlier spelling of Discover's own page (ADR 0061). Still read, so a bookmark keeps working,
* and respelled in place to `/settings/discover`; never linked to.
*/
export const DISCOVER_BROWSE_ALIAS = "browse";
/** What a Dashboard path means. A path the shell does not understand is `not-found`, never a guess. */
export type DashboardRoute =
| { readonly kind: "overview" }
@@ -101,15 +115,15 @@ const platformPages: ReadonlySet<string> = new Set(DASHBOARD_PLATFORM_PAGES);
const hostNamespaces: ReadonlySet<string> = new Set(HOST_NAMESPACES);
const settingsSections: ReadonlySet<string> = new Set(SETTINGS_SECTIONS);
const discoverViews: ReadonlySet<string> = new Set(DISCOVER_VIEWS);
const discoverChildViews: ReadonlySet<string> = new Set(DISCOVER_CHILD_VIEWS);
const accountSections: ReadonlySet<string> = new Set(ACCOUNT_SECTIONS);
function isSettingsPageSection(value: string): value is SettingsPageSection {
return value !== "discover" && settingsSections.has(value);
}
function isDiscoverView(value: string): value is DiscoverView {
return discoverViews.has(value);
function isDiscoverChildView(value: string): value is DiscoverChildView {
return discoverChildViews.has(value);
}
function isAccountSection(value: string): value is AccountSection {
@@ -204,10 +218,11 @@ export function parseDashboardPath(pathname: string): DashboardRoute {
/**
* Reads what follows `/settings`. One more segment is a platform section or, failing that, a
* module's settings. `discover` is entered at Browse: `/settings/discover` reads as Browse, whose
* canonical spelling is `/settings/discover/browse`, so the shell respells it in place and there is
* one Browse address rather than two. Anything deeper is reserved for later Settings pages and is
* `not-found` until they exist, rather than silently showing their parent.
* module's settings. `/settings/discover` is Discover's own page, where modules are found and
* installed (ADR 0064); `/settings/discover/browse`, its earlier spelling, reads as the same page and
* the shell respells it in place, so there is one address for it rather than two. Anything deeper is
* reserved for later Settings pages and is `not-found` until they exist, rather than silently showing
* their parent.
*/
function parseSettingsPath(rest: readonly string[]): DashboardRoute {
if (rest.length === 0) {
@@ -222,9 +237,13 @@ function parseSettingsPath(rest: readonly string[]): DashboardRoute {
return { kind: "discover", view: "browse" };
}
const view = rest[1] === undefined ? undefined : decodeSegment(rest[1]);
return rest.length === 2 && view !== undefined && isDiscoverView(view)
? { kind: "discover", view }
: { kind: "not-found" };
if (rest.length !== 2 || view === undefined) {
return { kind: "not-found" };
}
if (view === DISCOVER_BROWSE_ALIAS) {
return { kind: "discover", view: "browse" };
}
return isDiscoverChildView(view) ? { kind: "discover", view } : { kind: "not-found" };
}
if (rest.length !== 1) {
return { kind: "not-found" };
@@ -261,7 +280,7 @@ export function dashboardPath(route: DashboardDestination): string {
case "settings-section":
return `/settings/${route.section}`;
case "discover":
return `/settings/discover/${route.view}`;
return route.view === "browse" ? "/settings/discover" : `/settings/discover/${route.view}`;
case "module-settings":
return `/settings/${encodeURIComponent(route.moduleId)}`;
case "account":
+63 -2
View File
@@ -502,13 +502,26 @@ export async function pollDevicePairing(
return status;
}
/** One paired display, as the administrator's panel shows it. Deliberately no credential, no hash,
* and no pairing history — none of that is anything an operator can act on. */
/** One paired display, as Settings → Displays shows it. Deliberately no credential, no hash, and no
* pairing history — none of that is anything an operator can act on. `lastSeenAt` is coarse by
* design (the server records it at most every few minutes), so it says when a screen was last in
* touch, never that it is online right now. */
export interface HostDeviceSummary {
readonly id: string;
readonly name: string;
readonly permissions: readonly string[];
readonly revoked: boolean;
readonly pairedAt: Date | undefined;
readonly lastSeenAt: Date | undefined;
}
/** An optional ISO timestamp from the server: a `Date`, or `undefined` when absent or unreadable. */
function optionalTimestamp(value: unknown): Date | undefined {
if (typeof value !== "string") {
return undefined;
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? undefined : date;
}
export async function lookupDevicePairing(
@@ -574,6 +587,8 @@ export async function listHostDevices(
name,
permissions: Object.freeze(permissions.map((permission) => String(permission))),
revoked: (entry as { revoked?: unknown }).revoked === true,
pairedAt: optionalTimestamp((entry as { createdAt?: unknown }).createdAt),
lastSeenAt: optionalTimestamp((entry as { lastSeenAt?: unknown }).lastSeenAt),
});
}),
);
@@ -607,6 +622,52 @@ export async function revokeHostDevice(
}
}
/** One installed module as module lifecycle reports it (ADR 0028): what it is and whether it runs. */
export interface HostModuleLifecycleSummary {
readonly id: string;
readonly version: string;
readonly state: "running" | "disabled" | "blocked";
}
const MODULE_RUNTIME_STATES = new Set(["running", "disabled", "blocked"]);
/**
* The installed modules and whether each is running, from the existing lifecycle resource
* (`GET /api/v1/module-lifecycle`). It requires `core.modules.lifecycle.manage` — the same permission
* that shows Discover — so a person who may not see Discover is refused, never shown a partial list.
*/
export async function listHostModuleLifecycle(
config: EkklesiaHostConfig,
signal?: AbortSignal,
): Promise<readonly HostModuleLifecycleSummary[]> {
const payload = await requestPlatformJson(config, "/api/v1/module-lifecycle", signal, {
forbidden: "HOST_ACCESS_DENIED",
});
const modules =
typeof payload === "object" && payload !== null && "modules" in payload
? (payload as { modules: unknown }).modules
: undefined;
if (!Array.isArray(modules)) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze(
modules.map((module) => {
const id = nonEmptyString((module as { id?: unknown }).id);
const version = nonEmptyString((module as { version?: unknown }).version);
const state = (module as { runtime?: { state?: unknown } }).runtime?.state;
if (
id === undefined ||
version === undefined ||
typeof state !== "string" ||
!MODULE_RUNTIME_STATES.has(state)
) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze({ id, state: state as HostModuleLifecycleSummary["state"], version });
}),
);
}
/** Reads one exact `/display/<module-id>` pathname without accepting a trailing path or traversal. */
export function parseDisplayRoute(pathname: string): string {
const match = /^\/display\/([^/]+)$/u.exec(pathname);
+22 -6
View File
@@ -12,6 +12,7 @@ import {
returnTargetFromSearch,
dashboardPath,
parseDashboardPath,
DISCOVER_CHILD_VIEWS,
DISCOVER_VIEWS,
SETTINGS_SECTIONS,
} from "../src/dashboard-routes.js";
@@ -134,26 +135,41 @@ describe("Settings routes (ADR 0061)", () => {
}
});
it("reads Discover's three views, entering Discover at Browse, its one canonical address", () => {
for (const view of DISCOVER_VIEWS) {
it("reads Discover's own page at /settings/discover, and Installed and Updates beneath it", () => {
expect(DISCOVER_CHILD_VIEWS).toEqual(["installed", "updates"]);
// Discover itself is where modules are found and installed (ADR 0064): one canonical address.
expect(parseDashboardPath("/settings/discover")).toEqual({ kind: "discover", view: "browse" });
expect(parseDashboardPath("/settings/discover/")).toEqual({ kind: "discover", view: "browse" });
expect(dashboardPath({ kind: "discover", view: "browse" })).toBe("/settings/discover");
for (const view of DISCOVER_CHILD_VIEWS) {
expect(parseDashboardPath(`/settings/discover/${view}`), view).toEqual({
kind: "discover",
view,
});
expect(dashboardPath({ kind: "discover", view }), view).toBe(`/settings/discover/${view}`);
}
// `/settings/discover` means Browse, and the shell respells it `/settings/discover/browse` in
// place, so there is one meaningful Browse address rather than two.
expect(parseDashboardPath("/settings/discover")).toEqual({ kind: "discover", view: "browse" });
expect(dashboardPath({ kind: "discover", view: "browse" })).toBe("/settings/discover/browse");
for (const pathname of [
"/settings/discover/catalog",
"/settings/discover/browse/signage",
"/settings/discover/installed/signage",
"/settings/discover//",
]) {
expect(parseDashboardPath(pathname), pathname).toEqual({ kind: "not-found" });
}
});
it("still reads the earlier /settings/discover/browse, only as an alias the shell respells", () => {
// A bookmark from before ADR 0064 keeps working: it reads as Discover's own page, whose one
// canonical spelling is `/settings/discover`, so the shell replaces it in place.
expect(parseDashboardPath("/settings/discover/browse")).toEqual({
kind: "discover",
view: "browse",
});
expect(dashboardPath(parseDashboardPath("/settings/discover/browse") as never)).toBe(
"/settings/discover",
);
});
it("reads every other name as a module's settings — never under /settings/modules/", () => {
expect(parseDashboardPath("/settings/signage")).toEqual({
kind: "module-settings",
-106
View File
@@ -1,106 +0,0 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DevicePanel } from "../src/browser/DevicePanel.js";
declare global {
// eslint-disable-next-line no-var
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
}
const apiBaseUrl = "https://ekklesia.example.test";
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
headers: { "content-type": "application/json" },
status,
});
}
function server() {
let revoked = false;
const revokes: string[] = [];
const fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/v1/devices")) {
return json({
devices: [{ id: "device-1", name: "Foyer TV", permissions: [], revoked }],
});
}
if (url.endsWith("/api/v1/devices/device-1/revoke")) {
revoked = true;
revokes.push(url);
return new Response(null, { status: 204 });
}
return json({ code: "NOT_FOUND" }, 404);
}) as unknown as typeof globalThis.fetch;
return { fetch, revokes };
}
function query(testId: string): HTMLElement | null {
return container.querySelector(`[data-testid="${testId}"]`);
}
async function click(testId: string): Promise<void> {
await act(async () => {
query(testId)?.click();
for (let index = 0; index < 5; index += 1) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
});
}
/**
* Revoking a display strands that screen until someone re-pairs it in person, so the panel explains
* the consequence in its own dialog first — never with a browser-native confirm (DESIGN §21).
*/
describe("display device revocation", () => {
it("asks in a product dialog before revoking, and does nothing on cancel", async () => {
const platform = server();
const confirm = vi.fn(() => true);
Object.assign(window, { confirm });
await act(async () => {
root.render(<DevicePanel config={{ apiBaseUrl, fetch: platform.fetch }} />);
});
await click("device-revoke-device-1");
const dialog = container.querySelector('[role="dialog"]');
expect(dialog?.textContent).toContain("Revoke Foyer TV?");
expect(dialog?.textContent).toContain("pairing code");
expect(platform.revokes).toEqual([]);
await click("device-revoke-cancel");
expect(container.querySelector('[role="dialog"]')).toBeNull();
expect(platform.revokes).toEqual([]);
expect(confirm).not.toHaveBeenCalled();
});
it("revokes only after the destructive action is confirmed", async () => {
const platform = server();
await act(async () => {
root.render(<DevicePanel config={{ apiBaseUrl, fetch: platform.fetch }} />);
});
await click("device-revoke-device-1");
await click("device-revoke-confirm");
expect(platform.revokes).toHaveLength(1);
expect(container.querySelector('[role="dialog"]')).toBeNull();
expect(query("device-device-1")?.textContent).toContain("(revoked)");
});
});
@@ -0,0 +1,256 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DisplaysSettingsPage } from "../src/browser/DisplaysSettingsPage.js";
declare global {
// eslint-disable-next-line no-var
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
}
/**
* Settings → Displays (ADR 0064): the device surface that used to sit in the sidebar, moved into
* Settings — the paired displays, a deliberate Add display flow, and revocation in a product dialog.
*/
const apiBaseUrl = "https://ekklesia.example.test";
const NOW = new Date("2026-10-15T12:00:00.000Z");
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
headers: { "content-type": "application/json" },
status,
});
}
interface ServerOptions {
readonly devices?: unknown[];
readonly denied?: boolean;
}
function server(options: ServerOptions = {}) {
let devices: unknown[] = options.devices ?? [
{
createdAt: "2026-10-01T09:00:00.000Z",
id: "device-1",
lastSeenAt: "2026-10-15T11:50:00.000Z",
name: "Foyer TV",
permissions: [],
revoked: false,
},
];
const requests: { path: string; body: unknown }[] = [];
const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const path = new URL(String(input)).pathname;
const body = init?.body === undefined ? undefined : JSON.parse(String(init.body));
requests.push({ body, path });
if (path === "/api/v1/devices") {
return options.denied ? json({ code: "AUTHORIZATION_DENIED" }, 403) : json({ devices });
}
if (path === "/api/v1/devices/device-1/revoke") {
devices = devices.map((device) => ({ ...(device as object), revoked: true }));
return new Response(null, { status: 204 });
}
if (path === "/api/v1/device-pairing/lookups") {
return (body as { code: string }).code === "123456"
? json({
expiresAt: "2026-10-15T12:10:00.000Z",
moduleId: "signage",
permissionId: "signage.display",
requestedAt: "2026-10-15T11:59:00.000Z",
})
: json({ code: "DEVICE_PAIRING_NOT_FOUND" }, 404);
}
if (path === "/api/v1/device-pairing/approvals") {
devices = [
...devices,
{
createdAt: "2026-10-15T12:00:00.000Z",
id: "device-2",
name: (body as { deviceName: string }).deviceName,
permissions: [],
revoked: false,
},
];
return json({ moduleId: "signage", permissionId: "signage.display" });
}
return json({ code: "NOT_FOUND" }, 404);
}) as unknown as typeof globalThis.fetch;
return { fetch, requests };
}
function query(testId: string): HTMLElement | null {
return container.querySelector(`[data-testid="${testId}"]`);
}
function required(testId: string): HTMLElement {
const element = query(testId);
if (!element) {
throw new Error(`missing ${testId}`);
}
return element;
}
async function settle(): Promise<void> {
for (let index = 0; index < 6; index += 1) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
async function click(testId: string): Promise<void> {
await act(async () => {
required(testId).click();
await settle();
});
}
async function type(testId: string, value: string): Promise<void> {
const input = required(testId) as HTMLInputElement;
await act(async () => {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
}
async function submit(testId: string): Promise<void> {
const form = (required(testId) as HTMLButtonElement).form;
await act(async () => {
form?.requestSubmit();
await settle();
});
}
async function renderPage(options: ServerOptions = {}) {
const platform = server(options);
await act(async () => {
root.render(
<DisplaysSettingsPage
back={{ href: "/settings", label: "Back to Settings", onBack: () => undefined }}
config={{ apiBaseUrl, fetch: platform.fetch }}
moduleName={(moduleId) => (moduleId === "signage" ? "Signage" : undefined)}
now={() => NOW}
/>,
);
await settle();
});
return platform;
}
describe("Settings → Displays", () => {
it("lists the paired displays with what is known about each, and takes no focus", async () => {
const before = document.activeElement;
await renderPage();
const card = required("device-device-1");
expect(card.textContent).toContain("Foyer TV");
expect(card.textContent).toContain("Paired");
expect(card.textContent).toContain("Last seen within the last hour");
expect(container.querySelector("h1")?.textContent).toBe("Displays");
// No pairing form until someone asks for one, and nothing grabbed focus.
expect(container.querySelector("input")).toBeNull();
expect(document.activeElement).toBe(before);
});
it("says when nothing is paired yet, and when the list is not this person's to see", async () => {
await renderPage({ devices: [] });
expect(required("device-list-empty").textContent).toContain("No displays are paired yet");
await act(async () => root.unmount());
root = createRoot(container);
await renderPage({ denied: true });
expect(container.textContent).toContain("Displays aren't available to you");
expect(query("add-display")).toBeNull();
});
it("pairs a display through a deliberate Add display flow, focusing each next step", async () => {
const platform = await renderPage();
await click("add-display");
const dialog = required("add-display-dialog");
expect(dialog.getAttribute("role")).toBe("dialog");
// Typing the code is now plainly the next thing to do, so the field has focus.
expect(document.activeElement).toBe(required("device-pairing-code-input"));
await type("device-pairing-code-input", "999999");
await submit("device-pairing-lookup");
expect(required("device-pairing-error").textContent).toContain("not waiting for approval");
await type("device-pairing-code-input", "123456");
await submit("device-pairing-lookup");
expect(required("device-pairing-found").textContent).toContain("Signage");
expect(document.activeElement).toBe(required("device-pairing-name"));
await type("device-pairing-name", "Sanctuary left");
await submit("device-pairing-approve");
expect(query("add-display-dialog")).toBeNull();
expect(
platform.requests.find((r) => r.path === "/api/v1/device-pairing/approvals")?.body,
).toEqual({ code: "123456", deviceName: "Sanctuary left" });
// The new display appears without a page refresh, and the page says so.
expect(required("device-device-2").textContent).toContain("Sanctuary left");
expect(required("device-pairing-success").textContent).toContain("Sanctuary left is paired");
// Focus is back on the control that opened the flow.
expect(document.activeElement).toBe(required("add-display"));
});
it("closes the Add display flow on Cancel without pairing anything", async () => {
const platform = await renderPage();
await click("add-display");
await click("add-display-cancel");
expect(query("add-display-dialog")).toBeNull();
expect(platform.requests.map((r) => r.path)).toEqual(["/api/v1/devices"]);
expect(document.activeElement).toBe(required("add-display"));
});
});
/**
* 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).
*/
describe("display revocation", () => {
it("asks in a product dialog before revoking, 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?");
expect(dialog?.textContent).toContain("pairing code");
await click("device-revoke-cancel");
expect(container.querySelector('[role="dialog"]')).toBeNull();
expect(platform.requests.some((r) => r.path.endsWith("/revoke"))).toBe(false);
expect(confirm).not.toHaveBeenCalled();
});
it("revokes only after the destructive action is confirmed", async () => {
const platform = await renderPage();
await click("device-revoke-device-1");
await click("device-revoke-confirm");
expect(platform.requests.filter((r) => r.path.endsWith("/revoke"))).toHaveLength(1);
expect(container.querySelector('[role="dialog"]')).toBeNull();
expect(required("device-device-1").textContent).toContain("Revoked");
expect(query("device-revoke-device-1")).toBeNull();
});
});
+2 -2
View File
@@ -487,8 +487,8 @@ describe("Ekklesia shell — browser authentication", () => {
"/api/v1/dashboard/settings",
// Who is signed in and their own preferences, asked alongside them too (ADR 0062).
"/api/v1/account",
// The host-owned device panel loads with the rest of the shell for an administrator.
"/api/v1/devices",
// Nothing else: the sidebar is navigation only, so the shell no longer asks for the device
// list on every page. Displays are Settings → Displays' own business (ADR 0064).
]);
});
+310 -25
View File
@@ -73,6 +73,50 @@ const ADMINISTRATOR: DashboardSettings = {
sections: ["general", "branding", "displays", "discover", "system"],
};
const DEVICES = [
{
createdAt: "2026-10-01T09:00:00.000Z",
id: "device-1",
lastSeenAt: new Date(Date.now() - 5 * 60_000).toISOString(),
name: "Foyer TV",
permissions: ["signage.display"],
revoked: false,
},
{
createdAt: "2026-08-01T09:00:00.000Z",
id: "device-2",
name: "Old hall screen",
permissions: ["signage.display"],
revoked: true,
},
];
const LIFECYCLE = [
{
configured: { enabled: true, explicit: false },
id: "signage",
restartRequired: false,
runtime: { state: "running" },
version: "0.1.5",
},
{
configured: { enabled: false, explicit: true },
id: "archive",
restartRequired: false,
runtime: { state: "disabled" },
version: "1.0.0",
},
];
/** Lets pending requests settle, so the overview's summaries have answered. */
async function settle(): Promise<void> {
await act(async () => {
for (let index = 0; index < 5; index += 1) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
});
}
interface Rendered {
readonly history: MemoryHostHistory;
readonly calls: string[];
@@ -84,7 +128,9 @@ async function render(
options: {
readonly settings?: DashboardSettings | "fails";
readonly compact?: boolean;
readonly collapsed?: boolean;
readonly history?: MemoryHostHistory;
readonly devices?: unknown[];
} = {},
): Promise<Rendered> {
const history = options.history ?? createMemoryHostHistory(path);
@@ -107,6 +153,19 @@ async function render(
? Response.json({ code: "AUTHORIZATION_EVALUATION_FAILED" }, { status: 503 })
: Response.json(settings === "fails" ? ADMINISTRATOR : settings);
}
// The existing data the Settings overview reads (ADR 0064); a 403 for anyone it would refuse.
const visible = options.settings ?? ADMINISTRATOR;
const sections = visible === "fails" ? ADMINISTRATOR.sections : visible.sections;
if (url.endsWith("/api/v1/devices")) {
return sections.includes("displays")
? Response.json({ devices: options.devices ?? DEVICES })
: Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 });
}
if (url.endsWith("/api/v1/module-lifecycle")) {
return sections.includes("discover")
? Response.json({ modules: LIFECYCLE })
: Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 });
}
return Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 });
}) as unknown as typeof fetch;
await act(async () => {
@@ -125,7 +184,9 @@ async function render(
},
})}
setDocumentTitle={(title) => titles.push(title)}
sidebarPreference={createMemorySidebarPreference()}
sidebarPreference={createMemorySidebarPreference(
options.collapsed ? "collapsed" : undefined,
)}
viewport={createMemoryHostViewport(options.compact ?? false)}
/>,
);
@@ -157,33 +218,70 @@ const current = () =>
);
describe("the Settings landing page", () => {
it("is a real page, not a redirect, listing what this person may open", async () => {
const { history, titles } = await render("/settings");
it("is a real page, not a redirect: an overview of the installation, not a copy of the sidebar", async () => {
const { history, titles, calls } = await render("/settings");
await settle();
expect(history.entries).toEqual(["/settings"]);
expect(query("settings-home")?.querySelector("h1")?.textContent).toBe("Settings");
const home = required("settings-home");
expect(home.querySelector("h1")?.textContent).toBe("Settings");
// Each area is a summary of its state, read from what already exists (ADR 0064).
expect(required("settings-summary-displays-status").textContent).toBe("1 display paired");
expect(required("settings-summary-displays").textContent).toContain(
"Foyer TV was last seen within the last hour.",
);
expect(required("settings-summary-modules-status").textContent).toBe("2 modules installed");
expect(required("settings-summary-modules").textContent).toContain("1 turned off");
expect(required("settings-summary-branding-status").textContent).toBe(
"Using the default name and colours",
);
expect(calls).toContain("/api/v1/devices");
expect(calls).toContain("/api/v1/module-lifecycle");
// The one link that changes each area, rather than the sidebar's list again.
expect(
[...container.querySelectorAll('[data-testid^="settings-home-"]')].map((link) =>
[...home.querySelectorAll('[data-testid^="settings-home-"]')].map((link) =>
link.getAttribute("href"),
),
).toEqual([
"/settings/displays",
"/settings/discover/installed",
"/settings/signage",
"/settings/general",
"/settings/branding",
"/settings/displays",
"/settings/discover/browse",
"/settings/system",
"/settings/signage",
]);
// Not one navigation item per section, the way the sidebar lists them.
expect(home.querySelector(".ek-shell-settings-link")).toBeNull();
// A disabled module's settings are not offered.
expect(query("settings-home-module-archive")).toBeNull();
// Each group is a section of the page, below its one h1.
expect(
[...container.querySelectorAll("main h2")].map((heading) => heading.textContent),
).toEqual(["Platform", "Modules"]);
).toEqual(["Displays", "Modules", "General", "Branding", "System"]);
// Nothing on it takes focus by itself.
expect(document.activeElement?.tagName).not.toBe("INPUT");
expect(titles.at(-1)).toBe("Settings");
expect(current()).toEqual(["select-settings"]);
});
it("summarises only what this person may see, and asks for nothing else", async () => {
const { calls } = await render("/settings", {
settings: { modules: [{ available: true, id: "signage", name: "Signage" }], sections: [] },
});
await settle();
expect(query("settings-summary-displays")).toBeNull();
expect(required("settings-summary-modules-status").textContent).toBe("Module settings");
expect(calls).not.toContain("/api/v1/devices");
expect(calls).not.toContain("/api/v1/module-lifecycle");
});
it("says plainly when nothing is paired yet, and when a summary cannot be read", async () => {
await render("/settings", { devices: [] });
await settle();
expect(required("settings-summary-displays-status").textContent).toBe("No displays paired yet");
expect(required("settings-home-displays").textContent).toBe("Add a display");
});
it("says plainly when there is nothing for this person to change", async () => {
await render("/settings", { settings: { modules: [], sections: [] } });
@@ -195,7 +293,7 @@ describe("the Settings landing page", () => {
describe("the platform sections", () => {
it("gives each a page of its own, with Back to Settings and an honest placeholder", async () => {
for (const section of ["general", "branding", "displays", "system"] as const) {
for (const section of ["general", "branding", "system"] as const) {
await act(async () => {
root.unmount();
});
@@ -215,10 +313,10 @@ describe("the platform sections", () => {
}
});
it("enters Discover at Browse, its one canonical address, without adding a history entry", async () => {
it("opens Discover itself at /settings/discover, as the place modules are found", async () => {
const { history, titles } = await render("/settings/discover");
expect(history.entries).toEqual(["/settings/discover/browse"]);
expect(history.entries).toEqual(["/settings/discover"]);
expect(query("settings-discover-browse")?.querySelector("h1")?.textContent).toBe(
"Discover modules",
);
@@ -226,38 +324,86 @@ describe("the platform sections", () => {
"Modules add capabilities to your church's Dashboard. Install and manage the modules your church uses.",
);
expect(titles.at(-1)).toBe("Discover modules");
expect(current()).toEqual(["select-discover-browse"]);
expect(query("select-settings-discover")?.getAttribute("href")).toBe(
"/settings/discover/browse",
);
// Discover is the current page; it is not a parent of some separate Browse page.
expect(current()).toEqual(["select-settings-discover"]);
expect(query("select-settings-discover")?.getAttribute("href")).toBe("/settings/discover");
expect(query("select-discover-browse")).toBeNull();
});
it("has Browse, Installed, and Updates beneath Discover, each speaking of modules", async () => {
const { history } = await render("/settings/discover/browse");
it("respells the earlier /settings/discover/browse in place, adding no history entry", async () => {
const { history } = await render("/settings/discover/browse?return=%2Fsignage");
const views = [...required("discover-subnavigation").querySelectorAll("a")];
expect(views.map((view) => view.textContent)).toEqual(["Browse", "Installed", "Updates"]);
expect(history.entries).toEqual(["/settings/discover?return=%2Fsignage"]);
expect(query("settings-discover-browse")).not.toBeNull();
expect(current()).toEqual(["select-settings-discover"]);
// Nothing links to the alias.
expect(container.querySelector('a[href^="/settings/discover/browse"]')).toBeNull();
});
it("has Installed and Updates — and no Browse — beneath Discover, each speaking of modules", async () => {
const { history } = await render("/settings/discover");
const children = required("discover-subnavigation");
const views = [...children.querySelectorAll("a")];
expect(views.map((view) => view.textContent)).toEqual(["Installed", "Updates"]);
expect(views.map((view) => view.getAttribute("href"))).toEqual([
"/settings/discover/browse",
"/settings/discover/installed",
"/settings/discover/updates",
]);
// Children: text only, marked as children, and listed beneath Discover by name.
expect(children.getAttribute("aria-label")).toBe("Discover");
expect(children.className).toContain("ek-shell-nav__children");
for (const view of views) {
expect(view.dataset.navLevel).toBe("child");
expect(view.querySelector("svg")).toBeNull();
}
expect(required("select-settings-discover-disclosure").dataset.open).toBe("true");
await click(required("select-discover-installed"));
expect(query("settings-discover-installed")?.querySelector("h1")?.textContent).toBe(
"Installed modules",
);
// The child is the current page and Discover is marked as its parent, never also current.
expect(current()).toEqual(["select-discover-installed"]);
expect(required("select-settings-discover").dataset.navAncestor).toBe("");
await click(required("select-discover-updates"));
expect(query("settings-discover-updates")?.querySelector("h1")?.textContent).toBe(
"Module updates",
);
await click(required("select-settings-discover"));
expect(current()).toEqual(["select-settings-discover"]);
expect(required("select-settings-discover").dataset.navAncestor).toBeUndefined();
expect(history.entries).toEqual([
"/settings/discover/browse",
"/settings/discover",
"/settings/discover/installed",
"/settings/discover/updates",
"/settings/discover",
]);
});
it("shows Discover closed, with no children, while another Settings page is open", async () => {
await render("/settings/general");
expect(query("discover-subnavigation")).toBeNull();
expect(required("select-settings-discover-disclosure").dataset.open).toBe("false");
});
it("shows only the parent Discover item in the collapsed rail, current for every Discover page", async () => {
for (const path of ["/settings/discover", "/settings/discover/installed"]) {
await act(async () => {
root.unmount();
});
root = createRoot(container);
await render(path, { collapsed: true });
expect(query("discover-subnavigation"), path).toBeNull();
expect(query("select-discover-installed"), path).toBeNull();
expect(current(), path).toEqual(["select-settings-discover"]);
// No disclosure chevron in the rail: it has no room for the children it would announce.
expect(query("select-settings-discover-disclosure"), path).toBeNull();
}
});
it("says a section is unavailable to someone who may not see it, rather than showing it", async () => {
await render("/settings/discover/installed", {
settings: { modules: [], sections: ["displays"] },
@@ -383,13 +529,13 @@ describe("Settings in the navigation", () => {
it("keeps the exact Settings page across a refresh, and Back and Forward walk it", async () => {
const history = createMemoryHostHistory("/settings");
await render("/settings", { history });
await click(required("select-settings-displays"));
await click(required("select-settings-branding"));
await click(required("select-module-settings-signage"));
await act(async () => {
history.go(-1);
});
expect(query("settings-section-displays")).not.toBeNull();
expect(query("settings-section-branding")).not.toBeNull();
await act(async () => {
history.go(1);
});
@@ -436,3 +582,142 @@ describe("Settings in the navigation", () => {
expect(query("settings-section-system")).not.toBeNull();
});
});
describe("the sidebar is navigation only (ADR 0064)", () => {
it("has no display-pairing block, expanded or in the phone drawer", async () => {
await render("/settings");
const sidebar = required("host-sidebar");
expect(query("host-devices")).toBeNull();
expect(sidebar.querySelector("input, form")).toBeNull();
expect(sidebar.textContent).not.toMatch(/display devices|pairing code/iu);
await act(async () => {
root.unmount();
});
root = createRoot(container);
await render("/settings", { compact: true });
await click(required("mobile-navigation-trigger"));
const drawer = required("navigation-drawer");
expect(drawer.querySelector("input, form")).toBeNull();
expect(drawer.textContent).not.toMatch(/display devices|pairing code/iu);
});
it("offers pairing under Settings → Displays instead, without focusing anything on arrival", async () => {
for (const path of ["/settings", "/settings/displays", "/signage", "/"]) {
await act(async () => {
root.unmount();
});
root = createRoot(container);
await render(path);
await settle();
expect(document.activeElement?.tagName, path).not.toBe("INPUT");
expect(container.querySelector("input"), path).toBeNull();
if (path === "/settings/displays") {
expect(required("settings-section-displays").querySelector("h1")?.textContent).toBe(
"Displays",
);
}
}
// Moving to Displays from the navigation moves focus to the page, never into a field.
await act(async () => {
root.unmount();
});
root = createRoot(container);
await render("/settings");
await click(required("select-settings-displays"));
await settle();
expect(document.activeElement).toBe(required("host-main-region"));
expect(required("add-display").textContent).toContain("Add display");
});
});
describe("collapsed navigation geometry (ADR 0064)", () => {
it("leaves every item's layout to the stylesheet, so the rail can centre each glyph", async () => {
await render("/settings/general", { collapsed: true });
const sidebar = required("host-sidebar");
expect(sidebar.dataset.sidebar).toBe("collapsed");
const items = [...sidebar.querySelectorAll<HTMLElement>("nav a")];
expect(items.map((item) => item.dataset.testid)).toEqual([
"select-overview",
"select-signage",
"select-calendar",
"select-settings",
"select-settings-general",
"select-settings-branding",
"select-settings-displays",
"select-settings-discover",
"select-settings-system",
"select-module-settings-signage",
]);
for (const item of items) {
// One class carries the geometry; no inline layout can outrank the collapsed rule.
expect(item.classList.contains("ek-shell-nav-item"), item.dataset.testid).toBe(true);
expect(item.style.justifyContent, item.dataset.testid).toBe("");
expect(item.style.width, item.dataset.testid).toBe("");
expect(item.style.padding, item.dataset.testid).toBe("");
// Its name stays for assistive technology; only one glyph is drawn.
expect(item.querySelectorAll("svg"), item.dataset.testid).toHaveLength(1);
expect(item.querySelector(".ek-shell-visually-hidden")?.textContent).toBeTruthy();
}
const stylesheet = [...container.querySelectorAll("style")]
.map((style) => style.textContent)
.join("\n");
const collapsedRule =
/\.ek-shell-sidebar\[data-sidebar="collapsed"\] \.ek-shell-nav-item \{([^}]*)\}/u.exec(
stylesheet,
)?.[1];
expect(collapsedRule).toContain("justify-content: center");
expect(collapsedRule).toContain("margin-inline: auto");
expect(collapsedRule).toContain("padding: 0");
expect(collapsedRule).toContain("width: var(--ekklesia-control-height-md)");
expect(collapsedRule).toContain("height: var(--ekklesia-control-height-md)");
});
});
describe("the phone header (ADR 0064)", () => {
it("is a sticky header holding the menu trigger and the open page's name", async () => {
await render("/settings/displays", { compact: true });
const header = required("mobile-header");
expect(header.tagName).toBe("HEADER");
expect(header.className).toBe("ek-shell-mobile-bar");
expect(header.contains(required("mobile-navigation-trigger"))).toBe(true);
// A visual echo of the page title; the page's own h1 is what assistive technology reads.
const title = required("mobile-header-title");
expect(title.textContent).toBe("Displays settings");
expect(title.getAttribute("aria-hidden")).toBe("true");
const trigger = required("mobile-navigation-trigger");
expect(trigger.style.minHeight).toBe("var(--ekklesia-control-height-lg)");
expect(trigger.style.minWidth).toBe("var(--ekklesia-control-height-lg)");
const stylesheet = [...container.querySelectorAll("style")]
.map((style) => style.textContent)
.join("\n");
const rule = /\.ek-shell-mobile-bar \{([^}]*)\}/u.exec(stylesheet)?.[1];
expect(rule).toContain("position: sticky");
expect(rule).toContain("top: 0");
expect(rule).toContain("env(safe-area-inset-top, 0px)");
});
it("follows the open page, and the drawer still opens from it and returns focus to it", async () => {
await render("/settings", { compact: true });
expect(required("mobile-header-title").textContent).toBe("Settings");
await click(required("mobile-navigation-trigger"));
const drawer = required("navigation-drawer");
expect(document.activeElement).toBe(required("navigation-drawer-close"));
await act(async () => {
drawer.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }));
});
expect(query("navigation-drawer")).toBeNull();
expect(document.activeElement).toBe(required("mobile-navigation-trigger"));
await click(required("mobile-navigation-trigger"));
await click(required("select-settings-discover"));
expect(query("navigation-drawer")).toBeNull();
expect(required("mobile-header-title").textContent).toBe("Discover modules");
expect(document.activeElement).toBe(required("host-main-region"));
});
});