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
@@ -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;
}