Add the account area and account-synced Appearance preferences
CI / verify (pull_request) Successful in 1m10s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 50s
CI / migration-smoke (pull_request) Successful in 2m26s
CI / image-build (pull_request) Successful in 1m12s
CI / deploy-smoke (same-origin) (pull_request) Successful in 42s
CI / package-publication (pull_request) Successful in 54s
CI / deploy-smoke (split) (pull_request) Successful in 3m17s
CI / verify (pull_request) Successful in 1m10s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 50s
CI / migration-smoke (pull_request) Successful in 2m26s
CI / image-build (pull_request) Successful in 1m12s
CI / deploy-smoke (same-origin) (pull_request) Successful in 42s
CI / package-publication (pull_request) Successful in 54s
CI / deploy-smoke (split) (pull_request) Successful in 3m17s
Stage 1 PR 6 (ADR 0062). /account shows who is signed in; /account/appearance chooses System, Light, or Dark and reduced motion (System, Reduce, Allow). - Core: ekklesia_core_user_preference, one typed, CHECK-constrained, nullable column per preference (null = never chosen), migration 0008_user_preferences (additive), @ekklesia/core/account store, and the authenticated-baseline permission core.account.self.manage. - API: GET /api/v1/account (name, email, preferences) and partial PATCH /api/v1/account/preferences, self-only by construction: no user id is accepted anywhere; trusted Origin and body limit on the mutation. - Shell: the account control shows name, email, and initials (never the unvetted image URL) and offers Account, Appearance, Sign out; native radio groups apply a choice at once through the ADR 0060 controller and save it; the server's answer wins and a failed save reverts. - First paint: a classic, same-origin, render-blocking appearance.js in <head> applies a two-word localStorage cache before paint (no inline script, CSP unchanged). The account replaces the cache on every load, sign-out clears it, and other tabs follow via the storage event. - Deploy smoke: real-Chromium checks for light/dark, OS emulation, overrides, reload persistence, pre-paint, Shadow DOM modules, reduced motion, the display staying light, and phone width; the browser container gets a 512 MiB /dev/shm so a long session no longer fails requests with ERR_INSUFFICIENT_RESOURCES. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8571e6854c
commit
cd0287537e
@@ -0,0 +1,83 @@
|
||||
import { designToken } from "@ekklesia/design-tokens";
|
||||
import type { ReactElement } from "react";
|
||||
import type { HostAccount } from "../host-protocol.js";
|
||||
|
||||
/** The first user-perceived character of a word, so an accented letter or an emoji is never cut in
|
||||
* half. */
|
||||
function firstCharacter(word: string): string {
|
||||
if (typeof Intl.Segmenter === "function") {
|
||||
for (const { segment } of new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(
|
||||
word,
|
||||
)) {
|
||||
return segment;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
return Array.from(word)[0] ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* A person's initials, from the name they signed up with: the first letter of the first and last
|
||||
* words (`Ada King Lovelace` → `AL`), one letter for a one-word name, and the email's first letter
|
||||
* when the name is blank.
|
||||
*/
|
||||
export function accountInitials(account: Pick<HostAccount, "email" | "name">): string {
|
||||
const words = account.name
|
||||
.trim()
|
||||
.split(/\s+/u)
|
||||
.filter((word) => word.length > 0);
|
||||
const first = words[0];
|
||||
const last = words.length > 1 ? words.at(-1) : undefined;
|
||||
if (first === undefined) {
|
||||
return firstCharacter(account.email.trim()).toLocaleUpperCase();
|
||||
}
|
||||
return `${firstCharacter(first)}${last === undefined ? "" : firstCharacter(last)}`.toLocaleUpperCase();
|
||||
}
|
||||
|
||||
/** The name to show for a person: what they signed up with, or their email if that is blank. */
|
||||
export function accountDisplayName(account: Pick<HostAccount, "email" | "name">): string {
|
||||
const name = account.name.trim();
|
||||
return name.length > 0 ? name : account.email;
|
||||
}
|
||||
|
||||
/**
|
||||
* A person's initials in a circle (DESIGN.md §4.5).
|
||||
*
|
||||
* Initials rather than the account's `image`: that is an arbitrary URL the platform has never
|
||||
* vetted, and loading it would send every Dashboard visit to whatever host it names (ADR 0062).
|
||||
* Decorative — the person's name is always beside it or in the control's accessible name — so it is
|
||||
* hidden from assistive technology. Text on the subtle accent is a contrast-tested pair in both
|
||||
* schemes (`@ekklesia/design-tokens` `tests/contrast.test.ts`).
|
||||
*/
|
||||
export function AccountAvatar(props: {
|
||||
readonly account: Pick<HostAccount, "email" | "name">;
|
||||
readonly size?: "md" | "lg";
|
||||
}): ReactElement {
|
||||
const size = props.size === "lg" ? "3rem" : "1.75rem";
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="ek-shell-avatar"
|
||||
data-testid="account-avatar"
|
||||
style={{
|
||||
alignItems: "center",
|
||||
background: designToken("accent.subtle"),
|
||||
borderRadius: designToken("radius.pill"),
|
||||
color: designToken("text.primary"),
|
||||
display: "inline-flex",
|
||||
flex: "0 0 auto",
|
||||
fontSize:
|
||||
props.size === "lg"
|
||||
? designToken("typography.size-lg")
|
||||
: designToken("typography.size-xs"),
|
||||
fontWeight: designToken("typography.weight-bold"),
|
||||
height: size,
|
||||
justifyContent: "center",
|
||||
lineHeight: 1,
|
||||
width: size,
|
||||
}}
|
||||
>
|
||||
{accountInitials(props.account)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -7,22 +7,43 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { DashboardDestination } from "../dashboard-routes.js";
|
||||
import type { HostAccount } from "../host-protocol.js";
|
||||
import { AccountAvatar, accountDisplayName } from "./AccountAvatar.js";
|
||||
import { DashboardLink } from "./DashboardLink.js";
|
||||
import type { HostHistory } from "./host-history.js";
|
||||
import { useNavTooltip } from "./NavTooltip.js";
|
||||
import { controlHeight, type NavigationPresentation } from "./ShellNavigation.js";
|
||||
import { ShellIcon, type ShellIconName } from "./shell-icons.js";
|
||||
|
||||
export interface AccountMenuProps {
|
||||
readonly presentation: NavigationPresentation;
|
||||
readonly history: HostHistory;
|
||||
/** Who is signed in, or `"unavailable"` when their account could not be loaded: the control then
|
||||
* names the area rather than the person, and still offers every destination. */
|
||||
readonly account: HostAccount | "unavailable";
|
||||
readonly onSignOut: () => void;
|
||||
/** Called after choosing a destination, so a drawer around the menu can close. */
|
||||
readonly onNavigate?: () => void;
|
||||
}
|
||||
|
||||
interface AccountAction {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly icon: ShellIconName;
|
||||
readonly testId: string;
|
||||
readonly run: () => void;
|
||||
}
|
||||
type AccountAction =
|
||||
| {
|
||||
readonly kind: "link";
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly icon: ShellIconName;
|
||||
readonly testId: string;
|
||||
readonly to: DashboardDestination;
|
||||
}
|
||||
| {
|
||||
readonly kind: "button";
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly icon: ShellIconName;
|
||||
readonly testId: string;
|
||||
readonly run: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The signed-in person's account control, at the bottom of the navigation (DESIGN.md §4.5).
|
||||
@@ -33,21 +54,23 @@ interface AccountAction {
|
||||
* returns focus to the trigger; Tab or a pointer press outside closes it and leaves focus where it
|
||||
* went.
|
||||
*
|
||||
* It offers **only actions that work today** — Sign out. Profile, personal preferences, and
|
||||
* appearance belong here (DESIGN.md §4.5, §9.2) and arrive with the account pages; until then there
|
||||
* is no dead link to `/account`. The shell knows the person only by an opaque user id
|
||||
* (`/api/v1/principal`), which is never shown, so the control names the area rather than the person.
|
||||
* It shows **who is signed in** — their name and email beside their initials (ADR 0062) — and
|
||||
* offers only destinations that work: Account (`/account`), Appearance (`/account/appearance`), and
|
||||
* Sign out. Account and Appearance are real links, so they open in a new tab like any other. When
|
||||
* the account cannot be loaded, the control names the area ("Your account") rather than the person.
|
||||
*/
|
||||
export function AccountMenu(props: AccountMenuProps): ReactElement {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [initialFocus, setInitialFocus] = useState<"first" | "last">("first");
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const itemRefs = useRef<(HTMLElement | null)[]>([]);
|
||||
const menuId = useId();
|
||||
const triggerId = useId();
|
||||
const { collapsed } = props.presentation;
|
||||
const { tooltip, triggerProps } = useNavTooltip("Your account", collapsed && !open);
|
||||
const account = props.account === "unavailable" ? undefined : props.account;
|
||||
const name = account ? accountDisplayName(account) : "Your account";
|
||||
const { tooltip, triggerProps } = useNavTooltip(name, collapsed && !open);
|
||||
|
||||
const close = useCallback((returnFocus: boolean) => {
|
||||
setOpen(false);
|
||||
@@ -57,9 +80,26 @@ export function AccountMenu(props: AccountMenuProps): ReactElement {
|
||||
}, []);
|
||||
|
||||
const actions: readonly AccountAction[] = [
|
||||
{
|
||||
icon: "account",
|
||||
id: "account",
|
||||
kind: "link",
|
||||
label: "Account",
|
||||
testId: "host-account-link",
|
||||
to: { kind: "account" },
|
||||
},
|
||||
{
|
||||
icon: "appearance",
|
||||
id: "appearance",
|
||||
kind: "link",
|
||||
label: "Appearance",
|
||||
testId: "host-appearance-link",
|
||||
to: { kind: "account-section", section: "appearance" },
|
||||
},
|
||||
{
|
||||
icon: "signOut",
|
||||
id: "sign-out",
|
||||
kind: "button",
|
||||
label: "Sign out",
|
||||
run: () => {
|
||||
setOpen(false);
|
||||
@@ -78,7 +118,7 @@ export function AccountMenu(props: AccountMenuProps): ReactElement {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const items = itemRefs.current.filter((item): item is HTMLButtonElement => item !== null);
|
||||
const items = itemRefs.current.filter((item): item is HTMLElement => item !== null);
|
||||
(initialFocus === "last" ? items.at(-1) : items[0])?.focus();
|
||||
}, [initialFocus, open]);
|
||||
|
||||
@@ -98,8 +138,8 @@ export function AccountMenu(props: AccountMenuProps): ReactElement {
|
||||
}, [open]);
|
||||
|
||||
const onMenuKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
const items = itemRefs.current.filter((item): item is HTMLButtonElement => item !== null);
|
||||
const index = items.indexOf(event.target as HTMLButtonElement);
|
||||
const items = itemRefs.current.filter((item): item is HTMLElement => item !== null);
|
||||
const index = items.indexOf(event.target as HTMLElement);
|
||||
const focusAt = (next: number): void => {
|
||||
event.preventDefault();
|
||||
items[(next + items.length) % items.length]?.focus();
|
||||
@@ -125,32 +165,68 @@ export function AccountMenu(props: AccountMenuProps): ReactElement {
|
||||
return (
|
||||
<div ref={wrapperRef}>
|
||||
{open ? (
|
||||
<div
|
||||
aria-labelledby={triggerId}
|
||||
className="ek-shell-menu"
|
||||
data-testid="host-account-menu-list"
|
||||
id={menuId}
|
||||
onKeyDown={onMenuKeyDown}
|
||||
role="menu"
|
||||
>
|
||||
{actions.map((action, index) => (
|
||||
<button
|
||||
className="ek-button ek-focusable ek-button--quiet ek-shell-menu__item"
|
||||
data-testid={action.testId}
|
||||
key={action.id}
|
||||
onClick={action.run}
|
||||
ref={(element) => {
|
||||
<div className="ek-shell-menu" data-testid="host-account-menu-popover">
|
||||
{account ? (
|
||||
<div className="ek-shell-menu__identity" data-testid="host-account-identity">
|
||||
<span className="ek-shell-menu__identity-name">{accountDisplayName(account)}</span>
|
||||
{account.name.trim().length > 0 ? (
|
||||
<span className="ek-shell-menu__identity-email">{account.email}</span>
|
||||
) : undefined}
|
||||
</div>
|
||||
) : undefined}
|
||||
<div
|
||||
aria-labelledby={triggerId}
|
||||
className="ek-shell-menu__items"
|
||||
data-testid="host-account-menu-list"
|
||||
id={menuId}
|
||||
onKeyDown={onMenuKeyDown}
|
||||
role="menu"
|
||||
>
|
||||
{actions.map((action, index) => {
|
||||
const ref = (element: HTMLElement | null): void => {
|
||||
itemRefs.current[index] = element;
|
||||
}}
|
||||
role="menuitem"
|
||||
style={{ minHeight: controlHeight(props.presentation) }}
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
>
|
||||
<ShellIcon name={action.icon} />
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
};
|
||||
const content = (
|
||||
<>
|
||||
<ShellIcon name={action.icon} />
|
||||
{action.label}
|
||||
</>
|
||||
);
|
||||
return action.kind === "link" ? (
|
||||
<DashboardLink
|
||||
className="ek-shell-menu__item"
|
||||
data-testid={action.testId}
|
||||
history={props.history}
|
||||
key={action.id}
|
||||
onNavigate={() => {
|
||||
setOpen(false);
|
||||
props.onNavigate?.();
|
||||
}}
|
||||
ref={ref}
|
||||
role="menuitem"
|
||||
style={{ minHeight: controlHeight(props.presentation) }}
|
||||
tabIndex={-1}
|
||||
to={action.to}
|
||||
>
|
||||
{content}
|
||||
</DashboardLink>
|
||||
) : (
|
||||
<button
|
||||
className="ek-button ek-focusable ek-button--quiet ek-shell-menu__item"
|
||||
data-testid={action.testId}
|
||||
key={action.id}
|
||||
onClick={action.run}
|
||||
ref={ref}
|
||||
role="menuitem"
|
||||
style={{ minHeight: controlHeight(props.presentation) }}
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : undefined}
|
||||
<button
|
||||
@@ -176,9 +252,14 @@ export function AccountMenu(props: AccountMenuProps): ReactElement {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ShellIcon name="account" />
|
||||
<span className={collapsed ? "ek-shell-visually-hidden" : "ek-shell-nav-item__label"}>
|
||||
Your account
|
||||
{account ? <AccountAvatar account={account} /> : <ShellIcon name="account" />}
|
||||
<span className={collapsed ? "ek-shell-visually-hidden" : "ek-shell-account-trigger__copy"}>
|
||||
<span className="ek-shell-nav-item__label">{name}</span>
|
||||
{account && !collapsed && account.name.trim().length > 0 ? (
|
||||
<span className="ek-shell-nav-item__label ek-shell-account-trigger__email">
|
||||
{account.email}
|
||||
</span>
|
||||
) : undefined}
|
||||
</span>
|
||||
</button>
|
||||
{tooltip}
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
import { designToken } from "@ekklesia/design-tokens";
|
||||
import { Button, ErrorState, InlineNotice, PageHeader, Panel } from "@ekklesia/ui";
|
||||
import {
|
||||
type ReactElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import type { DashboardDestination } from "../dashboard-routes.js";
|
||||
import {
|
||||
type EkklesiaHostConfig,
|
||||
type HostAccount,
|
||||
type HostAccountPreferences,
|
||||
HostProtocolError,
|
||||
saveHostAccountPreferences,
|
||||
} from "../host-protocol.js";
|
||||
import { AccountAvatar, accountDisplayName } from "./AccountAvatar.js";
|
||||
import type { AppearancePreference } from "./appearance-preference.js";
|
||||
import { dashboardBack } from "./dashboard-back.js";
|
||||
import { DashboardLink } from "./DashboardLink.js";
|
||||
import type { HostHistory } from "./host-history.js";
|
||||
import { ShellIcon } from "./shell-icons.js";
|
||||
|
||||
/**
|
||||
* The account area (DESIGN.md §9.2, ADR 0062): who you are signed in as, and your own settings.
|
||||
*
|
||||
* Personal, never church administration — that is Settings (ADR 0061). Nothing here needs a
|
||||
* management permission, and nothing here can affect anyone else.
|
||||
*/
|
||||
|
||||
const ACCOUNT: DashboardDestination = { kind: "account" };
|
||||
const APPEARANCE: DashboardDestination = { kind: "account-section", section: "appearance" };
|
||||
|
||||
/**
|
||||
* `/account`: the person's own account. Their name and email as they signed up with them, and the
|
||||
* personal settings they can change. There is no profile editing yet, so nothing here pretends to
|
||||
* be editable.
|
||||
*/
|
||||
export function AccountHomePage(props: {
|
||||
readonly account: HostAccount;
|
||||
readonly history: HostHistory;
|
||||
readonly onSignOut: () => void;
|
||||
}): ReactElement {
|
||||
const { account } = props;
|
||||
const hasName = account.name.trim().length > 0;
|
||||
return (
|
||||
<div data-testid="account-home" style={{ display: "grid", gap: designToken("spacing.xl") }}>
|
||||
<PageHeader description="Who you're signed in as, and your own settings." title="Account" />
|
||||
<Panel title="Signed in as">
|
||||
<div
|
||||
data-testid="account-identity"
|
||||
style={{
|
||||
alignItems: "center",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: designToken("spacing.md"),
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<AccountAvatar account={account} size="lg" />
|
||||
<dl
|
||||
style={{
|
||||
display: "grid",
|
||||
gap: designToken("spacing.2xs"),
|
||||
margin: 0,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{hasName ? (
|
||||
<div>
|
||||
<dt className="ek-shell-visually-hidden">Name</dt>
|
||||
<dd
|
||||
data-testid="account-name"
|
||||
style={{
|
||||
fontWeight: designToken("typography.weight-medium"),
|
||||
margin: 0,
|
||||
overflowWrap: "anywhere",
|
||||
}}
|
||||
>
|
||||
{accountDisplayName(account)}
|
||||
</dd>
|
||||
</div>
|
||||
) : undefined}
|
||||
<div>
|
||||
<dt className="ek-shell-visually-hidden">Email</dt>
|
||||
<dd
|
||||
data-testid="account-email"
|
||||
style={{
|
||||
color: hasName ? designToken("text.secondary") : undefined,
|
||||
margin: 0,
|
||||
overflowWrap: "anywhere",
|
||||
}}
|
||||
>
|
||||
{account.email}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</Panel>
|
||||
<Panel title="Your settings">
|
||||
<ul style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||
<li>
|
||||
<DashboardLink
|
||||
className="ek-shell-settings-link"
|
||||
data-testid="account-home-appearance"
|
||||
history={props.history}
|
||||
style={{ minHeight: designToken("control.height-lg") }}
|
||||
to={APPEARANCE}
|
||||
>
|
||||
<ShellIcon name="appearance" />
|
||||
<span className="ek-shell-settings-link__copy">
|
||||
<span className="ek-shell-settings-link__label">Appearance</span>
|
||||
<span className="ek-shell-settings-link__description">
|
||||
Light, dark, or matching your device, and how much the Dashboard moves.
|
||||
</span>
|
||||
</span>
|
||||
</DashboardLink>
|
||||
</li>
|
||||
</ul>
|
||||
</Panel>
|
||||
<div>
|
||||
<Button data-testid="account-sign-out" onClick={props.onSignOut} variant="secondary">
|
||||
<ShellIcon name="signOut" />
|
||||
Sign out
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChoiceOption<Value extends string> {
|
||||
readonly value: Value;
|
||||
readonly label: string;
|
||||
readonly description: string;
|
||||
}
|
||||
|
||||
const APPEARANCE_OPTIONS: readonly ChoiceOption<HostAccountPreferences["appearance"]>[] = [
|
||||
{ description: "Match your device's light or dark setting.", label: "System", value: "system" },
|
||||
{ description: "Always use the light appearance.", label: "Light", value: "light" },
|
||||
{ description: "Always use the dark appearance.", label: "Dark", value: "dark" },
|
||||
];
|
||||
|
||||
const MOTION_OPTIONS: readonly ChoiceOption<HostAccountPreferences["motion"]>[] = [
|
||||
{
|
||||
description: "Reduce motion when your device asks for less.",
|
||||
label: "System",
|
||||
value: "system",
|
||||
},
|
||||
{ description: "Always keep animation to a minimum.", label: "Reduce", value: "reduce" },
|
||||
{
|
||||
description: "Keep the Dashboard's subtle animation, even if your device asks for less.",
|
||||
label: "Allow",
|
||||
value: "allow",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* One preference as a native radio group: a `<fieldset>` named by its `<legend>`, each option a real
|
||||
* `<input type="radio">` with its label and description. Arrow keys move between options, as the
|
||||
* browser already does for a radio group, and the chosen option is marked by the radio itself and by
|
||||
* weight, never by colour alone. Each row is a full-width touch target.
|
||||
*/
|
||||
function ChoiceGroup<Value extends string>(props: {
|
||||
readonly name: string;
|
||||
readonly legend: string;
|
||||
readonly description: string;
|
||||
readonly options: readonly ChoiceOption<Value>[];
|
||||
readonly value: Value;
|
||||
readonly onChange: (value: Value) => void;
|
||||
}): ReactElement {
|
||||
const descriptionId = useId();
|
||||
return (
|
||||
<fieldset
|
||||
aria-describedby={descriptionId}
|
||||
className="ek-shell-choice-group"
|
||||
data-testid={`appearance-${props.name}`}
|
||||
>
|
||||
<legend className="ek-shell-choice-group__legend">{props.legend}</legend>
|
||||
<p className="ek-shell-choice-group__description" id={descriptionId}>
|
||||
{props.description}
|
||||
</p>
|
||||
<div className="ek-shell-choice-group__options">
|
||||
{props.options.map((option) => {
|
||||
const checked = option.value === props.value;
|
||||
return (
|
||||
<label
|
||||
className="ek-shell-choice"
|
||||
data-checked={checked ? "true" : undefined}
|
||||
data-testid={`appearance-${props.name}-${option.value}`}
|
||||
key={option.value}
|
||||
style={{ minHeight: designToken("control.height-lg") }}
|
||||
>
|
||||
<input
|
||||
checked={checked}
|
||||
className="ek-shell-choice__input ek-focusable"
|
||||
name={props.name}
|
||||
onChange={() => props.onChange(option.value)}
|
||||
type="radio"
|
||||
value={option.value}
|
||||
/>
|
||||
<span className="ek-shell-choice__copy">
|
||||
<span className="ek-shell-choice__label">{option.label}</span>
|
||||
<span className="ek-shell-choice__description">{option.description}</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
type SaveStatus =
|
||||
| { readonly kind: "idle" }
|
||||
| { readonly kind: "saving" }
|
||||
| { readonly kind: "saved" }
|
||||
| { readonly kind: "failed" };
|
||||
|
||||
/**
|
||||
* `/account/appearance`: Appearance (System, Light, Dark) and Motion (System, Reduce, Allow).
|
||||
*
|
||||
* A choice takes effect **at once**, with no reload and no Save button, and is saved to the account
|
||||
* so it follows the person to every device they sign in on. The account is the source of truth:
|
||||
* what the server answers is what the shell applies, and if saving fails, the choice is put back to
|
||||
* what the account still holds, with a message saying so. Other open tabs of this Dashboard follow
|
||||
* along through this browser's cache.
|
||||
*/
|
||||
export function AppearancePage(props: {
|
||||
readonly account: HostAccount;
|
||||
readonly appearance: AppearancePreference;
|
||||
readonly config: EkklesiaHostConfig;
|
||||
readonly history: HostHistory;
|
||||
readonly onSaved: (preferences: HostAccountPreferences) => void;
|
||||
readonly onSessionCheck: () => void;
|
||||
readonly returnTarget?: unknown;
|
||||
}): ReactElement {
|
||||
const { appearance, config, onSaved, onSessionCheck } = props;
|
||||
const subscribe = useCallback(
|
||||
(listener: () => void) => appearance.subscribe(listener),
|
||||
[appearance],
|
||||
);
|
||||
const read = useCallback(() => appearance.current(), [appearance]);
|
||||
const current = useSyncExternalStore(subscribe, read, read);
|
||||
const [status, setStatus] = useState<SaveStatus>({ kind: "idle" });
|
||||
// What the account last confirmed: the value a failed save is put back to.
|
||||
const confirmed = useRef<HostAccountPreferences>(props.account.preferences);
|
||||
const sequence = useRef(0);
|
||||
const inFlight = useRef(new Set<AbortController>());
|
||||
|
||||
useEffect(() => {
|
||||
confirmed.current = props.account.preferences;
|
||||
}, [props.account.preferences]);
|
||||
|
||||
useEffect(() => {
|
||||
const controllers = inFlight.current;
|
||||
return () => {
|
||||
for (const controller of controllers) {
|
||||
controller.abort();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const choose = (update: Partial<HostAccountPreferences>): void => {
|
||||
appearance.apply({ ...appearance.current(), ...update });
|
||||
sequence.current += 1;
|
||||
const mine = sequence.current;
|
||||
const controller = new AbortController();
|
||||
inFlight.current.add(controller);
|
||||
setStatus({ kind: "saving" });
|
||||
void saveHostAccountPreferences(config, update, controller.signal)
|
||||
.then((saved) => {
|
||||
confirmed.current = saved;
|
||||
onSaved(saved);
|
||||
if (mine === sequence.current) {
|
||||
appearance.apply(saved);
|
||||
setStatus({ kind: "saved" });
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
if (mine === sequence.current) {
|
||||
appearance.apply(confirmed.current);
|
||||
setStatus({ kind: "failed" });
|
||||
}
|
||||
if (error instanceof HostProtocolError && error.code === "HOST_UNAUTHENTICATED") {
|
||||
// The session may have ended: the shell checks once, and signs the page out if so.
|
||||
onSessionCheck();
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight.current.delete(controller);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="account-appearance"
|
||||
style={{ display: "grid", gap: designToken("spacing.xl") }}
|
||||
>
|
||||
<PageHeader
|
||||
back={dashboardBack(props.history, {
|
||||
fallback: ACCOUNT,
|
||||
label: (destination) => (destination.kind === "account" ? "Back to Account" : "Back"),
|
||||
returnTarget: props.returnTarget,
|
||||
})}
|
||||
description="How the Dashboard looks for you, on every device you sign in on."
|
||||
title="Appearance"
|
||||
/>
|
||||
<Panel>
|
||||
<div style={{ display: "grid", gap: designToken("spacing.xl") }}>
|
||||
<ChoiceGroup
|
||||
description="Light or dark, or the same as your device."
|
||||
legend="Appearance"
|
||||
name="appearance"
|
||||
onChange={(value) => choose({ appearance: value })}
|
||||
options={APPEARANCE_OPTIONS}
|
||||
value={current.appearance}
|
||||
/>
|
||||
<ChoiceGroup
|
||||
description="Animation and transitions in the Dashboard."
|
||||
legend="Reduced motion"
|
||||
name="motion"
|
||||
onChange={(value) => choose({ motion: value })}
|
||||
options={MOTION_OPTIONS}
|
||||
value={current.motion}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
<div aria-live="polite" data-testid="appearance-status">
|
||||
{status.kind === "saved" ? (
|
||||
<span className="ek-shell-visually-hidden">Saved to your account.</span>
|
||||
) : undefined}
|
||||
{status.kind === "failed" ? (
|
||||
<InlineNotice title="Your choice wasn't saved" tone="danger">
|
||||
It's back to what your account had. Check your connection and try again.
|
||||
</InlineNotice>
|
||||
) : undefined}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The account could not be loaded: the rest of the Dashboard still works, and Retry asks again. */
|
||||
export function AccountLoadFailedPage(props: { readonly onRetry: () => void }): ReactElement {
|
||||
return (
|
||||
<div data-testid="account-load-failed">
|
||||
<ErrorState
|
||||
actions={<Button onClick={props.onRetry}>Retry</Button>}
|
||||
description="The rest of the Dashboard still works. Try again in a moment."
|
||||
title="Your account could not be loaded"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
MouseEventHandler,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
Ref,
|
||||
} from "react";
|
||||
import { type DashboardDestination, dashboardPath } from "../dashboard-routes.js";
|
||||
import type { HostHistory } from "./host-history.js";
|
||||
@@ -29,6 +30,11 @@ export interface DashboardLinkProps {
|
||||
readonly onMouseEnter?: MouseEventHandler<HTMLAnchorElement>;
|
||||
readonly onMouseLeave?: MouseEventHandler<HTMLAnchorElement>;
|
||||
readonly "data-testid"?: string;
|
||||
/** 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";
|
||||
readonly tabIndex?: number;
|
||||
readonly ref?: Ref<HTMLAnchorElement>;
|
||||
}
|
||||
|
||||
const variantClass = {
|
||||
@@ -100,7 +106,10 @@ export function DashboardLink(props: DashboardLinkProps): ReactElement {
|
||||
onKeyDown={props.onKeyDown}
|
||||
onMouseEnter={props.onMouseEnter}
|
||||
onMouseLeave={props.onMouseLeave}
|
||||
ref={props.ref}
|
||||
role={props.role}
|
||||
style={props.style ? { ...baseStyle, ...props.style } : baseStyle}
|
||||
tabIndex={props.tabIndex}
|
||||
>
|
||||
{props.children}
|
||||
</a>
|
||||
|
||||
@@ -25,10 +25,17 @@ import {
|
||||
bootstrapHost,
|
||||
type DashboardNavigationEntry,
|
||||
type EkklesiaHostConfig,
|
||||
type HostAccountPreferences,
|
||||
type HostBootstrap,
|
||||
HostProtocolError,
|
||||
loadHostPrincipal,
|
||||
} from "../host-protocol.js";
|
||||
import { AccountHomePage, AccountLoadFailedPage, AppearancePage } from "./AccountPages.js";
|
||||
import { createMemoryAppearanceCache } from "./appearance-cache.js";
|
||||
import {
|
||||
type AppearancePreference,
|
||||
createDetachedAppearancePreference,
|
||||
} from "./appearance-preference.js";
|
||||
import { ForgotPasswordForm } from "./ForgotPasswordForm.js";
|
||||
import type { HostHistory } from "./host-history.js";
|
||||
import { createBrowserHostViewport, type HostViewport } from "./host-viewport.js";
|
||||
@@ -142,6 +149,13 @@ export interface HostAppProps {
|
||||
* passes this browser's storage; left unset, the mode lasts only as long as this component.
|
||||
*/
|
||||
readonly sidebarPreference?: SidebarPreference;
|
||||
/**
|
||||
* The signed-in person's appearance and motion as this page applies them (ADR 0062). The entry
|
||||
* point passes one over the document's theme controller and this browser's pre-paint cache; left
|
||||
* unset, choices apply to nothing and last only as long as this component. Must be a stable
|
||||
* reference.
|
||||
*/
|
||||
readonly appearance?: AppearancePreference;
|
||||
/** Test-only seam, forwarded to {@link ModuleUiFrame}. Production composition leaves it unset. */
|
||||
readonly importModule?: ModuleUiImporter;
|
||||
}
|
||||
@@ -278,8 +292,12 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
// Fallbacks for a composition that supplies neither; created once, so they stay stable.
|
||||
const [fallbackViewport] = useState(() => createBrowserHostViewport(undefined));
|
||||
const [fallbackPreference] = useState(() => createMemorySidebarPreference());
|
||||
const [fallbackAppearance] = useState(() =>
|
||||
createDetachedAppearancePreference(createMemoryAppearanceCache()),
|
||||
);
|
||||
const viewport = props.viewport ?? fallbackViewport;
|
||||
const sidebarPreference = props.sidebarPreference ?? fallbackPreference;
|
||||
const appearance = props.appearance ?? fallbackAppearance;
|
||||
const subscribe = useCallback((listener: () => void) => history.subscribe(listener), [history]);
|
||||
const readPathname = useCallback(() => history.pathname(), [history]);
|
||||
const pathname = useSyncExternalStore(subscribe, readPathname, readPathname);
|
||||
@@ -308,8 +326,38 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
[],
|
||||
);
|
||||
|
||||
const commitResolvedState = useCallback((resolved: ResolvedBootstrapState): void => {
|
||||
setState(resolved);
|
||||
/**
|
||||
* Every resolved bootstrap reconciles the page's appearance with the account (ADR 0062): the
|
||||
* account's preferences replace whatever this browser cached, so a choice made on another device
|
||||
* is in effect here from this load on. A signed-out page follows the system and keeps no cached
|
||||
* choice, so the next person to use this browser does not inherit one. An account that could not
|
||||
* be loaded leaves the cached choice alone rather than guessing.
|
||||
*/
|
||||
const commitResolvedState = useCallback(
|
||||
(resolved: ResolvedBootstrapState): void => {
|
||||
if (resolved.status === "ready" && resolved.bootstrap.account !== "unavailable") {
|
||||
appearance.apply(resolved.bootstrap.account.preferences);
|
||||
} else if (resolved.status === "anonymous") {
|
||||
appearance.forget();
|
||||
}
|
||||
setState(resolved);
|
||||
},
|
||||
[appearance],
|
||||
);
|
||||
|
||||
/** A saved preference, as the account answered, kept in the bootstrap's copy of the account. */
|
||||
const recordSavedPreferences = useCallback((preferences: HostAccountPreferences): void => {
|
||||
setState((current) =>
|
||||
current.status === "ready" && current.bootstrap.account !== "unavailable"
|
||||
? {
|
||||
...current,
|
||||
bootstrap: {
|
||||
...current.bootstrap,
|
||||
account: { ...current.bootstrap.account, preferences },
|
||||
},
|
||||
}
|
||||
: current,
|
||||
);
|
||||
}, []);
|
||||
|
||||
// A non-canonical spelling of a real page (`/example/`) is corrected in place, adding no
|
||||
@@ -397,9 +445,12 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
// out while a verification or reset screen was the last anonymous view would show that screen
|
||||
// again, describing an emailed link the person has long since finished with.
|
||||
setAuthView({ kind: "sign-in" });
|
||||
setState(
|
||||
principal.kind === "anonymous" ? { status: "anonymous" } : { status: "sign-out-failed" },
|
||||
);
|
||||
if (principal.kind === "anonymous") {
|
||||
appearance.forget();
|
||||
setState({ status: "anonymous" });
|
||||
} else {
|
||||
setState({ status: "sign-out-failed" });
|
||||
}
|
||||
} catch {
|
||||
if (isCurrent(controller, generation)) {
|
||||
console.error("[ekklesia] sign out failed");
|
||||
@@ -410,7 +461,7 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
activeTransitionRef.current = undefined;
|
||||
}
|
||||
}
|
||||
}, [beginTransition, config, isCurrent]);
|
||||
}, [appearance, beginTransition, config, isCurrent]);
|
||||
|
||||
const onAuthorizationResponse = useCallback(
|
||||
(status: 401 | 403): void => {
|
||||
@@ -440,6 +491,7 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
transition.controller.abort();
|
||||
activeTransitionRef.current = undefined;
|
||||
setAuthView({ kind: "sign-in" });
|
||||
appearance.forget();
|
||||
setState({ status: "anonymous" });
|
||||
}
|
||||
})
|
||||
@@ -463,7 +515,7 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
}
|
||||
});
|
||||
},
|
||||
[beginTransition, config],
|
||||
[appearance, beginTransition, config],
|
||||
);
|
||||
|
||||
const openEntry =
|
||||
@@ -491,7 +543,11 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
? "Overview"
|
||||
: route.kind === "module"
|
||||
? (openEntry?.name ?? "This page isn’t available")
|
||||
: "Page not found";
|
||||
: route.kind === "account"
|
||||
? "Account"
|
||||
: route.kind === "account-section"
|
||||
? "Appearance"
|
||||
: "Page not found";
|
||||
const documentTitle = state.status === "anonymous" ? "Sign in" : pageTitle;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -609,6 +665,7 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
config={config}
|
||||
history={history}
|
||||
mainRef={mainRef}
|
||||
account={bootstrap.account}
|
||||
navigation={bootstrap.navigation}
|
||||
onSignOut={() => void signOut()}
|
||||
pageTitle={pageTitle}
|
||||
@@ -619,6 +676,25 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
>
|
||||
{settingsPage !== undefined ? (
|
||||
<SettingsPageView history={history} onRetry={retryBootstrap} page={settingsPage} />
|
||||
) : route.kind === "account" || route.kind === "account-section" ? (
|
||||
bootstrap.account === "unavailable" ? (
|
||||
<AccountLoadFailedPage onRetry={retryBootstrap} />
|
||||
) : route.kind === "account" ? (
|
||||
<AccountHomePage
|
||||
account={bootstrap.account}
|
||||
history={history}
|
||||
onSignOut={() => void signOut()}
|
||||
/>
|
||||
) : (
|
||||
<AppearancePage
|
||||
account={bootstrap.account}
|
||||
appearance={appearance}
|
||||
config={config}
|
||||
history={history}
|
||||
onSaved={recordSavedPreferences}
|
||||
onSessionCheck={() => onAuthorizationResponse(403)}
|
||||
/>
|
||||
)
|
||||
) : route.kind === "overview" ? (
|
||||
<OverviewPage />
|
||||
) : route.kind === "module" ? (
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
DashboardNavigation,
|
||||
DashboardSettings,
|
||||
EkklesiaHostConfig,
|
||||
HostAccount,
|
||||
} from "../host-protocol.js";
|
||||
import { AccountMenu } from "./AccountMenu.js";
|
||||
import { DevicePanel } from "./DevicePanel.js";
|
||||
@@ -45,6 +46,7 @@ export interface ShellLayoutProps {
|
||||
readonly history: HostHistory;
|
||||
readonly navigation: DashboardNavigation;
|
||||
readonly settings: DashboardSettings | "unavailable";
|
||||
readonly account: HostAccount | "unavailable";
|
||||
readonly route: DashboardRoute;
|
||||
readonly viewport: HostViewport;
|
||||
readonly sidebarPreference: SidebarPreference;
|
||||
@@ -89,6 +91,7 @@ function Sidebar(props: {
|
||||
readonly history: HostHistory;
|
||||
readonly navigation: DashboardNavigation;
|
||||
readonly settings: DashboardSettings | "unavailable";
|
||||
readonly account: HostAccount | "unavailable";
|
||||
readonly route: DashboardRoute;
|
||||
readonly mode: SidebarMode;
|
||||
readonly onToggle: () => void;
|
||||
@@ -123,7 +126,12 @@ function Sidebar(props: {
|
||||
{presentation.collapsed ? undefined : <DevicePanel config={props.config} />}
|
||||
</div>
|
||||
<div className="ek-shell-sidebar__bottom">
|
||||
<AccountMenu onSignOut={props.onSignOut} presentation={presentation} />
|
||||
<AccountMenu
|
||||
account={props.account}
|
||||
history={props.history}
|
||||
onSignOut={props.onSignOut}
|
||||
presentation={presentation}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
@@ -143,6 +151,7 @@ function NavigationDrawer(props: {
|
||||
readonly history: HostHistory;
|
||||
readonly navigation: DashboardNavigation;
|
||||
readonly settings: DashboardSettings | "unavailable";
|
||||
readonly account: HostAccount | "unavailable";
|
||||
readonly route: DashboardRoute;
|
||||
readonly onDismiss: () => void;
|
||||
readonly onNavigate: () => void;
|
||||
@@ -226,7 +235,13 @@ function NavigationDrawer(props: {
|
||||
<DevicePanel config={props.config} />
|
||||
</div>
|
||||
<div className="ek-shell-sidebar__bottom">
|
||||
<AccountMenu onSignOut={props.onSignOut} presentation={presentation} />
|
||||
<AccountMenu
|
||||
account={props.account}
|
||||
history={props.history}
|
||||
onNavigate={props.onNavigate}
|
||||
onSignOut={props.onSignOut}
|
||||
presentation={presentation}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -325,6 +340,7 @@ export function ShellLayout(props: ShellLayoutProps): ReactElement {
|
||||
<style>{SHELL_STYLESHEET}</style>
|
||||
{compact ? undefined : (
|
||||
<Sidebar
|
||||
account={props.account}
|
||||
config={props.config}
|
||||
history={props.history}
|
||||
mode={mode}
|
||||
@@ -376,6 +392,7 @@ export function ShellLayout(props: ShellLayoutProps): ReactElement {
|
||||
</div>
|
||||
{showDrawer ? (
|
||||
<NavigationDrawer
|
||||
account={props.account}
|
||||
config={props.config}
|
||||
history={props.history}
|
||||
navigation={props.navigation}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { APPEARANCE_CACHE_KEY, parseAppearanceCache } from "./appearance-cache.js";
|
||||
|
||||
/**
|
||||
* The Dashboard's pre-paint appearance script (ADR 0062), served at
|
||||
* `/assets/ekklesia-dashboard/appearance.js`.
|
||||
*
|
||||
* A classic, render-blocking, same-origin script in the page's `<head>` — never inline, so a strict
|
||||
* `script-src` needs no exception — that runs before the body is parsed. It reads this browser's
|
||||
* cached copy of the person's choice (`appearance-cache.ts`) and puts it on the document root, so
|
||||
* an explicit Light or Dark is in place before the first frame instead of after the API answers.
|
||||
*
|
||||
* It is deliberately tiny and does nothing else. The cache holds two words and nothing about the
|
||||
* account; the shell replaces it with what the account says on every load. When there is no cache —
|
||||
* a first visit, a new device, after sign-out, storage blocked — it does nothing, the page follows
|
||||
* the system, and the account's answer arrives moments later.
|
||||
*
|
||||
* Besides the two attributes the token stylesheet reads (ADR 0060), an explicit choice also narrows
|
||||
* `<meta name="color-scheme">`, because the stylesheet is installed by the main script after first
|
||||
* paint: until then the browser paints its own canvas in the scheme that element names.
|
||||
*/
|
||||
try {
|
||||
const choice = parseAppearanceCache(window.localStorage.getItem(APPEARANCE_CACHE_KEY));
|
||||
if (choice) {
|
||||
const root = document.documentElement;
|
||||
// The token stylesheet's own attribute names (`DESIGN_TOKEN_APPEARANCE_ATTRIBUTE`,
|
||||
// `DESIGN_TOKEN_MOTION_ATTRIBUTE`), spelled out to keep this script free of the token package;
|
||||
// the shell's tests hold them equal.
|
||||
root.setAttribute("data-ekklesia-appearance", choice.appearance);
|
||||
root.setAttribute("data-ekklesia-motion", choice.motion);
|
||||
if (choice.appearance !== "system") {
|
||||
document
|
||||
.querySelector('meta[name="color-scheme"]')
|
||||
?.setAttribute("content", choice.appearance);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Storage blocked or unavailable: follow the system until the account answers.
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { EkklesiaAppearance, EkklesiaMotionPreference } from "@ekklesia/design-tokens";
|
||||
|
||||
/**
|
||||
* This browser's copy of the signed-in person's appearance and motion (ADR 0062).
|
||||
*
|
||||
* **A cache, never the source of truth.** The person's account holds their preferences, and every
|
||||
* Dashboard load replaces this copy with what the account says. The copy exists for one reason:
|
||||
* the account is only known once the API has answered, and by then the page has painted. The
|
||||
* Dashboard's pre-paint script (`appearance-boot.ts`) reads it synchronously, before the body is
|
||||
* parsed, so someone who chose Dark does not see a light page first.
|
||||
*
|
||||
* It holds two words — the appearance and the motion preference — under one namespaced key. No
|
||||
* name, email, user id, or anything else about the account: it is safe for anyone with access to
|
||||
* this browser profile to read, and it is cleared on sign-out so the next person starts from the
|
||||
* system's setting.
|
||||
*
|
||||
* This file is imported by the pre-paint script, so it imports only types: the script must stay
|
||||
* tiny and must not pull the token package into a render-blocking request.
|
||||
*/
|
||||
|
||||
export interface AppearanceChoice {
|
||||
readonly appearance: EkklesiaAppearance;
|
||||
readonly motion: EkklesiaMotionPreference;
|
||||
}
|
||||
|
||||
/** One namespaced key, beside the sidebar's (`ekklesia.dashboard.sidebar`). */
|
||||
export const APPEARANCE_CACHE_KEY = "ekklesia.dashboard.appearance";
|
||||
|
||||
// Repeated rather than imported from the token package, to keep the pre-paint script small; the
|
||||
// shell's tests hold both lists equal to `EKKLESIA_APPEARANCES` and `EKKLESIA_MOTION_PREFERENCES`.
|
||||
const APPEARANCES: readonly string[] = ["system", "light", "dark"];
|
||||
const MOTION_PREFERENCES: readonly string[] = ["system", "reduce", "allow"];
|
||||
|
||||
/** The serialized form: `<appearance> <motion>`, for example `dark reduce`. */
|
||||
export function serializeAppearanceChoice(choice: AppearanceChoice): string {
|
||||
return `${choice.appearance} ${choice.motion}`;
|
||||
}
|
||||
|
||||
/** Strict: exactly two known words. Anything else — including an older or tampered value — is no
|
||||
* cache at all, and the page follows the system until the account answers. */
|
||||
export function parseAppearanceCache(value: unknown): AppearanceChoice | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const parts = value.split(" ");
|
||||
const [appearance, motion] = parts;
|
||||
if (
|
||||
parts.length !== 2 ||
|
||||
appearance === undefined ||
|
||||
motion === undefined ||
|
||||
!APPEARANCES.includes(appearance) ||
|
||||
!MOTION_PREFERENCES.includes(motion)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
appearance: appearance as EkklesiaAppearance,
|
||||
motion: motion as EkklesiaMotionPreference,
|
||||
};
|
||||
}
|
||||
|
||||
export interface AppearanceCache {
|
||||
read(): AppearanceChoice | undefined;
|
||||
write(choice: AppearanceChoice): void;
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The browser's `localStorage`, handed in by the entry point. Exactly like the sidebar preference,
|
||||
* every access is guarded: storage can be missing, blocked, or full, and losing the cache only
|
||||
* costs the pre-paint hint, never the preference itself.
|
||||
*/
|
||||
export function createLocalAppearanceCache(
|
||||
storage: Pick<Storage, "getItem" | "setItem" | "removeItem"> | undefined,
|
||||
): AppearanceCache {
|
||||
return {
|
||||
clear() {
|
||||
try {
|
||||
storage?.removeItem(APPEARANCE_CACHE_KEY);
|
||||
} catch {
|
||||
// Nothing to undo: an unreadable cache is already no cache.
|
||||
}
|
||||
},
|
||||
read() {
|
||||
try {
|
||||
return parseAppearanceCache(storage?.getItem(APPEARANCE_CACHE_KEY));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
write(choice) {
|
||||
const serialized = serializeAppearanceChoice(choice);
|
||||
try {
|
||||
storage?.setItem(APPEARANCE_CACHE_KEY, serialized);
|
||||
} catch {
|
||||
// The account still holds the preference; only the pre-paint hint is lost.
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Nothing persisted: for tests, and for a host composed without browser storage. */
|
||||
export function createMemoryAppearanceCache(initial?: AppearanceChoice): AppearanceCache {
|
||||
let choice = initial;
|
||||
return {
|
||||
clear() {
|
||||
choice = undefined;
|
||||
},
|
||||
read: () => choice,
|
||||
write(next) {
|
||||
choice = next;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
createEkklesiaThemeController,
|
||||
type EkklesiaThemeController,
|
||||
type EkklesiaThemeTarget,
|
||||
} from "@ekklesia/design-tokens";
|
||||
import type { AppearanceCache, AppearanceChoice } from "./appearance-cache.js";
|
||||
|
||||
/**
|
||||
* The signed-in person's appearance and motion, as this Dashboard applies them (ADR 0062).
|
||||
*
|
||||
* The **account** is the source of truth; this is the shell's one handle for putting a choice into
|
||||
* effect. It drives the ADR 0060 theme controller — never a second implementation of the theme —
|
||||
* and keeps this browser's pre-paint cache in step, so whatever is applied now is also what the next
|
||||
* page load paints first.
|
||||
*/
|
||||
export interface AppearancePreference {
|
||||
/** What is in effect now. */
|
||||
current(): AppearanceChoice;
|
||||
/** Puts a choice into effect at once, with no reload, and remembers it for the next first paint. */
|
||||
apply(choice: AppearanceChoice): void;
|
||||
/** Another tab of this Dashboard applied a choice: take it up, without writing it back. */
|
||||
syncFromCache(): void;
|
||||
/** Sign-out: follow the system again and forget the cached choice, so the next person to use this
|
||||
* browser does not inherit it. */
|
||||
forget(): void;
|
||||
/** Notifies after the choice in effect changes. */
|
||||
subscribe(listener: () => void): () => void;
|
||||
}
|
||||
|
||||
export const SYSTEM_APPEARANCE: AppearanceChoice = Object.freeze({
|
||||
appearance: "system",
|
||||
motion: "system",
|
||||
});
|
||||
|
||||
export interface CreateAppearancePreferenceOptions {
|
||||
readonly theme: EkklesiaThemeController;
|
||||
readonly cache: AppearanceCache;
|
||||
/** `<meta name="color-scheme">`, narrowed with an explicit choice exactly as the pre-paint script
|
||||
* narrows it, so the two never disagree. */
|
||||
readonly colorSchemeMeta?: Pick<EkklesiaThemeTarget, "setAttribute">;
|
||||
}
|
||||
|
||||
export function createAppearancePreference(
|
||||
options: CreateAppearancePreferenceOptions,
|
||||
): AppearancePreference {
|
||||
const listeners = new Set<() => void>();
|
||||
// One frozen snapshot per distinct choice, so a React external-store read is stable between
|
||||
// changes.
|
||||
let snapshot: AppearanceChoice = Object.freeze({
|
||||
appearance: options.theme.appearance(),
|
||||
motion: options.theme.motion(),
|
||||
});
|
||||
const current = (): AppearanceChoice => snapshot;
|
||||
const put = (choice: AppearanceChoice): void => {
|
||||
options.theme.setAppearance(choice.appearance);
|
||||
options.theme.setMotion(choice.motion);
|
||||
options.colorSchemeMeta?.setAttribute(
|
||||
"content",
|
||||
options.theme.appearance() === "system" ? "light dark" : options.theme.appearance(),
|
||||
);
|
||||
const appearance = options.theme.appearance();
|
||||
const motion = options.theme.motion();
|
||||
if (appearance !== snapshot.appearance || motion !== snapshot.motion) {
|
||||
snapshot = Object.freeze({ appearance, motion });
|
||||
for (const listener of [...listeners]) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
apply(choice) {
|
||||
put(choice);
|
||||
options.cache.write(current());
|
||||
},
|
||||
current,
|
||||
forget() {
|
||||
options.cache.clear();
|
||||
put(SYSTEM_APPEARANCE);
|
||||
},
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
syncFromCache() {
|
||||
put(options.cache.read() ?? SYSTEM_APPEARANCE);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A self-contained preference over a detached target: for tests, and for a host composed without a
|
||||
* document. */
|
||||
export function createDetachedAppearancePreference(
|
||||
cache: AppearanceCache,
|
||||
initial: AppearanceChoice = SYSTEM_APPEARANCE,
|
||||
): AppearancePreference {
|
||||
const attributes = new Map<string, string>();
|
||||
return createAppearancePreference({
|
||||
cache,
|
||||
theme: createEkklesiaThemeController({
|
||||
appearance: initial.appearance,
|
||||
motion: initial.motion,
|
||||
target: { setAttribute: (name, value) => attributes.set(name, value) },
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { EkklesiaUiStyles } from "@ekklesia/ui";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { consumeAuthLanding } from "../auth-protocol.js";
|
||||
import { type EkklesiaHostConfig, resolveApiOrigin } from "../host-protocol.js";
|
||||
import { APPEARANCE_CACHE_KEY, createLocalAppearanceCache } from "./appearance-cache.js";
|
||||
import { createAppearancePreference } from "./appearance-preference.js";
|
||||
import { HostApp } from "./HostApp.js";
|
||||
import { createBrowserHostHistory } from "./host-history.js";
|
||||
import { createBrowserHostViewport } from "./host-viewport.js";
|
||||
@@ -36,21 +38,25 @@ const setDocumentTitle = (title: string): void => {
|
||||
document.title = title;
|
||||
};
|
||||
|
||||
/** The phone-layout media query and this browser's storage, for the sidebar (ADR 0058). Merely
|
||||
* reading `localStorage` throws where site data is blocked; the shell then remembers the sidebar's
|
||||
* mode for this page only. */
|
||||
/**
|
||||
* This browser's storage, read once. It holds exactly two things: the desktop sidebar's mode
|
||||
* (ADR 0058) and the pre-paint copy of the person's appearance (ADR 0062), which is a cache of what
|
||||
* their account holds and never the source of truth. Merely reading `localStorage` throws where site
|
||||
* data is blocked; the shell then remembers both for this page only.
|
||||
*/
|
||||
const browserStorage = (() => {
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
})();
|
||||
|
||||
/** The phone-layout media query and this browser's storage, for the sidebar (ADR 0058). */
|
||||
const viewport = createBrowserHostViewport(
|
||||
typeof window.matchMedia === "function" ? (query) => window.matchMedia(query) : undefined,
|
||||
);
|
||||
const sidebarPreference = createLocalSidebarPreference(
|
||||
(() => {
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
})(),
|
||||
);
|
||||
const sidebarPreference = createLocalSidebarPreference(browserStorage);
|
||||
|
||||
const container = document.getElementById("ekklesia-host");
|
||||
if (!container) {
|
||||
@@ -69,22 +75,42 @@ if (!container) {
|
||||
installEkklesiaDesignTokens(document);
|
||||
|
||||
/**
|
||||
* The Dashboard's appearance and motion (ADR 0060), applied to the document root before React
|
||||
* renders anything.
|
||||
* The Dashboard's appearance and motion (ADR 0060, ADR 0062), applied to the document root before
|
||||
* React renders anything.
|
||||
*
|
||||
* Both are `system` until a person chooses otherwise: the token stylesheet then follows the
|
||||
* operating system's light/dark and reduced-motion settings live, by CSS alone, into every module
|
||||
* root. This is the one place a remembered choice will be supplied: the account preferences work
|
||||
* reads it synchronously and passes it here, so an explicit Light or Dark is in place before the
|
||||
* first frame. Nothing is stored yet, and there is no second, temporary way to choose.
|
||||
* The page's pre-paint script (`appearance-boot.ts`) has already put this browser's cached choice
|
||||
* on the root, before the body was parsed; the controller starts from that same cache, so the two
|
||||
* agree and nothing changes here. Without a cache both are `system`, and the token stylesheet
|
||||
* follows the operating system's light/dark and reduced-motion settings live, by CSS alone, into
|
||||
* every module root. Once the account answers, the shell applies what it holds (`HostApp`).
|
||||
*/
|
||||
createEkklesiaThemeController({
|
||||
appearance: "system",
|
||||
...(typeof window.matchMedia === "function"
|
||||
? { matchMedia: (query: string) => window.matchMedia(query) }
|
||||
: {}),
|
||||
motion: "system",
|
||||
target: document.documentElement,
|
||||
const appearanceCache = createLocalAppearanceCache(browserStorage);
|
||||
const cachedAppearance = appearanceCache.read();
|
||||
const appearance = createAppearancePreference({
|
||||
cache: appearanceCache,
|
||||
...(() => {
|
||||
const meta = document.querySelector<HTMLMetaElement>('meta[name="color-scheme"]');
|
||||
return meta ? { colorSchemeMeta: meta } : {};
|
||||
})(),
|
||||
theme: createEkklesiaThemeController({
|
||||
appearance: cachedAppearance?.appearance ?? "system",
|
||||
...(typeof window.matchMedia === "function"
|
||||
? { matchMedia: (query: string) => window.matchMedia(query) }
|
||||
: {}),
|
||||
motion: cachedAppearance?.motion ?? "system",
|
||||
target: document.documentElement,
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Another tab of this Dashboard changed the choice, or signed out: the browser tells every other
|
||||
* tab when this origin's storage changes, so they follow along with no reload and no request. The
|
||||
* account is still the source of truth — each tab reconciles with it on its next load.
|
||||
*/
|
||||
window.addEventListener("storage", (event) => {
|
||||
if (event.key === APPEARANCE_CACHE_KEY || event.key === null) {
|
||||
appearance.syncFromCache();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -102,6 +128,7 @@ function renderHost(config: EkklesiaHostConfig | undefined) {
|
||||
}
|
||||
return authLanding ? (
|
||||
<HostApp
|
||||
appearance={appearance}
|
||||
authLanding={authLanding}
|
||||
config={config}
|
||||
history={history}
|
||||
@@ -111,6 +138,7 @@ function renderHost(config: EkklesiaHostConfig | undefined) {
|
||||
/>
|
||||
) : (
|
||||
<HostApp
|
||||
appearance={appearance}
|
||||
config={config}
|
||||
history={history}
|
||||
setDocumentTitle={setDocumentTitle}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Server,
|
||||
Settings,
|
||||
SlidersHorizontal,
|
||||
SunMoon,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { ReactElement } from "react";
|
||||
@@ -34,6 +35,7 @@ import type { ReactElement } from "react";
|
||||
*/
|
||||
export const SHELL_ICONS = Object.freeze({
|
||||
account: CircleUserRound,
|
||||
appearance: SunMoon,
|
||||
closeNavigation: X,
|
||||
collapseSidebar: PanelLeftClose,
|
||||
discoverBrowse: Search,
|
||||
|
||||
@@ -209,9 +209,12 @@ export const SHELL_STYLESHEET = `
|
||||
gap: ${designToken("spacing.2xs")};
|
||||
left: 0;
|
||||
margin: 0;
|
||||
max-width: min(20rem, calc(100vw - 2 * ${designToken("spacing.md")}));
|
||||
min-width: 12rem;
|
||||
padding: ${designToken("spacing.xs")};
|
||||
position: absolute;
|
||||
/* As wide as a name and email need, within the cap, rather than the width of the sidebar. */
|
||||
width: max-content;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
@@ -227,6 +230,126 @@ export const SHELL_STYLESHEET = `
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ek-shell-menu__items {
|
||||
display: grid;
|
||||
gap: ${designToken("spacing.2xs")};
|
||||
}
|
||||
|
||||
.ek-shell-menu__identity {
|
||||
border-bottom: ${designToken("border.width")} solid ${designToken("border.divider")};
|
||||
display: grid;
|
||||
gap: ${designToken("spacing.2xs")};
|
||||
min-width: 0;
|
||||
padding: ${designToken("spacing.xs")} ${designToken("spacing.sm")} ${designToken("spacing.sm")};
|
||||
}
|
||||
|
||||
.ek-shell-menu__identity-name {
|
||||
color: ${designToken("text.primary")};
|
||||
font-weight: ${designToken("typography.weight-medium")};
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.ek-shell-menu__identity-email {
|
||||
color: ${designToken("text.secondary")};
|
||||
font-size: ${designToken("typography.size-sm")};
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.ek-shell-account-trigger__copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.ek-shell-choice-group {
|
||||
border: 0;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ek-shell-choice-group__legend {
|
||||
color: ${designToken("text.primary")};
|
||||
font-size: ${designToken("typography.size-heading-3")};
|
||||
font-weight: ${designToken("typography.weight-bold")};
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ek-shell-choice-group__description {
|
||||
color: ${designToken("text.secondary")};
|
||||
font-size: ${designToken("typography.size-sm")};
|
||||
margin: ${designToken("spacing.2xs")} 0 ${designToken("spacing.md")};
|
||||
}
|
||||
|
||||
.ek-shell-choice-group__options {
|
||||
display: grid;
|
||||
gap: ${designToken("spacing.xs")};
|
||||
}
|
||||
|
||||
/* One option of a radio group: the whole row is the label, so the whole row is the touch target.
|
||||
The chosen option is marked by the radio itself and by weight as well as by its background. */
|
||||
.ek-shell-choice {
|
||||
align-items: flex-start;
|
||||
border: ${designToken("border.width")} solid ${designToken("border.control")};
|
||||
border-radius: ${designToken("radius.md")};
|
||||
box-sizing: border-box;
|
||||
color: ${designToken("text.primary")};
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
gap: ${designToken("spacing.md")};
|
||||
padding: ${designToken("spacing.sm")} ${designToken("spacing.md")};
|
||||
}
|
||||
|
||||
.ek-shell-choice:hover {
|
||||
background: ${designToken("state.hover-background")};
|
||||
}
|
||||
|
||||
.ek-shell-choice:has(.ek-shell-choice__input:focus-visible) {
|
||||
outline: ${designToken("state.focus-visible-width")} solid ${designToken("accent.focus")};
|
||||
outline-offset: ${designToken("state.focus-visible-offset")};
|
||||
}
|
||||
|
||||
.ek-shell-choice[data-checked="true"] {
|
||||
background: ${designToken("state.selected-background")};
|
||||
color: ${designToken("state.selected-foreground")};
|
||||
}
|
||||
|
||||
.ek-shell-choice__input {
|
||||
accent-color: ${designToken("accent.base")};
|
||||
flex: 0 0 auto;
|
||||
height: 1.25rem;
|
||||
margin: 0.125rem 0 0;
|
||||
width: 1.25rem;
|
||||
}
|
||||
|
||||
.ek-shell-choice__copy {
|
||||
display: grid;
|
||||
gap: ${designToken("spacing.2xs")};
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ek-shell-choice__label {
|
||||
font-weight: ${designToken("typography.weight-medium")};
|
||||
}
|
||||
|
||||
.ek-shell-choice[data-checked="true"] .ek-shell-choice__label {
|
||||
font-weight: ${designToken("typography.weight-bold")};
|
||||
}
|
||||
|
||||
.ek-shell-choice__description {
|
||||
color: ${designToken("text.secondary")};
|
||||
font-size: ${designToken("typography.size-sm")};
|
||||
}
|
||||
|
||||
.ek-shell-choice[data-checked="true"] .ek-shell-choice__description {
|
||||
color: ${designToken("state.selected-foreground")};
|
||||
}
|
||||
|
||||
.ek-shell-account-trigger__email {
|
||||
color: ${designToken("text.secondary")};
|
||||
font-size: ${designToken("typography.size-xs")};
|
||||
}
|
||||
|
||||
.ek-shell-mobile-bar {
|
||||
padding: ${designToken("spacing.sm")} ${designToken("spacing.md")} 0;
|
||||
}
|
||||
|
||||
@@ -6,10 +6,14 @@
|
||||
* selectors. `apps/api` reads these exact two files before binding and serves their bytes at fixed
|
||||
* host-owned routes on the same origin as `/api`. Nothing here exposes a directory or a generic
|
||||
* static-file root, and the source map beside the script is deliberately not listed.
|
||||
*
|
||||
* `appearance` is the small classic script the page loads in `<head>` before anything paints, which
|
||||
* applies this browser's cached appearance (ADR 0062).
|
||||
*/
|
||||
const packageRoot = new URL("../", import.meta.url);
|
||||
|
||||
export const dashboardHostAssetUrls = Object.freeze({
|
||||
html: new URL("dist/browser/index.html", packageRoot).href,
|
||||
script: new URL("dist/browser/assets/ekklesia-dashboard/app.js", packageRoot).href,
|
||||
appearance: new URL("dist/browser/assets/ekklesia-dashboard/appearance.js", packageRoot).href,
|
||||
});
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
* /settings/<section> a platform Settings section: general, branding, displays, system
|
||||
* /settings/discover/<view> finding, installing, and managing modules: browse, installed, updates
|
||||
* /settings/<module-id> a module's settings (and later its own deeper settings pages)
|
||||
* /account[/…] the account area
|
||||
* /account your account
|
||||
* /account/<section> a personal settings page: appearance
|
||||
* /<module-id>[/…] a module's page, and later its own sub-pages
|
||||
* ```
|
||||
*
|
||||
@@ -50,6 +51,15 @@ export type SettingsSection = (typeof SETTINGS_SECTIONS)[number];
|
||||
/** The sections that are one page each. Discover is a group of views instead. */
|
||||
export type SettingsPageSection = Exclude<SettingsSection, "discover">;
|
||||
|
||||
/**
|
||||
* The account area's personal settings pages, in navigation order (ADR 0062). Personal, never
|
||||
* church administration: that is Settings. They live beneath `/account/`, so none can collide with a
|
||||
* module id and none is reserved.
|
||||
*/
|
||||
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
|
||||
@@ -67,6 +77,8 @@ export type DashboardRoute =
|
||||
| { readonly kind: "settings-section"; readonly section: SettingsPageSection }
|
||||
| { readonly kind: "discover"; readonly view: DiscoverView }
|
||||
| { readonly kind: "module-settings"; readonly moduleId: string }
|
||||
| { readonly kind: "account" }
|
||||
| { readonly kind: "account-section"; readonly section: AccountSection }
|
||||
| { readonly kind: "not-found" };
|
||||
|
||||
/** A route the shell can link to. `not-found` is something the shell arrives at, never a target. */
|
||||
@@ -76,6 +88,7 @@ const platformPages: ReadonlySet<string> = new Set(DASHBOARD_PLATFORM_PAGES);
|
||||
const settingsSections: ReadonlySet<string> = new Set(SETTINGS_SECTIONS);
|
||||
|
||||
const discoverViews: ReadonlySet<string> = new Set(DISCOVER_VIEWS);
|
||||
const accountSections: ReadonlySet<string> = new Set(ACCOUNT_SECTIONS);
|
||||
|
||||
function isSettingsPageSection(value: string): value is SettingsPageSection {
|
||||
return value !== "discover" && settingsSections.has(value);
|
||||
@@ -85,6 +98,10 @@ function isDiscoverView(value: string): value is DiscoverView {
|
||||
return discoverViews.has(value);
|
||||
}
|
||||
|
||||
function isAccountSection(value: string): value is AccountSection {
|
||||
return accountSections.has(value);
|
||||
}
|
||||
|
||||
function decodeSegment(segment: string): string | undefined {
|
||||
try {
|
||||
const decoded = decodeURIComponent(segment);
|
||||
@@ -120,6 +137,9 @@ export function parseDashboardPath(pathname: string): DashboardRoute {
|
||||
if (first === "settings") {
|
||||
return parseSettingsPath(meaningful.slice(1));
|
||||
}
|
||||
if (first === "account") {
|
||||
return parseAccountPath(meaningful.slice(1));
|
||||
}
|
||||
if (first === undefined || platformPages.has(first)) {
|
||||
return { kind: "not-found" };
|
||||
}
|
||||
@@ -161,6 +181,18 @@ function parseSettingsPath(rest: readonly string[]): DashboardRoute {
|
||||
: { kind: "module-settings", moduleId: name };
|
||||
}
|
||||
|
||||
/** Reads what follows `/account`: the account page itself, or one of its personal settings pages.
|
||||
* Anything else is `not-found` until it exists. */
|
||||
function parseAccountPath(rest: readonly string[]): DashboardRoute {
|
||||
if (rest.length === 0) {
|
||||
return { kind: "account" };
|
||||
}
|
||||
const name = rest[0] === undefined ? undefined : decodeSegment(rest[0]);
|
||||
return rest.length === 1 && name !== undefined && isAccountSection(name)
|
||||
? { kind: "account-section", section: name }
|
||||
: { kind: "not-found" };
|
||||
}
|
||||
|
||||
/** The one canonical spelling of a route. A module id is percent-encoded, so no id can escape its
|
||||
* own path segment. */
|
||||
export function dashboardPath(route: DashboardDestination): string {
|
||||
@@ -177,5 +209,9 @@ export function dashboardPath(route: DashboardDestination): string {
|
||||
return `/settings/discover/${route.view}`;
|
||||
case "module-settings":
|
||||
return `/settings/${encodeURIComponent(route.moduleId)}`;
|
||||
case "account":
|
||||
return "/account";
|
||||
case "account-section":
|
||||
return `/account/${route.section}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { SETTINGS_SECTIONS, type SettingsSection } from "./dashboard-routes.js";
|
||||
import {
|
||||
EKKLESIA_APPEARANCES,
|
||||
EKKLESIA_MOTION_PREFERENCES,
|
||||
type EkklesiaAppearance,
|
||||
type EkklesiaMotionPreference,
|
||||
} from "@ekklesia/design-tokens";
|
||||
import type { ModuleUiPrincipal } from "@ekklesia/module-ui-sdk";
|
||||
import { SETTINGS_SECTIONS, type SettingsSection } from "./dashboard-routes.js";
|
||||
|
||||
/**
|
||||
* How this shell reaches the Ekklesia API.
|
||||
@@ -39,6 +45,8 @@ const PRINCIPAL_PATH = "/api/v1/principal";
|
||||
const DASHBOARD_NAVIGATION_PATH = "/api/v1/dashboard/navigation";
|
||||
const DASHBOARD_SETTINGS_PATH = "/api/v1/dashboard/settings";
|
||||
const MODULE_DISPLAYS_PATH = "/api/v1/module-displays";
|
||||
const ACCOUNT_PATH = "/api/v1/account";
|
||||
const ACCOUNT_PREFERENCES_PATH = "/api/v1/account/preferences";
|
||||
|
||||
const MODULE_ID_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
|
||||
const MODULE_ID_MAX_LENGTH = 64;
|
||||
@@ -404,6 +412,7 @@ async function requestPlatformMutation(
|
||||
rootRelativePath: string,
|
||||
body: unknown,
|
||||
signal?: AbortSignal,
|
||||
method: "PATCH" | "POST" = "POST",
|
||||
): Promise<unknown> {
|
||||
const performRequest = config.fetch ?? fetch;
|
||||
let response: Response;
|
||||
@@ -413,7 +422,7 @@ async function requestPlatformMutation(
|
||||
cache: "no-store",
|
||||
credentials: "include",
|
||||
headers: { accept: "application/json", "content-type": "application/json" },
|
||||
method: "POST",
|
||||
method,
|
||||
redirect: "error",
|
||||
...(signal ? { signal } : {}),
|
||||
});
|
||||
@@ -935,6 +944,79 @@ export async function loadDashboardSettings(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A signed-in person's own appearance and motion preferences, as their account stores them
|
||||
* (ADR 0062). The value sets are the design tokens' own, so what the account stores is exactly what
|
||||
* the theme controller applies.
|
||||
*/
|
||||
export interface HostAccountPreferences {
|
||||
readonly appearance: EkklesiaAppearance;
|
||||
readonly motion: EkklesiaMotionPreference;
|
||||
}
|
||||
|
||||
/** Who is signed in, as the account area shows them: their name and email, and their preferences.
|
||||
* Nothing else about the account reaches the browser. */
|
||||
export interface HostAccount {
|
||||
readonly name: string;
|
||||
readonly email: string;
|
||||
readonly preferences: HostAccountPreferences;
|
||||
}
|
||||
|
||||
/** Strict: exactly the two preferences, each one of its known values. */
|
||||
export function parseHostAccountPreferences(value: unknown): HostAccountPreferences {
|
||||
if (
|
||||
!hasExactKeys(value, ["appearance", "motion"]) ||
|
||||
!EKKLESIA_APPEARANCES.includes(value.appearance as EkklesiaAppearance) ||
|
||||
!EKKLESIA_MOTION_PREFERENCES.includes(value.motion as EkklesiaMotionPreference)
|
||||
) {
|
||||
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
||||
}
|
||||
return Object.freeze({
|
||||
appearance: value.appearance as EkklesiaAppearance,
|
||||
motion: value.motion as EkklesiaMotionPreference,
|
||||
});
|
||||
}
|
||||
|
||||
/** Strict: exactly name, email, and preferences. A name or email is rendered as text, never as
|
||||
* markup or a URL, so the only requirement is that each is a string with something in it. */
|
||||
export function parseHostAccount(value: unknown): HostAccount {
|
||||
if (
|
||||
!hasExactKeys(value, ["email", "name", "preferences"]) ||
|
||||
typeof value.name !== "string" ||
|
||||
typeof value.email !== "string" ||
|
||||
value.email.trim().length === 0
|
||||
) {
|
||||
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
||||
}
|
||||
return Object.freeze({
|
||||
email: value.email,
|
||||
name: value.name,
|
||||
preferences: parseHostAccountPreferences(value.preferences),
|
||||
});
|
||||
}
|
||||
|
||||
/** The signed-in person's own account. There is no way to ask for anyone else's. */
|
||||
export async function loadHostAccount(
|
||||
config: EkklesiaHostConfig,
|
||||
signal?: AbortSignal,
|
||||
): Promise<HostAccount> {
|
||||
return parseHostAccount(
|
||||
await requestPlatformJson(config, ACCOUNT_PATH, signal, { forbidden: "HOST_ACCESS_DENIED" }),
|
||||
);
|
||||
}
|
||||
|
||||
/** Changes some of the signed-in person's own preferences and returns everything now in effect —
|
||||
* the server's answer, which the shell applies, rather than what it asked for. */
|
||||
export async function saveHostAccountPreferences(
|
||||
config: EkklesiaHostConfig,
|
||||
update: Partial<HostAccountPreferences>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<HostAccountPreferences> {
|
||||
return parseHostAccountPreferences(
|
||||
await requestPlatformMutation(config, ACCOUNT_PREFERENCES_PATH, update, signal, "PATCH"),
|
||||
);
|
||||
}
|
||||
|
||||
export interface HostBootstrap {
|
||||
readonly principal: ModuleUiPrincipal;
|
||||
readonly navigation: DashboardNavigation;
|
||||
@@ -944,6 +1026,13 @@ export interface HostBootstrap {
|
||||
* is withheld and its pages say they could not be loaded.
|
||||
*/
|
||||
readonly settings: DashboardSettings | "unavailable";
|
||||
/**
|
||||
* The signed-in person's own account, or `"unavailable"` when it could not be loaded (and for an
|
||||
* anonymous viewer, who has none). Like Settings, an account that cannot be loaded never stops the
|
||||
* Dashboard: the account control shows no name, the theme stays as this browser last knew it, and
|
||||
* the account pages say they could not be loaded.
|
||||
*/
|
||||
readonly account: HostAccount | "unavailable";
|
||||
}
|
||||
|
||||
const EMPTY_SETTINGS: DashboardSettings = Object.freeze({
|
||||
@@ -968,9 +1057,14 @@ export async function bootstrapHost(
|
||||
): Promise<HostBootstrap> {
|
||||
const principal = await loadHostPrincipal(config, signal);
|
||||
if (principal.kind === "anonymous") {
|
||||
return { navigation: EMPTY_NAVIGATION, principal, settings: EMPTY_SETTINGS };
|
||||
return {
|
||||
account: "unavailable",
|
||||
navigation: EMPTY_NAVIGATION,
|
||||
principal,
|
||||
settings: EMPTY_SETTINGS,
|
||||
};
|
||||
}
|
||||
const [navigation, settings] = await Promise.all([
|
||||
const [navigation, settings, account] = await Promise.all([
|
||||
loadDashboardNavigation(config, signal),
|
||||
loadDashboardSettings(config, signal).catch((error: unknown) => {
|
||||
if (signal?.aborted) {
|
||||
@@ -979,6 +1073,13 @@ export async function bootstrapHost(
|
||||
console.error("[ekklesia] settings navigation unavailable", error);
|
||||
return "unavailable" as const;
|
||||
}),
|
||||
loadHostAccount(config, signal).catch((error: unknown) => {
|
||||
if (signal?.aborted) {
|
||||
throw error;
|
||||
}
|
||||
console.error("[ekklesia] account unavailable", error);
|
||||
return "unavailable" as const;
|
||||
}),
|
||||
]);
|
||||
return { navigation, principal, settings };
|
||||
return { account, navigation, principal, settings };
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ export {
|
||||
type DashboardNavigationGroup,
|
||||
type DisplayHostBootstrap,
|
||||
type EkklesiaHostConfig,
|
||||
type HostAccount,
|
||||
type HostAccountPreferences,
|
||||
type HostBootstrap,
|
||||
type HostEventSourceFactory,
|
||||
type HostEventSourceOptions,
|
||||
@@ -28,16 +30,20 @@ export {
|
||||
type HostProtocolErrorCode,
|
||||
loadDashboardNavigation,
|
||||
loadDashboardSettings,
|
||||
loadHostAccount,
|
||||
loadHostPrincipal,
|
||||
loadModuleDisplay,
|
||||
moduleDisplayDescriptorPath,
|
||||
parseDashboardNavigation,
|
||||
parseDashboardSettings,
|
||||
parseDisplayRoute,
|
||||
parseHostAccount,
|
||||
parseHostAccountPreferences,
|
||||
parseHostModuleDisplay,
|
||||
parseHostPrincipal,
|
||||
resolveApiOrigin,
|
||||
resolveApiUrl,
|
||||
saveHostAccountPreferences,
|
||||
} from "./host-protocol.js";
|
||||
export {
|
||||
type CreateModuleUiContextOptions,
|
||||
|
||||
Reference in New Issue
Block a user