Files
Ekklesia/docs/architecture/decisions/0062-account-area-and-synced-appearance-preferences.md
T
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

12 KiB

0062: The account area, and appearance preferences that belong to the account

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

/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:

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.