Files
Ekklesia/docs/architecture/decisions/0062-account-area-and-synced-appearance-preferences.md
ekklesiaandClaude Opus 5.5 cd0287537e
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
Add the account area and account-synced Appearance preferences
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]>
2026-10-14 23:56:50 -05:00

200 lines
12 KiB
Markdown

# 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 `<head>`, after
`<meta name="color-scheme">`. 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.