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
+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>
);
}