# 0062: The account area, and appearance preferences that belong to the account - Status: Accepted - Date: 2026-10-14 - Extends: [0026](0026-minimal-ekklesia-module-ui-host.md), [0044](0044-self-hosted-reference-deployment.md), [0045](0045-split-dashboard-and-api-origins.md), [0057](0057-dashboard-urls-are-host-owned-routes.md), [0058](0058-dashboard-shell-navigation-and-lucide-icons.md), [0060](0060-light-dark-system-theme-and-motion.md), [0061](0061-settings-hierarchy-and-module-settings-routes.md) ## Context DESIGN.md puts the signed-in person at the bottom of the sidebar (§4.5), keeps personal configuration under that account area rather than in church-wide Settings (§9.2), and requires Light, Dark, and System appearance with the person's choice **syncing across their devices** (§3.2, locked decision 25), System being the default (locked decision 26). ADR 0060 built the theme: the token stylesheet resolves appearance and motion in CSS, a controller sets two attributes on the theme root, and `main.tsx` is the bootstrap hook where a remembered choice would be supplied. It deferred the Account → Appearance page and synced storage, and it noted the remaining cost of a page with no inline script: an explicit choice opposite to the system can show the system's empty canvas until the module script runs. Three things were missing. The shell knew the person only as an opaque user id (`/api/v1/principal`), so the account control could not say who was signed in. There was nowhere to keep a personal preference. And the Dashboard page is one frozen snapshot with one `ETag`, served in the split topology from a hostname that never receives the API's session cookie (ADR 0045), so the server cannot write a person's choice into the HTML. ## Decision ### The account area ```text /account Account: who you are signed in as, and your own settings /account/appearance Appearance: Light, Dark, or System; and reduced motion ``` Personal, never church administration. It needs no management permission, it sits outside the Settings hierarchy (ADR 0061), and the sidebar's Settings group does not expand on it. `account` was already a reserved top-level name (ADR 0057), so the API already served the page here and no server or proxy change was needed for the addresses. Account sections live beneath `/account/`, so they cannot collide with a module id and are not reserved. Anything else under `/account/` is *Page not found* until it exists. **The account control names the person**: their initials, their name, and their email, at the bottom of the sidebar and in the phone drawer. Its menu offers exactly the destinations that work: Account, Appearance, and Sign out. Account and Appearance are real links. When the account cannot be loaded, the control falls back to "Your account" and keeps every destination. **No avatar image.** The account's `image` is an arbitrary URL the platform has never vetted. Rendering it would make every Dashboard visit a request to whatever host it names, and it has no CSP story. Initials are drawn from the name instead, or from the email when the name is blank. Showing a person's picture needs an upload and serving path the platform owns, which is later work. There is **no profile editing**. Name and email are shown as Better Auth stores them, and nothing on the page pretends to be editable. ### Preferences belong to the account One Core table, one row per person, **one typed column per preference**: ```text ekklesia_core_user_preference user_id text primary key, references ekklesia_core_user(id) on delete cascade appearance text null, or one of 'system' | 'light' | 'dark' (CHECK) motion text null, or one of 'system' | 'reduce' | 'allow' (CHECK) created_at, updated_at ``` - **`null` means never chosen** and resolves to the default (System, System) when read. A column default would make "chose System" indistinguishable from "never chose". It would also force a value nobody chose onto every existing row when a preference is added. A person with no row has chosen nothing. Reading never creates a row. - **A new preference is a new nullable column** in a new migration, beside these. That is not one table per preference, and it is not a key/value or JSON document a client could fill with anything. Values are checked twice: by Core's strict parser (`parseAccountPreferencesUpdate`) and by the database's `CHECK` constraints. - Core declares the value sets itself (`ACCOUNT_APPEARANCES`, `ACCOUNT_MOTION_PREFERENCES`), because Core cannot import the design tokens. An `apps/api` test holds them equal to `EKKLESIA_APPEARANCES` and `EKKLESIA_MOTION_PREFERENCES`. - Migration `core:0008_user_preferences` creates the table and destroys nothing. Like every migration, it is applied only by an operator (`pnpm migrations:apply`). The sidebar's expanded or collapsed mode is **not** moved here. It stays a preference of this browser on this device (ADR 0058). ### The API is self-only by construction | Route | Body | Answer | | --- | --- | --- | | `GET /api/v1/account` | none | `{ name, email, preferences: { appearance, motion } }` | | `PATCH /api/v1/account/preferences` | `{ appearance?, motion? }`, at least one | `{ appearance, motion }`, everything now in effect | - **Neither route accepts a user id**, not in the path, the query, or the body. The account is always the one the session names, so no request can read or change anyone else's, whatever the caller holds. A body naming any key besides a known preference, or any value outside its set, is a `400` with nothing changed. - **`PATCH` changes only what it names.** Changing motion on a phone never overwrites an appearance just chosen on a laptop. - **Both require `core.account.self.manage`**, a new Core permission with an `authenticated` baseline. Every signed-in person holds it, and choosing dark mode needs no grant. It is a declared permission rather than a hand-written "is signed in" check, following `core.modules.ui.read` (ADR 0026). A paired device never satisfies `authenticated`, and an anonymous caller is refused; both get `403`. The route also checks that the principal is a person before reading. - The mutation takes an exact trusted `Origin` and a bounded body before authorization, like every Core mutation. Responses are `no-store` and vary by `Origin, Cookie`. - **The browser learns name, email, and preferences, and nothing else**: not the image URL, verification state, timestamps, or user id. ### First paint: a classic pre-paint script, never inline The server cannot render the choice into the page. The page is one snapshot with one validator for everyone, and in the split topology its hostname never sees the session cookie. So: 1. The page loads `/assets/ekklesia-dashboard/appearance.js` in ``, after ``. It is a classic, same-origin, render-blocking script of about a kilobyte, and **never inline**, so a strict `script-src` still needs no exception. ADR 0060 rejected an inline script for exactly that reason. It is a fixed asset route beside `app.js` with its own strong `ETag`. The proxies already forward `/assets/ekklesia-dashboard/*`. 2. It reads this browser's **cached copy** of the choice: one key, `ekklesia.dashboard.appearance`, holding two words (for example `dark reduce`). It sets the two theme attributes on the root. For an explicit Light or Dark it also narrows the colour-scheme element, so the browser's canvas is painted in that scheme before the token stylesheet arrives. An invalid, missing, or unreadable cache does nothing, and the page follows the system. 3. `main.tsx` starts the ADR 0060 controller from the same cache, so the two agree. **The cache is never the source of truth.** Every Dashboard load replaces it with what the account holds, so a choice made on another device takes effect here from the next load on. A choice is saved to the account first-class: it applies at once, the account's answer is what stays applied, and a failed save puts the account's value back and says so. The cache holds nothing about the account. It is cleared on sign-out and whenever the page resolves to signed-out, so the next person to use the browser starts from the system's setting. An account that cannot be loaded leaves the cache alone rather than guessing. This removes the cost ADR 0060 noted. An explicit choice is in place from the first frame on any browser that has loaded the Dashboard before. A first visit, a new device, or a blocked storage follows the system until the account answers, moments later. ### Changes apply live, and other tabs follow A choice takes effect through `setAppearance` and `setMotion` on the one controller, with no reload and no Save button. Isolated module roots follow because the attributes and custom properties inherit, as in ADR 0060. **Other tabs** of the Dashboard in the same browser follow through the browser's `storage` event on the cache key, with no request. Sign-out in one tab returns the others to the system as well. **Cross-device realtime is not provided**: another device picks up a change on its next load. That meets DESIGN.md's "syncs across devices" without a push channel, which would cost a persistent connection per open Dashboard for a setting people change rarely. ### Unchanged - **The display host stays Light** (ADR 0060). It loads neither the pre-paint script nor the account. A lobby screen is not a person. - Module UIs read the theme through tokens and never set it, and the module UI contract is unchanged. - No church theme, brand colours, radius control, administrator-enforced mode, or Signage appearance. ## Alternatives - **Render the choice into the HTML on the server.** Rejected: one frozen snapshot and `ETag` for everyone (ADR 0044), and in the split topology the Dashboard hostname never receives the session cookie (ADR 0045). Per-person HTML would also put account data into a cacheable page. - **An inline bootstrap script.** Rejected, as in ADR 0060: it costs a strict `script-src` an exception. A same-origin external script does the same job. - **Treat `localStorage` as the preference store.** Rejected: it does not follow the person to another device, and it would survive into the next person's session on a shared computer. - **A cookie carrying the choice.** Rejected: in the split topology a cookie the Dashboard origin could read would be a second, cross-origin cookie policy for one preference. It would also travel on every request. - **A key/value or JSON preference table.** Rejected: values the server cannot type-check, and a column any client could fill. One typed, constrained column per preference costs one migration per preference, which is the point. - **One table per preference.** Rejected: many tables for one row per person. - **Fold the account into `/api/v1/principal`.** Rejected: the principal is the platform's identity primitive, also read by the display host and the module UI host. Name and email are the account area's concern, behind their own permission. - **Load the account's `image`.** Rejected, as above. - **Realtime cross-device sync.** Deferred: next-load reconciliation meets the requirement. - **Move the sidebar mode to the account.** Not done: it is a per-device preference (ADR 0058). ## Consequences - Core gains `@ekklesia/core/account` (schema, store, value sets), migration `0008_user_preferences`, and a seventh Core permission, `core.account.self.manage`. Every composed registry and the Doctor's count grow by one. - The Dashboard serves three host-owned files instead of two, and bootstrap makes one more request, alongside the navigation and Settings. An account that fails to load never stops the Dashboard. - Browser storage now holds two things: the sidebar mode and the appearance cache. A boundary test pins each read and write. - No public `@ekklesia/*` package changes. ## Deferred Profile editing, an uploaded avatar the platform serves itself, notification and other personal preferences, which arrive as further columns. Also deferred: realtime cross-device sync, a church theme or administrator-enforced appearance, and the display host's own appearance.