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,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();
});
});