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]>
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
nullmeans 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'sCHECKconstraints. - Core declares the value sets itself (
ACCOUNT_APPEARANCES,ACCOUNT_MOTION_PREFERENCES), because Core cannot import the design tokens. Anapps/apitest holds them equal toEKKLESIA_APPEARANCESandEKKLESIA_MOTION_PREFERENCES. - Migration
core:0008_user_preferencescreates 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
400with nothing changed. PATCHchanges 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 anauthenticatedbaseline. 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, followingcore.modules.ui.read(ADR 0026). A paired device never satisfiesauthenticated, and an anonymous caller is refused; both get403. The route also checks that the principal is a person before reading. - The mutation takes an exact trusted
Originand a bounded body before authorization, like every Core mutation. Responses areno-storeand vary byOrigin, 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:
- The page loads
/assets/ekklesia-dashboard/appearance.jsin<head>, after<meta name="color-scheme">. It is a classic, same-origin, render-blocking script of about a kilobyte, and never inline, so a strictscript-srcstill needs no exception. ADR 0060 rejected an inline script for exactly that reason. It is a fixed asset route besideapp.jswith its own strongETag. The proxies already forward/assets/ekklesia-dashboard/*. - It reads this browser's cached copy of the choice: one key,
ekklesia.dashboard.appearance, holding two words (for exampledark 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. main.tsxstarts 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
ETagfor 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-srcan exception. A same-origin external script does the same job. - Treat
localStorageas 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), migration0008_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.