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

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:
ekklesia
2026-10-14 23:56:50 -05:00
co-authored by Claude Opus 5.5
parent 8571e6854c
commit cd0287537e
88 changed files with 6404 additions and 158 deletions
+5
View File
@@ -10,6 +10,11 @@
<!-- Light and dark are both supported (ADR 0060). The browser paints its own canvas in the
system's scheme before any script runs, so a dark system never flashes white first. -->
<meta content="light dark" name="color-scheme" />
<!-- The pre-paint appearance script (ADR 0062): classic, same-origin, and render-blocking on
purpose, so a person who chose Light or Dark sees it from the first frame. It reads this
browser's cached copy of their choice (two words, nothing about the account) and sets the
theme attributes and the colour scheme above. Never inline. -->
<script src="/assets/ekklesia-dashboard/appearance.js"></script>
<title>Ekklesia</title>
</head>
<body
+24
View File
@@ -67,6 +67,27 @@ const buildOptions = {
target: "es2022",
};
/**
* The Dashboard's pre-paint appearance script (ADR 0062): a **classic** script, not a module,
* because only a classic script in `<head>` runs before the body is parsed and painted. Built
* separately as an IIFE, with nothing shared with the main bundle at run time, so it stays a few
* hundred bytes and adds no import to a render-blocking request.
*/
/** @type {import("esbuild").BuildOptions} */
const appearanceBootOptions = {
...buildOptions,
entryPoints: {
"assets/ekklesia-dashboard/appearance": path.join(
packageDirectory,
"src",
"browser",
"appearance-boot.ts",
),
},
format: "iife",
sourcemap: false,
};
await rm(outputDirectory, { force: true, recursive: true });
await mkdir(outputDirectory, { recursive: true });
await mkdir(path.join(outputDirectory, "display"), { recursive: true });
@@ -87,6 +108,8 @@ await copyFile(
);
if (serve) {
const appearanceBootContext = await esbuild.context(appearanceBootOptions);
await appearanceBootContext.watch();
const context = await esbuild.context(buildOptions);
await context.watch();
// Any path with no file behind it gets the Dashboard page, so a refreshed deep link such as
@@ -104,5 +127,6 @@ if (serve) {
);
} else {
await esbuild.build(buildOptions);
await esbuild.build(appearanceBootOptions);
console.log(`Built the Ekklesia shell into ${path.relative(packageDirectory, outputDirectory)}`);
}
@@ -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>
);
}
+125 -44
View File
@@ -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}
+359
View File
@@ -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>
+84 -8
View File
@@ -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" ? (
+19 -2
View File
@@ -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) },
}),
});
}
+54 -26
View File
@@ -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,
+123
View File
@@ -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;
}
+4
View File
@@ -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,
});
+37 -1
View File
@@ -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}`;
}
}
+106 -5
View File
@@ -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 };
}
+6
View File
@@ -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,
@@ -0,0 +1,530 @@
import {
createEkklesiaThemeController,
DESIGN_TOKEN_APPEARANCE_ATTRIBUTE,
DESIGN_TOKEN_MOTION_ATTRIBUTE,
} from "@ekklesia/design-tokens";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
type AppearanceChoice,
createMemoryAppearanceCache,
} from "../src/browser/appearance-cache.js";
import { createAppearancePreference } from "../src/browser/appearance-preference.js";
import { HostApp } from "../src/browser/HostApp.js";
import { createMemoryHostHistory, type MemoryHostHistory } from "../src/browser/host-history.js";
import { createMemoryHostViewport } from "../src/browser/host-viewport.js";
import { createMemorySidebarPreference } from "../src/browser/sidebar-preference.js";
declare global {
// eslint-disable-next-line no-var
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
}
/**
* The account area (ADR 0062): the account control's real identity, `/account`,
* `/account/appearance`, and how a chosen appearance and motion take effect, persist, and are
* reconciled with the account.
*/
const apiBaseUrl = "https://ekklesia.example.test";
const ADA = {
email: "[email protected]",
name: "Ada Lovelace",
preferences: { appearance: "dark", motion: "reduce" },
} as const;
let container: HTMLDivElement;
let root: Root;
let consoleError: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
});
afterEach(async () => {
await act(async () => {
root.unmount();
});
container.remove();
consoleError.mockRestore();
});
interface Deferred {
readonly resolve: (response: Response) => void;
}
interface RenderOptions {
readonly account?:
| typeof ADA
| { readonly email: string; readonly name: string; readonly preferences: AppearanceChoice }
| "fails";
readonly cached?: AppearanceChoice;
readonly compact?: boolean;
readonly history?: MemoryHostHistory;
/** Answers each preferences PATCH; by default the server merges the change, as the API does. */
readonly save?: (body: Record<string, string>) => Response | "hold";
readonly principalAfterSave?: "authenticated" | "anonymous";
}
async function render(path: string, options: RenderOptions = {}) {
const history = options.history ?? createMemoryHostHistory(path);
const calls: { method: string; path: string; body?: unknown }[] = [];
const held: Deferred[] = [];
const titles: string[] = [];
const rootAttributes = new Map<string, string>();
const meta = new Map<string, string>();
const cache = createMemoryAppearanceCache(options.cached);
const appearance = createAppearancePreference({
cache,
colorSchemeMeta: { setAttribute: (name, value) => meta.set(name, value) },
theme: createEkklesiaThemeController({
appearance: options.cached?.appearance ?? "system",
motion: options.cached?.motion ?? "system",
target: { setAttribute: (name, value) => rootAttributes.set(name, value) },
}),
});
let stored =
options.account === "fails" ? undefined : { ...(options.account ?? ADA).preferences };
let signedIn = true;
const fetchImplementation = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = new URL(String(input));
const method = init?.method ?? "GET";
const body =
typeof init?.body === "string"
? (JSON.parse(init.body) as Record<string, string>)
: undefined;
calls.push({ body, method, path: url.pathname });
if (url.pathname === "/api/v1/principal") {
return Response.json(
signedIn ? { kind: "authenticated", userId: "user-1" } : { kind: "anonymous" },
);
}
if (url.pathname === "/api/auth/sign-out") {
signedIn = false;
return Response.json({ success: true });
}
if (url.pathname === "/api/v1/dashboard/navigation") {
return Response.json({ groups: [], ungrouped: [] });
}
if (url.pathname === "/api/v1/dashboard/settings") {
return Response.json({ modules: [], sections: [] });
}
if (url.pathname === "/api/v1/account" && method === "GET") {
return options.account === "fails"
? Response.json({ code: "ACCOUNT_READ_FAILED" }, { status: 503 })
: Response.json({ ...(options.account ?? ADA), preferences: stored });
}
if (url.pathname === "/api/v1/account/preferences" && method === "PATCH" && body) {
const answer = options.save?.(body);
if (answer === "hold") {
return new Promise<Response>((resolve) => held.push({ resolve }));
}
if (answer) {
if (answer.status === 403 && options.principalAfterSave === "anonymous") {
signedIn = false;
}
return answer;
}
stored = {
...(stored ?? { appearance: "system", motion: "system" }),
...body,
} as AppearanceChoice;
return Response.json(stored);
}
return Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 });
}) as unknown as typeof fetch;
await act(async () => {
root.render(
<HostApp
appearance={appearance}
config={{ apiBaseUrl, fetch: fetchImplementation }}
history={history}
setDocumentTitle={(title) => titles.push(title)}
sidebarPreference={createMemorySidebarPreference()}
viewport={createMemoryHostViewport(options.compact ?? false)}
/>,
);
});
return {
appearance,
cache,
calls,
held,
history,
meta,
patches: () => calls.filter((call) => call.method === "PATCH"),
rootAttribute: (name: string) => rootAttributes.get(name),
titles,
};
}
function query(testId: string): HTMLElement | null {
return container.querySelector<HTMLElement>(`[data-testid="${testId}"]`);
}
function required(testId: string): HTMLElement {
const element = query(testId);
if (!element) {
throw new Error(`missing ${testId}`);
}
return element;
}
async function click(element: Element): Promise<void> {
await act(async () => {
element.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, cancelable: true }));
});
}
function radio(group: "appearance" | "motion", value: string): HTMLInputElement {
const input = required(`appearance-${group}-${value}`).querySelector("input");
if (!(input instanceof HTMLInputElement)) {
throw new Error(`missing ${group} ${value} radio`);
}
return input;
}
function checked(group: "appearance" | "motion"): string | undefined {
return (
required(`appearance-${group}`).querySelector<HTMLInputElement>("input:checked")?.value ??
undefined
);
}
async function choose(group: "appearance" | "motion", value: string): Promise<void> {
await click(radio(group, value));
}
describe("the account control", () => {
it("names who is signed in: their name and email beside their initials", async () => {
await render("/");
const trigger = required("host-account-menu");
expect(trigger.textContent).toContain("Ada Lovelace");
expect(trigger.textContent).toContain("[email protected]");
expect(trigger.querySelector('[data-testid="account-avatar"]')?.textContent).toBe("AL");
// Initials are decoration; the name is what assistive technology hears.
expect(
trigger.querySelector('[data-testid="account-avatar"]')?.getAttribute("aria-hidden"),
).toBe("true");
// No image is ever loaded for an account, and the opaque user id is never shown.
expect(container.querySelector("img")).toBeNull();
expect(container.textContent).not.toContain("user-1");
});
it("repeats who is signed in inside the menu, above its three destinations", async () => {
await render("/");
await click(required("host-account-menu"));
expect(required("host-account-identity").textContent).toBe("Ada [email protected]");
expect(
[...required("host-account-menu-list").querySelectorAll('[role="menuitem"]')].map(
(item) => item.textContent,
),
).toEqual(["Account", "Appearance", "Sign out"]);
});
it("keeps the name in the accessible name of the collapsed control", async () => {
await render("/");
await click(container.querySelector('[aria-label="Collapse navigation"]') as Element);
const trigger = required("host-account-menu");
expect(trigger.querySelector(".ek-shell-visually-hidden")?.textContent).toBe("Ada Lovelace");
expect(trigger.querySelector('[data-testid="account-avatar"]')).not.toBeNull();
});
it("opens Account and Appearance in place, closing the menu", async () => {
const { history } = await render("/");
await click(required("host-account-menu"));
await click(required("host-appearance-link"));
expect(history.entries).toEqual(["/", "/account/appearance"]);
expect(query("account-appearance")).not.toBeNull();
expect(query("host-account-menu-list")).toBeNull();
await click(required("host-account-menu"));
await click(required("host-account-link"));
expect(history.pathname()).toBe("/account");
expect(query("account-home")).not.toBeNull();
});
it("names the area instead of the person when the account cannot be loaded", async () => {
await render("/", { account: "fails" });
expect(required("host-account-menu").textContent).toBe("Your account");
expect(query("account-avatar")).toBeNull();
await click(required("host-account-menu"));
expect(query("host-account-identity")).toBeNull();
// Every destination still works.
expect(query("host-appearance-link")).not.toBeNull();
});
});
describe("/account", () => {
it("shows who you are signed in as and links to your own settings — and no administration", async () => {
const { titles } = await render("/account");
const page = required("account-home");
expect(page.querySelector("h1")?.textContent).toBe("Account");
expect(required("account-name").textContent).toBe("Ada Lovelace");
expect(required("account-email").textContent).toBe("[email protected]");
expect(required("account-home-appearance").getAttribute("href")).toBe("/account/appearance");
expect(titles.at(-1)).toBe("Account");
// Nothing editable pretends to exist: no profile form.
expect(page.querySelector("input, textarea, select")).toBeNull();
// Personal, not Settings: the Settings hierarchy does not open, and no nav item is current.
expect(container.querySelector('nav [aria-current="page"]')).toBeNull();
});
it("shows only the email when the name is blank", async () => {
await render("/account", {
account: {
email: "[email protected]",
name: " ",
preferences: { appearance: "system", motion: "system" },
},
});
expect(query("account-name")).toBeNull();
expect(required("account-email").textContent).toBe("[email protected]");
expect(required("host-account-menu").textContent).toContain("[email protected]");
});
it("signs out, and the signed-out page follows the system with no cached choice", async () => {
const { appearance, cache, calls } = await render("/account");
expect(appearance.current()).toEqual(ADA.preferences);
await click(required("account-sign-out"));
expect(query("host-login")).not.toBeNull();
expect(calls.some((call) => call.path === "/api/auth/sign-out")).toBe(true);
expect(appearance.current()).toEqual({ appearance: "system", motion: "system" });
expect(cache.read()).toBeUndefined();
});
it("says when the account could not be loaded, with Retry, and the Dashboard still works", async () => {
await render("/account", { account: "fails" });
expect(required("account-load-failed").textContent).toContain(
"Your account could not be loaded",
);
expect(query("host-sidebar")).not.toBeNull();
});
it("opens the same page after a refresh, and Back and Forward move between the account pages", async () => {
const history = createMemoryHostHistory("/account");
await render("/account", { history });
await click(required("account-home-appearance"));
expect(query("account-appearance")).not.toBeNull();
await act(async () => history.go(-1));
expect(query("account-home")).not.toBeNull();
await act(async () => history.go(1));
expect(query("account-appearance")).not.toBeNull();
await act(async () => {
root.unmount();
});
root = createRoot(container);
await render("/account/appearance");
expect(query("account-appearance")).not.toBeNull();
});
it("is not found beneath it for a page that does not exist", async () => {
await render("/account/profile");
expect(query("host-not-found")).not.toBeNull();
});
});
describe("/account/appearance", () => {
it("shows the account's choices as two native radio groups, with Back to Account", async () => {
const { titles } = await render("/account/appearance");
const page = required("account-appearance");
expect(page.querySelector("h1")?.textContent).toBe("Appearance");
expect(titles.at(-1)).toBe("Appearance");
const back = page.querySelector<HTMLAnchorElement>(".ek-page-header__back");
expect(back?.getAttribute("href")).toBe("/account");
expect(back?.textContent).toContain("Back to Account");
const groups = [...page.querySelectorAll("fieldset")];
expect(groups.map((group) => group.querySelector("legend")?.textContent)).toEqual([
"Appearance",
"Reduced motion",
]);
for (const group of groups) {
// A real radio group: one name, each option labelled, and described by its group's line.
const inputs = [...group.querySelectorAll<HTMLInputElement>('input[type="radio"]')];
expect(inputs).toHaveLength(3);
expect(new Set(inputs.map((input) => input.name)).size).toBe(1);
for (const input of inputs) {
expect(input.closest("label")?.textContent?.length).toBeGreaterThan(0);
}
expect(group.getAttribute("aria-describedby")).toBeTruthy();
}
expect(
[...page.querySelectorAll('input[name="appearance"]')].map(
(input) => (input as HTMLInputElement).value,
),
).toEqual(["system", "light", "dark"]);
expect(
[...page.querySelectorAll('input[name="motion"]')].map(
(input) => (input as HTMLInputElement).value,
),
).toEqual(["system", "reduce", "allow"]);
expect(checked("appearance")).toBe("dark");
expect(checked("motion")).toBe("reduce");
});
it("gives every option a full-width touch target and marks the choice by more than colour", async () => {
await render("/account/appearance", { compact: true });
for (const option of container.querySelectorAll<HTMLElement>(".ek-shell-choice")) {
expect(option.style.minHeight).toBe("var(--ekklesia-control-height-lg)");
}
expect(required("appearance-appearance-dark").dataset.checked).toBe("true");
expect(required("appearance-appearance-light").dataset.checked).toBeUndefined();
});
it.each([
["appearance", "light"],
["appearance", "system"],
["motion", "allow"],
["motion", "system"],
] as const)("applies %s %s at once and saves it to the account", async (group, value) => {
const { cache, patches, rootAttribute, history } = await render("/account/appearance");
await choose(group, value);
const attribute =
group === "appearance" ? DESIGN_TOKEN_APPEARANCE_ATTRIBUTE : DESIGN_TOKEN_MOTION_ATTRIBUTE;
expect(rootAttribute(attribute)).toBe(value);
expect(patches()).toEqual([
{ body: { [group]: value }, method: "PATCH", path: "/api/v1/account/preferences" },
]);
expect(checked(group)).toBe(value);
expect(cache.read()?.[group]).toBe(value);
// No reload, no navigation: the same page, the same history.
expect(history.entries).toEqual(["/account/appearance"]);
expect(required("appearance-status").textContent).toBe("Saved to your account.");
});
it("switches before the server has answered, so the change is immediate", async () => {
const { held, rootAttribute } = await render("/account/appearance", { save: () => "hold" });
await choose("appearance", "light");
expect(rootAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe("light");
expect(held).toHaveLength(1);
await act(async () => {
held[0]?.resolve(Response.json({ appearance: "light", motion: "reduce" }));
});
expect(rootAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe("light");
});
it("applies what the server answered, which is the source of truth", async () => {
const { rootAttribute } = await render("/account/appearance", {
save: () => Response.json({ appearance: "light", motion: "allow" }),
});
await choose("appearance", "light");
// Motion changed elsewhere in the meantime; the account's answer wins.
expect(rootAttribute(DESIGN_TOKEN_MOTION_ATTRIBUTE)).toBe("allow");
expect(checked("motion")).toBe("allow");
});
it("puts the choice back and says so when it could not be saved", async () => {
const { cache, rootAttribute } = await render("/account/appearance", {
save: () => Response.json({ code: "ACCOUNT_WRITE_FAILED" }, { status: 503 }),
});
await choose("appearance", "light");
expect(rootAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe("dark");
expect(checked("appearance")).toBe("dark");
expect(cache.read()).toEqual(ADA.preferences);
const notice = required("appearance-status").querySelector('[role="alert"]');
expect(notice?.textContent).toContain("Your choice wasn't saved");
});
it("checks the session when a save is refused, and signs the page out if it has ended", async () => {
const { appearance } = await render("/account/appearance", {
principalAfterSave: "anonymous",
save: () => Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 }),
});
await choose("motion", "allow");
expect(query("host-login")).not.toBeNull();
expect(appearance.current()).toEqual({ appearance: "system", motion: "system" });
});
it("follows a change made in another tab, without saving it again", async () => {
const { appearance, cache, patches } = await render("/account/appearance");
await act(async () => {
cache.write({ appearance: "light", motion: "allow" });
appearance.syncFromCache();
});
expect(checked("appearance")).toBe("light");
expect(checked("motion")).toBe("allow");
expect(patches()).toEqual([]);
});
it("is reachable on a phone from the drawer's account menu, which closes on choosing", async () => {
await render("/", { compact: true });
await click(required("mobile-navigation-trigger"));
await click(required("host-account-menu"));
await click(required("host-appearance-link"));
expect(query("account-appearance")).not.toBeNull();
expect(query("navigation-drawer")).toBeNull();
});
});
describe("reconciling with the account", () => {
it("replaces this browser's cached choice with the account's on every load", async () => {
const { appearance, cache, meta, rootAttribute } = await render("/", {
cached: { appearance: "light", motion: "allow" },
});
expect(appearance.current()).toEqual(ADA.preferences);
expect(rootAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe("dark");
expect(rootAttribute(DESIGN_TOKEN_MOTION_ATTRIBUTE)).toBe("reduce");
expect(meta.get("content")).toBe("dark");
expect(cache.read()).toEqual(ADA.preferences);
});
it("keeps the cached choice when the account cannot be loaded, rather than guessing", async () => {
const { appearance } = await render("/", {
account: "fails",
cached: { appearance: "light", motion: "allow" },
});
expect(appearance.current()).toEqual({ appearance: "light", motion: "allow" });
});
it("follows the system on the signed-out page and forgets the previous person's choice", async () => {
const { appearance, cache } = await render("/", {
cached: { appearance: "dark", motion: "reduce" },
});
await click(required("host-account-menu"));
await click(required("host-logout"));
expect(query("host-login")).not.toBeNull();
expect(appearance.current()).toEqual({ appearance: "system", motion: "system" });
expect(cache.read()).toBeUndefined();
});
});
@@ -0,0 +1,301 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
createEkklesiaThemeController,
DESIGN_TOKEN_APPEARANCE_ATTRIBUTE,
DESIGN_TOKEN_MOTION_ATTRIBUTE,
EKKLESIA_APPEARANCES,
EKKLESIA_MOTION_PREFERENCES,
} from "@ekklesia/design-tokens";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { accountDisplayName, accountInitials } from "../src/browser/AccountAvatar.js";
import {
APPEARANCE_CACHE_KEY,
createLocalAppearanceCache,
createMemoryAppearanceCache,
parseAppearanceCache,
serializeAppearanceChoice,
} from "../src/browser/appearance-cache.js";
import { createAppearancePreference } from "../src/browser/appearance-preference.js";
/**
* The browser half of account appearance (ADR 0062): the pre-paint cache, the pre-paint script,
* and the one handle that puts a choice into effect through the ADR 0060 theme controller.
*/
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
function memoryStorage(initial: Record<string, string> = {}) {
const values = new Map(Object.entries(initial));
return {
getItem: vi.fn((key: string) => values.get(key) ?? null),
removeItem: vi.fn((key: string) => {
values.delete(key);
}),
setItem: vi.fn((key: string, value: string) => {
values.set(key, value);
}),
values,
};
}
function throwingStorage() {
const fail = () => {
throw new Error("SecurityError: storage is disabled");
};
return { getItem: vi.fn(fail), removeItem: vi.fn(fail), setItem: vi.fn(fail) };
}
function recordingTarget() {
const attributes = new Map<string, string>();
return {
attributes,
setAttribute: (name: string, value: string) => {
attributes.set(name, value);
},
};
}
describe("the appearance cache", () => {
it("accepts every combination of the design tokens' own values, and round-trips each", () => {
for (const appearance of EKKLESIA_APPEARANCES) {
for (const motion of EKKLESIA_MOTION_PREFERENCES) {
const serialized = serializeAppearanceChoice({ appearance, motion });
expect(parseAppearanceCache(serialized)).toEqual({ appearance, motion });
}
}
});
it("refuses anything else, so a stale or tampered value is no cache at all", () => {
for (const value of [
null,
undefined,
"",
"dark",
"dark reduce extra",
"Dark reduce",
"dark reduce",
"sepia reduce",
"dark none",
'{"appearance":"dark","motion":"reduce"}',
42,
]) {
expect(parseAppearanceCache(value), String(value)).toBeUndefined();
}
});
it("holds two words under one namespaced key, and nothing about the account", () => {
const storage = memoryStorage();
const cache = createLocalAppearanceCache(storage);
cache.write({ appearance: "dark", motion: "reduce" });
expect([...storage.values.entries()]).toEqual([[APPEARANCE_CACHE_KEY, "dark reduce"]]);
expect(APPEARANCE_CACHE_KEY).toBe("ekklesia.dashboard.appearance");
expect(cache.read()).toEqual({ appearance: "dark", motion: "reduce" });
cache.clear();
expect(storage.values.size).toBe(0);
expect(cache.read()).toBeUndefined();
});
it("never breaks when storage is missing or throws", () => {
for (const storage of [undefined, throwingStorage()]) {
const cache = createLocalAppearanceCache(storage);
expect(() => cache.write({ appearance: "dark", motion: "allow" })).not.toThrow();
expect(cache.read()).toBeUndefined();
expect(() => cache.clear()).not.toThrow();
}
});
});
describe("the appearance preference", () => {
function build(initial?: {
appearance: "system" | "light" | "dark";
motion: "system" | "reduce" | "allow";
}) {
const root = recordingTarget();
const meta = recordingTarget();
const cache = createMemoryAppearanceCache(initial);
const theme = createEkklesiaThemeController({
appearance: initial?.appearance ?? "system",
motion: initial?.motion ?? "system",
target: root,
});
const preference = createAppearancePreference({ cache, colorSchemeMeta: meta, theme });
return { cache, meta, preference, root, theme };
}
it("drives the ADR 0060 theme controller: the root's two attributes are the whole effect", () => {
const { preference, root, theme } = build();
preference.apply({ appearance: "dark", motion: "reduce" });
expect(root.attributes.get(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe("dark");
expect(root.attributes.get(DESIGN_TOKEN_MOTION_ATTRIBUTE)).toBe("reduce");
expect(theme.colorScheme()).toBe("dark");
expect(theme.reducedMotion()).toBe(true);
});
it("remembers what it applied for the next first paint, and narrows the colour scheme with it", () => {
const { cache, meta, preference } = build();
preference.apply({ appearance: "light", motion: "allow" });
expect(cache.read()).toEqual({ appearance: "light", motion: "allow" });
expect(meta.attributes.get("content")).toBe("light");
preference.apply({ appearance: "system", motion: "allow" });
expect(meta.attributes.get("content")).toBe("light dark");
});
it("gives a stable snapshot between changes, and notifies only on a real change", () => {
const { preference } = build();
const listener = vi.fn();
preference.subscribe(listener);
const before = preference.current();
expect(preference.current()).toBe(before);
preference.apply({ appearance: "system", motion: "system" });
expect(listener).not.toHaveBeenCalled();
expect(preference.current()).toBe(before);
preference.apply({ appearance: "dark", motion: "system" });
expect(listener).toHaveBeenCalledOnce();
expect(preference.current()).toEqual({ appearance: "dark", motion: "system" });
});
it("follows another tab through the cache, without writing it back", () => {
const { cache, preference } = build({ appearance: "light", motion: "system" });
const write = vi.spyOn(cache, "write");
const listener = vi.fn();
preference.subscribe(listener);
cache.write({ appearance: "dark", motion: "reduce" });
write.mockClear();
preference.syncFromCache();
expect(preference.current()).toEqual({ appearance: "dark", motion: "reduce" });
expect(listener).toHaveBeenCalledOnce();
expect(write).not.toHaveBeenCalled();
});
it("follows another tab's sign-out back to the system", () => {
const { cache, preference } = build({ appearance: "dark", motion: "reduce" });
cache.clear();
preference.syncFromCache();
expect(preference.current()).toEqual({ appearance: "system", motion: "system" });
});
it("forgets on sign-out: the system again, and no cached choice for the next person", () => {
const { cache, meta, preference, root } = build({ appearance: "dark", motion: "reduce" });
preference.forget();
expect(cache.read()).toBeUndefined();
expect(root.attributes.get(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe("system");
expect(root.attributes.get(DESIGN_TOKEN_MOTION_ATTRIBUTE)).toBe("system");
expect(meta.attributes.get("content")).toBe("light dark");
});
});
describe("the pre-paint appearance script", () => {
let meta: HTMLMetaElement;
beforeEach(() => {
vi.resetModules();
window.localStorage.clear();
document.documentElement.removeAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE);
document.documentElement.removeAttribute(DESIGN_TOKEN_MOTION_ATTRIBUTE);
meta = document.createElement("meta");
meta.setAttribute("name", "color-scheme");
meta.setAttribute("content", "light dark");
document.head.append(meta);
});
afterEach(() => {
meta.remove();
window.localStorage.clear();
vi.restoreAllMocks();
});
async function runBootScript(): Promise<void> {
await import("../src/browser/appearance-boot.js");
}
it("puts a cached explicit choice on the root and narrows the colour scheme, before anything renders", async () => {
window.localStorage.setItem(APPEARANCE_CACHE_KEY, "dark reduce");
await runBootScript();
expect(document.documentElement.getAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe("dark");
expect(document.documentElement.getAttribute(DESIGN_TOKEN_MOTION_ATTRIBUTE)).toBe("reduce");
expect(meta.getAttribute("content")).toBe("dark");
});
it("leaves the colour scheme following the system for a cached System choice", async () => {
window.localStorage.setItem(APPEARANCE_CACHE_KEY, "system allow");
await runBootScript();
expect(document.documentElement.getAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe("system");
expect(document.documentElement.getAttribute(DESIGN_TOKEN_MOTION_ATTRIBUTE)).toBe("allow");
expect(meta.getAttribute("content")).toBe("light dark");
});
it("does nothing without a valid cache", async () => {
window.localStorage.setItem(APPEARANCE_CACHE_KEY, "neon reduce");
await runBootScript();
expect(document.documentElement.hasAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe(false);
expect(meta.getAttribute("content")).toBe("light dark");
});
it("does nothing, and throws nothing, where storage is blocked", async () => {
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
throw new Error("SecurityError");
});
await expect(runBootScript()).resolves.toBeUndefined();
expect(document.documentElement.hasAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe(false);
});
it("names exactly the token stylesheet's attributes, and reads storage without ever writing it", async () => {
const source = await readFile(
path.join(packageDirectory, "src", "browser", "appearance-boot.ts"),
"utf8",
);
expect(source).toContain(`"${DESIGN_TOKEN_APPEARANCE_ATTRIBUTE}"`);
expect(source).toContain(`"${DESIGN_TOKEN_MOTION_ATTRIBUTE}"`);
expect(source).not.toMatch(/setItem|removeItem|fetch|cookie/u);
// Types only from the token package, so the render-blocking script stays tiny.
const cacheSource = await readFile(
path.join(packageDirectory, "src", "browser", "appearance-cache.ts"),
"utf8",
);
expect(cacheSource).toMatch(/^import type \{[^}]*\} from "@ekklesia\/design-tokens";$/mu);
});
});
describe("account initials", () => {
it.each([
[{ email: "[email protected]", name: "Ada Lovelace" }, "AL"],
[{ email: "[email protected]", name: "Ada King Lovelace" }, "AL"],
[{ email: "[email protected]", name: " ada lovelace " }, "AL"],
[{ email: "[email protected]", name: "Cher" }, "C"],
[{ email: "[email protected]", name: "Émile Zola" }, "ÉZ"],
[{ email: "[email protected]", name: " " }, "B"],
])("draws %o as %s", (account, initials) => {
expect(accountInitials(account)).toBe(initials);
});
it("shows the email as the name when the name is blank", () => {
expect(accountDisplayName({ email: "[email protected]", name: " " })).toBe(
"[email protected]",
);
expect(accountDisplayName({ email: "[email protected]", name: "Ada" })).toBe("Ada");
});
});
+22 -3
View File
@@ -8,9 +8,13 @@ import { dashboardHostAssetUrls } from "../src/dashboard-assets.js";
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
describe("Dashboard host asset locator", () => {
it("exports only frozen immutable href strings for the two exact built files, never the source map", () => {
it("exports only frozen immutable href strings for the three exact built files, never the source map", () => {
expect(Object.isFrozen(dashboardHostAssetUrls)).toBe(true);
expect(dashboardHostAssetUrls).toEqual({
appearance: new URL(
"dist/browser/assets/ekklesia-dashboard/appearance.js",
`file://${packageDirectory}/`,
).href,
html: new URL("dist/browser/index.html", `file://${packageDirectory}/`).href,
script: new URL(
"dist/browser/assets/ekklesia-dashboard/app.js",
@@ -43,14 +47,28 @@ describe("Dashboard host asset locator", () => {
expect(html).toContain("color: var(--ekklesia-text-primary);");
});
it("loads exactly one fixed, absolute, same-origin script and no inline script", async () => {
it("loads exactly two fixed, absolute, same-origin scripts and no inline script", async () => {
const html = await readFile(path.join(packageDirectory, "index.html"), "utf8");
expect(html).toContain('id="ekklesia-host"');
expect(html.match(/<script\b/gu)).toHaveLength(1);
expect(html.match(/<script\b/gu)).toHaveLength(2);
expect(html).toContain(
'<script src="/assets/ekklesia-dashboard/app.js" type="module"></script>',
);
expect(html).toContain('<script src="/assets/ekklesia-dashboard/appearance.js"></script>');
// Every script names a source: there is no inline script for a strict script-src to excuse.
expect(html.match(/<script(?![^>]*\bsrc=")/gu)).toBeNull();
});
it("applies the cached appearance before first paint: a classic script in <head>, after the colour-scheme element it narrows (ADR 0062)", async () => {
const html = await readFile(path.join(packageDirectory, "index.html"), "utf8");
const head = html.slice(html.indexOf("<head>"), html.indexOf("</head>"));
const boot = head.indexOf('<script src="/assets/ekklesia-dashboard/appearance.js"></script>');
// Classic (no `type="module"`, no `defer`, no `async`), so it blocks rendering until it ran.
expect(boot).toBeGreaterThan(-1);
expect(head.indexOf('name="color-scheme"')).toBeLessThan(boot);
expect(head.slice(boot)).not.toMatch(/appearance\.js"[^>]*(?:type=|defer|async)/u);
});
it("carries exactly one empty API-origin meta element for the serving API to fill (ADR 0045)", async () => {
@@ -76,5 +94,6 @@ describe("Dashboard host asset locator", () => {
);
expect(buildScript).toContain('"assets/ekklesia-dashboard/app": path.join(');
expect(buildScript).toContain('"assets/ekklesia-dashboard/appearance": path.join(');
});
});
+30 -2
View File
@@ -4,6 +4,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import {
ACCOUNT_SECTIONS,
DASHBOARD_PLATFORM_PAGES,
dashboardPath,
parseDashboardPath,
@@ -37,8 +38,35 @@ describe("parseDashboardPath", () => {
it("never reads a platform page as a module page", () => {
expect(parseDashboardPath("/settings")).toEqual({ kind: "settings" });
for (const pathname of ["/account", "/account/appearance"]) {
expect(parseDashboardPath(pathname), pathname).toEqual({ kind: "not-found" });
expect(parseDashboardPath("/account")).toEqual({ kind: "account" });
});
it("reads the account area: the account page and its personal settings pages (ADR 0062)", () => {
expect(parseDashboardPath("/account")).toEqual({ kind: "account" });
expect(parseDashboardPath("/account/")).toEqual({ kind: "account" });
expect(parseDashboardPath("/account/appearance")).toEqual({
kind: "account-section",
section: "appearance",
});
expect(parseDashboardPath("/account/appearance/")).toEqual({
kind: "account-section",
section: "appearance",
});
expect(dashboardPath({ kind: "account" })).toBe("/account");
expect(dashboardPath({ kind: "account-section", section: "appearance" })).toBe(
"/account/appearance",
);
expect(ACCOUNT_SECTIONS).toEqual(["appearance"]);
for (const pathname of [
"/account/profile",
"/account/appearance/extra",
"/account/Appearance",
"/account//appearance",
"/accounts",
]) {
expect(parseDashboardPath(pathname), pathname).toEqual(
pathname === "/accounts" ? { kind: "module", moduleId: "accounts" } : { kind: "not-found" },
);
}
});
+2
View File
@@ -484,6 +484,8 @@ describe("Ekklesia shell — browser authentication", () => {
"/api/v1/dashboard/navigation",
// Which Settings destinations the person may see, asked alongside the navigation (ADR 0061).
"/api/v1/dashboard/settings",
// Who is signed in and their own preferences, asked alongside them too (ADR 0062).
"/api/v1/account",
// The host-owned device panel loads with the rest of the shell for an administrator.
"/api/v1/devices",
]);
+116
View File
@@ -4,11 +4,15 @@ import {
type HostModuleUiDescriptor,
HostProtocolError,
loadDashboardNavigation,
loadHostAccount,
loadHostPrincipal,
parseDashboardNavigation,
parseDashboardSettings,
parseHostAccount,
parseHostAccountPreferences,
parseHostPrincipal,
resolveApiOrigin,
saveHostAccountPreferences,
} from "../src/host-protocol.js";
import { createModuleUiContext, moduleUiSourceUrl } from "../src/module-context.js";
@@ -37,6 +41,12 @@ function stubFetch(routes: Readonly<Record<string, () => Response>>) {
return { calls, implementation: implementation as unknown as typeof fetch };
}
const accountPayload = {
email: "[email protected]",
name: "Ada Lovelace",
preferences: { appearance: "dark", motion: "reduce" },
};
const inventoryPayload = {
modules: [
{
@@ -304,6 +314,7 @@ describe("host protocol — bootstrap", () => {
sections: ["displays"],
}),
"/api/v1/principal": () => jsonResponse({ kind: "authenticated", userId: "user-1" }),
"/api/v1/account": () => jsonResponse(accountPayload),
});
const bootstrap = await bootstrapHost({ apiBaseUrl, fetch: implementation });
@@ -312,7 +323,9 @@ describe("host protocol — bootstrap", () => {
`${apiBaseUrl}/api/v1/principal`,
`${apiBaseUrl}/api/v1/dashboard/navigation`,
`${apiBaseUrl}/api/v1/dashboard/settings`,
`${apiBaseUrl}/api/v1/account`,
]);
expect(bootstrap.account).toEqual(accountPayload);
expect(bootstrap.principal).toEqual({ kind: "authenticated", userId: "user-1" });
expect(bootstrap.navigation.groups[0]?.modules[0]?.descriptor).toEqual(exampleDescriptor);
expect(bootstrap.settings).toEqual({
@@ -571,3 +584,106 @@ describe("module UI host context", () => {
);
});
});
describe("host protocol — the signed-in person's account (ADR 0062)", () => {
it("reads the account strictly: exactly name, email, and two known preferences", () => {
expect(parseHostAccount(accountPayload)).toEqual(accountPayload);
for (const payload of [
null,
{},
{ ...accountPayload, image: "https://tracker.example.test/me.png" },
{ ...accountPayload, userId: "user-1" },
{ ...accountPayload, email: "" },
{ ...accountPayload, email: 1 },
{ ...accountPayload, name: null },
{ ...accountPayload, preferences: { appearance: "sepia", motion: "reduce" } },
{ ...accountPayload, preferences: { appearance: "dark", motion: "none" } },
{ ...accountPayload, preferences: { appearance: "dark" } },
{ ...accountPayload, preferences: { appearance: "dark", motion: "allow", radius: "lg" } },
]) {
expect(() => parseHostAccount(payload), JSON.stringify(payload)).toThrowError(
expect.objectContaining({ code: "HOST_RESPONSE_INVALID" }),
);
}
});
it.each([
["system", "system"],
["light", "reduce"],
["dark", "allow"],
] as const)("accepts appearance %s with motion %s", (appearance, motion) => {
expect(parseHostAccountPreferences({ appearance, motion })).toEqual({ appearance, motion });
});
it("loads the account from its one platform path, with credentials, naming nobody", async () => {
const { calls, implementation } = stubFetch({
"/api/v1/account": () => jsonResponse(accountPayload),
});
expect(await loadHostAccount({ apiBaseUrl, fetch: implementation })).toEqual(accountPayload);
expect(calls).toHaveLength(1);
expect(calls[0]?.url).toBe(`${apiBaseUrl}/api/v1/account`);
expect(calls[0]?.init?.credentials).toBe("include");
});
it("saves a change with one credentialed PATCH of exactly the change, and returns the server's answer", async () => {
const { calls, implementation } = stubFetch({
"/api/v1/account/preferences": () => jsonResponse({ appearance: "light", motion: "reduce" }),
});
const saved = await saveHostAccountPreferences(
{ apiBaseUrl, fetch: implementation },
{ appearance: "light" },
);
expect(saved).toEqual({ appearance: "light", motion: "reduce" });
expect(calls).toHaveLength(1);
expect(calls[0]?.url).toBe(`${apiBaseUrl}/api/v1/account/preferences`);
expect(calls[0]?.init?.method).toBe("PATCH");
expect(calls[0]?.init?.credentials).toBe("include");
expect(calls[0]?.init?.redirect).toBe("error");
expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({ appearance: "light" });
});
it("reports a refused save as signed out, and a failed one as unreachable", async () => {
for (const [status, code] of [
[403, "HOST_UNAUTHENTICATED"],
[503, "HOST_REQUEST_FAILED"],
[400, "HOST_REQUEST_FAILED"],
] as const) {
const { implementation } = stubFetch({
"/api/v1/account/preferences": () => jsonResponse({ code: "X" }, status),
});
await expect(
saveHostAccountPreferences({ apiBaseUrl, fetch: implementation }, { motion: "allow" }),
).rejects.toMatchObject({ code });
}
});
it("keeps the Dashboard working when the account cannot be loaded", async () => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
const { implementation } = stubFetch({
"/api/v1/account": () => jsonResponse({ code: "ACCOUNT_READ_FAILED" }, 503),
"/api/v1/dashboard/navigation": () => jsonResponse(navigationPayload),
"/api/v1/dashboard/settings": () => jsonResponse({ modules: [], sections: [] }),
"/api/v1/principal": () => jsonResponse({ kind: "authenticated", userId: "user-1" }),
});
const bootstrap = await bootstrapHost({ apiBaseUrl, fetch: implementation });
expect(bootstrap.account).toBe("unavailable");
expect(bootstrap.navigation.groups[0]?.modules[0]?.descriptor).toEqual(exampleDescriptor);
consoleError.mockRestore();
});
it("never asks for an account on behalf of an anonymous viewer", async () => {
const { calls, implementation } = stubFetch({
"/api/v1/principal": () => jsonResponse({ kind: "anonymous" }),
});
const bootstrap = await bootstrapHost({ apiBaseUrl, fetch: implementation });
expect(bootstrap.account).toBe("unavailable");
expect(calls.map((call) => call.url)).toEqual([`${apiBaseUrl}/api/v1/principal`]);
});
});
+1 -1
View File
@@ -196,7 +196,7 @@ describe("the Dashboard opens on the page its URL names", () => {
});
it("shows a host-owned not-found page for an address inside the Dashboard it does not know", async () => {
for (const path of ["/settings/displays/add", "/calendar/events", "/account"]) {
for (const path of ["/settings/displays/add", "/calendar/events", "/account/profile"]) {
await act(async () => {
root.unmount();
});
+12 -4
View File
@@ -384,12 +384,13 @@ describe("the account area", () => {
expect(trigger.getAttribute("aria-haspopup")).toBe("menu");
expect(trigger.getAttribute("aria-expanded")).toBe("false");
expect(trigger.textContent).toContain("Your account");
// Only the opaque user id is known to the shell, and it is never shown.
// This harness serves no account, so the control names the area; the opaque user id is never
// shown either way. With an account it names the person (account-appearance.test.tsx).
expect(container.textContent).not.toContain("user-1");
expect(query("host-account-menu-list")).toBeNull();
});
it("opens on activation with focus on its first item, and offers only actions that work today", async () => {
it("opens on activation with focus on its first item, and offers only destinations that work", async () => {
await render();
const trigger = required("host-account-menu");
@@ -400,9 +401,14 @@ describe("the account area", () => {
expect(trigger.getAttribute("aria-expanded")).toBe("true");
expect(trigger.getAttribute("aria-controls")).toBe(menu.id);
const items = [...menu.querySelectorAll('[role="menuitem"]')];
expect(items.map((item) => item.textContent)).toEqual(["Sign out"]);
expect(items.map((item) => item.textContent)).toEqual(["Account", "Appearance", "Sign out"]);
expect(document.activeElement).toBe(items[0]);
expect(menu.querySelector("a")).toBeNull();
// Account and Appearance are real links to real pages (ADR 0062); Sign out is an action.
expect([...menu.querySelectorAll("a")].map((link) => link.getAttribute("href"))).toEqual([
"/account",
"/account/appearance",
]);
expect(items[2]?.tagName).toBe("BUTTON");
});
it("opens from the keyboard with ArrowDown, and Escape closes it and returns focus", async () => {
@@ -411,6 +417,8 @@ describe("the account area", () => {
await focus(trigger);
await key(trigger, "ArrowDown");
expect(document.activeElement).toBe(required("host-account-link"));
await key(required("host-account-link"), "ArrowUp");
expect(document.activeElement).toBe(required("host-logout"));
await key(required("host-logout"), "Escape");