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
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:
co-authored by
Claude Opus 5.5
parent
8571e6854c
commit
cd0287537e
@@ -0,0 +1,199 @@
|
||||
# 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.
|
||||
@@ -70,3 +70,4 @@ ADRs capture durable choices that affect more than one package or phase.
|
||||
- [0059: Shared interaction primitives — confirmation, toasts, errors, loading, Back, and no native dialogs](0059-shared-interaction-primitives.md) — Accepted
|
||||
- [0060: Light, Dark, and System appearance; reduced motion; and one radius scale](0060-light-dark-system-theme-and-motion.md) — Accepted
|
||||
- [0061: Settings is one host-owned hierarchy; module lifecycle is Discover, and a module's settings live at `/settings/<module-id>`](0061-settings-hierarchy-and-module-settings-routes.md) — Accepted
|
||||
- [0062: The account area, and appearance preferences that belong to the account](0062-account-area-and-synced-appearance-preferences.md) — Accepted
|
||||
|
||||
Reference in New Issue
Block a user