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