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
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:
co-authored by
Claude Opus 5.5
parent
186668915b
commit
405e509bcb
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user