Stage 1 PR 6 adds the personal account area and Appearance preferences that belong to the account. Decisions are recorded in ADR 0062.
Routes and UX
/account Account: who you're signed in as, and your own settings
/account/appearance Appearance: System / Light / Dark, and Reduced motion: System / Reduce / Allow
Account control (bottom of the sidebar and the phone drawer):
Shows the person's initials, name, and email. Collapsed, it shows initials, with the name in the accessible name and tooltip.
The menu repeats the name and email, then offers exactly Account, Appearance, and Sign out. Account and Appearance are real links.
If the account can't be loaded, it falls back to "Your account" and keeps every destination.
/account:
Shows the identity and a link to Appearance, plus Sign out.
No profile editing, and no fake fields.
No church administration. The Settings group does not expand here.
/account/appearance:
Two native radio groups (fieldset/legend, real input type="radio", arrow keys).
Every option row is a 44px+ touch target. The chosen option is shown by the radio, by bold text, and by its background, never colour alone.
Back to Account uses PageHeader and dashboardBack, so a validated return target takes precedence.
A choice applies at once, with no reload and no Save button, through the ADR 0060 controller (no new theme logic). It is then saved to the account. The server's answer is what stays applied. If the save fails, the choice is put back and an inline notice says so.
Both pages work on refresh, Back/Forward, phone, tablet and desktop, inside the existing shell. Anything else under /account/ is Page not found.
Schema
New Core table ekklesia_core_user_preference, with one row per person and one typed column per preference:
Column
Type
user_id
text, primary key, FK to ekklesia_core_user, on delete cascade
appearance
text, null, or CHECK in system/light/dark
motion
text, null, or CHECK in system/reduce/allow
created_at, updated_at
timestamptz
null means never chosen, and resolves to the defaults (System/System) when read. Reading never creates a row.
A future preference is a new nullable column, not a new table and not JSON.
An apps/api test holds Core's value sets equal to @ekklesia/design-tokens'.
Migration core:0008_user_preferences is additive (destructive: none) and is never auto-applied. Until an operator runs pnpm migrations:apply, the account routes return a sanitized 503, and the rest of the Dashboard keeps working.
New Core subpath @ekklesia/core/account (private package).
Self-only by construction. No user id is accepted anywhere (path, query, or body). The account is always the session's.
Partial PATCH. It changes only the keys it names, so two devices editing different preferences don't overwrite each other.
New permission core.account.self.manage, baseline authenticated, following the ADR 0026 precedent. Anonymous callers and devices get 403.
The PATCH requires a trusted Origin and the Core mutation body limit.
Responses are no-store and Vary: Origin, Cookie.
Unknown keys or values give 400 INVALID_REQUEST with nothing written.
Store failures give a sanitized 503. A session whose account no longer exists gives 404.
Identity data shown
Name and email, as Better Auth stores them.
Initials, from the name, or from the email if the name is blank.
No avatar image. The account's image is an arbitrary, unvetted URL. Loading it would leak every Dashboard visit to that host, and it has no CSP story. A platform-served avatar is later work.
First paint
The server can't render a per-person page. The Dashboard HTML is one frozen snapshot with one ETag, and in the split topology the Dashboard host never sees the session cookie. So the change is:
A new classic, render-blocking, same-origin script, /assets/ekklesia-dashboard/appearance.js (~1.3 KB), in <head>. It is not inline, so CSP is unchanged.
It reads a non-authoritative cache: one localStorage key, ekklesia.dashboard.appearance, holding two words such as dark reduce, and nothing about the account.
It sets the two theme attributes. For an explicit Light or Dark it also narrows <meta name="color-scheme">, so the canvas is right before any stylesheet loads.
Verified in real Chromium: with Dark cached under a light OS and app.js blocked, the page paints dark.
Every load reconciles with the account. The account's preferences replace the cache.
Sign-out clears the cache, and so does any load that resolves to signed-out, so the next person on that browser starts from System.
If the account can't be loaded, the cache is left alone rather than guessed at.
Other tabs follow live through the browser's storage event. Cross-device changes arrive on the next load; there is no realtime push.
The sidebar collapse preference is unchanged: it is still per-device localStorage (ADR 0058).
The display host stays Light: it loads neither the script nor the account.
Serving: a third fixed asset route beside app.js, with its own strong ETag, revalidated like the others. The proxies already forward /assets/ekklesia-dashboard/*, so there is no deployment change. The storage boundary test now pins exactly these two storage uses.
Public package / API changes
None. Core, the API, and the shell are private, and no @ekklesia/* public package changed. apps/api gains a dev-only dependency on @ekklesia/design-tokens for the parity test.
ADR and docs
ADR 0062 (new), and the ADR index.
docs/development/api.md:
routes table;
Dashboard host (three files);
new Account section.
docs/development/authorization.md: permission table and self-only note.
docs/development/security.md: new section on account data and the browser cache.
docs/development/testing.md: the storage guard and the account test map.
docs/architecture/platform-architecture.md and README.md: account area.
DESIGN.md is unchanged: "syncs across devices" was already settled (locked decision 25).
Fixed in passing (stale docs, not filed as issues):
authorization.md's Core permission table was missing core.devices.manage.
The Doctor transcripts in README.md and repository-workflow.md were missing migration 0007 and showed old counts. They are replaced with real pnpm doctor output.
A test comment in module-permissions.test.ts said Core had five permissions; it had six.
Smoke harness fix.
The split smoke's Chromium container now gets --shm-size 512m. With Docker's default 64 MiB /dev/shm, this PR's extra page loads in one long-lived tab made Chromium fail requests with net::ERR_INSUFFICIENT_RESOURCES partway through. That failed the Dashboard bootstrap in 4 of 5 runs, always client-side, never an API error. With the fix, two consecutive full runs passed.
Any browser-smoke timeout now prints the page state and the last failed or refused requests, as paths only, so a future failure names its cause.
Tests
Core:
account-preferences.test.ts: model, defaults, strict parser (unknown keys/values, prototype tricks), typed columns, migration CHECK lists equal to the model, sanitized store errors.
Permission lists updated to seven.
account-store.integration.ts (real PostgreSQL): defaults with no row written, saving each of the six values, partial updates, two-person isolation, persistence across a new pool, DB-level CHECK refusal, cascade on delete, not-found for an unknown person.
API:
account-routes.test.ts (32 tests):
authenticated baseline; anonymous and device refused;
only the session's account is read or written, whatever the query, body, or headers name;
no route addresses another person;
each value saved; partial update;
validation, Origin, 413, 503s, preflight;
value parity with the design tokens.
api-http.integration.ts: two real accounts over real sessions. Defaults, saves, query and body attempts at the other account, untrusted Origin, persistence across a new session, exactly one DB row, refused after sign-out.
The host e2e test drives the exported shell protocol against the real app.
The Dashboard host serves appearance.js with its own ETag and no variants.
Shell:
routes;
strict protocol parsing;
bootstrap with the account, and tolerating its failure;
account control identity, collapsed and drawer;
/account;
/account/appearance: native radio groups, touch targets, applying and saving each value, immediate switch before the server answers, server answer wins, revert on failure, session check on 403, cross-tab follow, phone drawer;
reconciliation and sign-out forgetting;
the pre-paint script run in the DOM;
initials.
Deploy smoke (real Chromium, split topology):
identity in the control;
the Appearance menu link;
System following an emulated OS dark and back, live;
Dark overriding a light OS, then a reload keeping it;
first paint with app.js blocked;
Light overriding a dark OS;
a module's Shadow DOM root following Dark and Light through the tokens;
reduced motion: System follows OS reduce, Allow overrides it, Reduce applies with no OS request;
the display stays Light under a dark OS and a Dark account;
phone width with no horizontal overflow and 44px+ options;
sign-out clears the cache.
Deploy smoke (HTTP):
the page names appearance.js, and it is served;
the API hostname refuses it;
/account and /account/appearance deep links serve the page;
the account round-trips and an anonymous caller is refused.
Results
TURBO_FORCE=true pnpm verify (no Turbo cache, see #105): passed. shell 396, api 569, core 1191, plus every package.
pnpm test:migrations:integration: passed, 428 tests, including the new account-store.integration.ts and the live-HTTP account suite.
scripts/smoke-deploy.sh same-origin: passed, including the new account and appearance HTTP checks.
EKKLESIA_SMOKE_TOPOLOGY=split scripts/smoke-deploy.sh: passed twice in a row after the /dev/shm fix, 337 checks each, including every Appearance browser step above.
Not run: test:package-release and test:package-publication. No public package changed; CI runs them regardless.
Visual check. Headless Chromium 151 at 1280×860, in light and dark:
/account, /account/appearance, the save-failure notice, and keyboard focus on a radio row;
the account menu, both expanded and collapsed;
at 390×820: the Appearance page (no horizontal overflow, options ≥ 55px) and the drawer's account menu.
Deferred
Profile editing, and a platform-served avatar.
Notification and other personal preferences (future columns).
Realtime cross-device sync.
A church theme, branding colours, radius control, administrator-enforced appearance, Signage appearance, and the display host's own appearance.
Stage 1 PR 6 adds **the personal account area and Appearance preferences that belong to the account**. Decisions are recorded in [ADR 0062](docs/architecture/decisions/0062-account-area-and-synced-appearance-preferences.md).
## Routes and UX
```text
/account Account: who you're signed in as, and your own settings
/account/appearance Appearance: System / Light / Dark, and Reduced motion: System / Reduce / Allow
```
**Account control** (bottom of the sidebar and the phone drawer):
- Shows the person's **initials, name, and email**. Collapsed, it shows initials, with the name in the accessible name and tooltip.
- The menu repeats the name and email, then offers exactly **Account**, **Appearance**, and **Sign out**. Account and Appearance are real links.
- If the account can't be loaded, it falls back to "Your account" and keeps every destination.
**`/account`**:
- Shows the identity and a link to Appearance, plus Sign out.
- No profile editing, and no fake fields.
- No church administration. The Settings group does not expand here.
**`/account/appearance`**:
- Two native radio groups (`fieldset`/`legend`, real `input type="radio"`, arrow keys).
- Every option row is a 44px+ touch target. The chosen option is shown by the radio, by bold text, and by its background, never colour alone.
- Back to Account uses `PageHeader` and `dashboardBack`, so a validated return target takes precedence.
- **A choice applies at once, with no reload and no Save button**, through the ADR 0060 controller (no new theme logic). It is then saved to the account. The server's answer is what stays applied. If the save fails, the choice is put back and an inline notice says so.
Both pages work on refresh, Back/Forward, phone, tablet and desktop, inside the existing shell. Anything else under `/account/` is *Page not found*.
## Schema
New Core table `ekklesia_core_user_preference`, with one row per person and **one typed column per preference**:
| Column | Type |
| --- | --- |
| `user_id` | text, primary key, FK to `ekklesia_core_user`, `on delete cascade` |
| `appearance` | text, null, or `CHECK` in `system`/`light`/`dark` |
| `motion` | text, null, or `CHECK` in `system`/`reduce`/`allow` |
| `created_at`, `updated_at` | timestamptz |
- `null` means *never chosen*, and resolves to the defaults (System/System) when read. Reading never creates a row.
- A future preference is a new nullable column, not a new table and not JSON.
- An `apps/api` test holds Core's value sets equal to `@ekklesia/design-tokens`'.
- **Migration `core:0008_user_preferences`** is additive (`destructive: none`) and is never auto-applied. Until an operator runs `pnpm migrations:apply`, the account routes return a sanitized `503`, and the rest of the Dashboard keeps working.
- New Core subpath `@ekklesia/core/account` (private package).
## API
| Route | Body | `200` |
| --- | --- | --- |
| `GET /api/v1/account` | none | `{ name, email, preferences: { appearance, motion } }` |
| `PATCH /api/v1/account/preferences` | `{ appearance?, motion? }`, at least one | `{ appearance, motion }`, everything in effect |
- **Self-only by construction.** No user id is accepted anywhere (path, query, or body). The account is always the session's.
- **Partial `PATCH`.** It changes only the keys it names, so two devices editing different preferences don't overwrite each other.
- **New permission `core.account.self.manage`**, baseline `authenticated`, following the ADR 0026 precedent. Anonymous callers and devices get `403`.
- The `PATCH` requires a trusted `Origin` and the Core mutation body limit.
- Responses are `no-store` and `Vary: Origin, Cookie`.
- Unknown keys or values give `400 INVALID_REQUEST` with nothing written.
- Store failures give a sanitized `503`. A session whose account no longer exists gives `404`.
## Identity data shown
- Name and email, as Better Auth stores them.
- Initials, from the name, or from the email if the name is blank.
- **No avatar image.** The account's `image` is an arbitrary, unvetted URL. Loading it would leak every Dashboard visit to that host, and it has no CSP story. A platform-served avatar is later work.
## First paint
The server can't render a per-person page. The Dashboard HTML is one frozen snapshot with one `ETag`, and in the split topology the Dashboard host never sees the session cookie. So the change is:
- **A new classic, render-blocking, same-origin script**, `/assets/ekklesia-dashboard/appearance.js` (~1.3 KB), in `<head>`. It is not inline, so CSP is unchanged.
- It reads a **non-authoritative cache**: one `localStorage` key, `ekklesia.dashboard.appearance`, holding two words such as `dark reduce`, and nothing about the account.
- It sets the two theme attributes. For an explicit Light or Dark it also narrows `<meta name="color-scheme">`, so the canvas is right before any stylesheet loads.
- Verified in real Chromium: with Dark cached under a light OS and `app.js` blocked, the page paints dark.
- **Every load reconciles with the account.** The account's preferences replace the cache.
- **Sign-out clears the cache**, and so does any load that resolves to signed-out, so the next person on that browser starts from System.
- If the account can't be loaded, the cache is left alone rather than guessed at.
- **Other tabs follow** live through the browser's `storage` event. Cross-device changes arrive on the next load; there is no realtime push.
- The **sidebar collapse preference is unchanged**: it is still per-device `localStorage` (ADR 0058).
- The **display host stays Light**: it loads neither the script nor the account.
Serving: a third fixed asset route beside `app.js`, with its own strong `ETag`, revalidated like the others. The proxies already forward `/assets/ekklesia-dashboard/*`, so there is no deployment change. The storage boundary test now pins exactly these two storage uses.
## Public package / API changes
**None.** Core, the API, and the shell are private, and no `@ekklesia/*` public package changed. `apps/api` gains a dev-only dependency on `@ekklesia/design-tokens` for the parity test.
## ADR and docs
- **ADR 0062** (new), and the ADR index.
- `docs/development/api.md`:
- routes table;
- Dashboard host (three files);
- new **Account** section.
- `docs/development/authorization.md`: permission table and self-only note.
- `docs/development/security.md`: new section on account data and the browser cache.
- `docs/development/testing.md`: the storage guard and the account test map.
- `docs/architecture/platform-architecture.md` and `README.md`: account area.
- `DESIGN.md` is unchanged: "syncs across devices" was already settled (locked decision 25).
**Fixed in passing (stale docs, not filed as issues):**
- `authorization.md`'s Core permission table was missing `core.devices.manage`.
- The Doctor transcripts in `README.md` and `repository-workflow.md` were missing migration `0007` and showed old counts. They are replaced with real `pnpm doctor` output.
- A test comment in `module-permissions.test.ts` said Core had five permissions; it had six.
**Smoke harness fix.**
- The split smoke's Chromium container now gets `--shm-size 512m`. With Docker's default 64 MiB `/dev/shm`, this PR's extra page loads in one long-lived tab made Chromium fail requests with `net::ERR_INSUFFICIENT_RESOURCES` partway through. That failed the Dashboard bootstrap in 4 of 5 runs, always client-side, never an API error. With the fix, two consecutive full runs passed.
- Any browser-smoke timeout now prints the page state and the last failed or refused requests, as paths only, so a future failure names its cause.
## Tests
- **Core:**
- `account-preferences.test.ts`: model, defaults, strict parser (unknown keys/values, prototype tricks), typed columns, migration `CHECK` lists equal to the model, sanitized store errors.
- Permission lists updated to seven.
- `account-store.integration.ts` (real PostgreSQL): defaults with no row written, saving each of the six values, partial updates, two-person isolation, persistence across a new pool, DB-level `CHECK` refusal, cascade on delete, not-found for an unknown person.
- **API:**
- `account-routes.test.ts` (32 tests):
- authenticated baseline; anonymous and device refused;
- only the session's account is read or written, whatever the query, body, or headers name;
- no route addresses another person;
- each value saved; partial update;
- validation, `Origin`, 413, 503s, preflight;
- value parity with the design tokens.
- `api-http.integration.ts`: two real accounts over real sessions. Defaults, saves, query and body attempts at the other account, untrusted `Origin`, persistence across a new session, exactly one DB row, refused after sign-out.
- The host e2e test drives the exported shell protocol against the real app.
- The Dashboard host serves `appearance.js` with its own `ETag` and no variants.
- **Shell:**
- routes;
- strict protocol parsing;
- bootstrap with the account, and tolerating its failure;
- account control identity, collapsed and drawer;
- `/account`;
- `/account/appearance`: native radio groups, touch targets, applying and saving each value, immediate switch before the server answers, server answer wins, revert on failure, session check on `403`, cross-tab follow, phone drawer;
- reconciliation and sign-out forgetting;
- the pre-paint script run in the DOM;
- initials.
- **Deploy smoke (real Chromium, split topology):**
- identity in the control;
- the Appearance menu link;
- System following an emulated OS dark and back, live;
- Dark overriding a light OS, then a reload keeping it;
- first paint with `app.js` blocked;
- Light overriding a dark OS;
- a module's **Shadow DOM** root following Dark and Light through the tokens;
- reduced motion: System follows OS reduce, Allow overrides it, Reduce applies with no OS request;
- the **display stays Light** under a dark OS and a Dark account;
- phone width with no horizontal overflow and 44px+ options;
- sign-out clears the cache.
- **Deploy smoke (HTTP):**
- the page names `appearance.js`, and it is served;
- the API hostname refuses it;
- `/account` and `/account/appearance` deep links serve the page;
- the account round-trips and an anonymous caller is refused.
### Results
- `TURBO_FORCE=true pnpm verify` (no Turbo cache, see #105): **passed**. shell 396, api 569, core 1191, plus every package.
- `pnpm test:migrations:integration`: **passed**, 428 tests, including the new `account-store.integration.ts` and the live-HTTP account suite.
- `scripts/smoke-deploy.sh` same-origin: **passed**, including the new account and appearance HTTP checks.
- `EKKLESIA_SMOKE_TOPOLOGY=split scripts/smoke-deploy.sh`: **passed twice in a row** after the `/dev/shm` fix, 337 checks each, including every Appearance browser step above.
- Not run: `test:package-release` and `test:package-publication`. No public package changed; CI runs them regardless.
**Visual check.** Headless Chromium 151 at 1280×860, in light and dark:
- `/account`, `/account/appearance`, the save-failure notice, and keyboard focus on a radio row;
- the account menu, both expanded and collapsed;
- at 390×820: the Appearance page (no horizontal overflow, options ≥ 55px) and the drawer's account menu.
## Deferred
- Profile editing, and a platform-served avatar.
- Notification and other personal preferences (future columns).
- Realtime cross-device sync.
- A church theme, branding colours, radius control, administrator-enforced appearance, Signage appearance, and the display host's own appearance.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
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]>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Stage 1 PR 6 adds the personal account area and Appearance preferences that belong to the account. Decisions are recorded in ADR 0062.
Routes and UX
Account control (bottom of the sidebar and the phone drawer):
/account:/account/appearance:fieldset/legend, realinput type="radio", arrow keys).PageHeaderanddashboardBack, so a validated return target takes precedence.Both pages work on refresh, Back/Forward, phone, tablet and desktop, inside the existing shell. Anything else under
/account/is Page not found.Schema
New Core table
ekklesia_core_user_preference, with one row per person and one typed column per preference:user_idekklesia_core_user,on delete cascadeappearanceCHECKinsystem/light/darkmotionCHECKinsystem/reduce/allowcreated_at,updated_atnullmeans never chosen, and resolves to the defaults (System/System) when read. Reading never creates a row.apps/apitest holds Core's value sets equal to@ekklesia/design-tokens'.core:0008_user_preferencesis additive (destructive: none) and is never auto-applied. Until an operator runspnpm migrations:apply, the account routes return a sanitized503, and the rest of the Dashboard keeps working.@ekklesia/core/account(private package).API
200GET /api/v1/account{ name, email, preferences: { appearance, motion } }PATCH /api/v1/account/preferences{ appearance?, motion? }, at least one{ appearance, motion }, everything in effectPATCH. It changes only the keys it names, so two devices editing different preferences don't overwrite each other.core.account.self.manage, baselineauthenticated, following the ADR 0026 precedent. Anonymous callers and devices get403.PATCHrequires a trustedOriginand the Core mutation body limit.no-storeandVary: Origin, Cookie.400 INVALID_REQUESTwith nothing written.503. A session whose account no longer exists gives404.Identity data shown
imageis an arbitrary, unvetted URL. Loading it would leak every Dashboard visit to that host, and it has no CSP story. A platform-served avatar is later work.First paint
The server can't render a per-person page. The Dashboard HTML is one frozen snapshot with one
ETag, and in the split topology the Dashboard host never sees the session cookie. So the change is:/assets/ekklesia-dashboard/appearance.js(~1.3 KB), in<head>. It is not inline, so CSP is unchanged.localStoragekey,ekklesia.dashboard.appearance, holding two words such asdark reduce, and nothing about the account.<meta name="color-scheme">, so the canvas is right before any stylesheet loads.app.jsblocked, the page paints dark.storageevent. Cross-device changes arrive on the next load; there is no realtime push.localStorage(ADR 0058).Serving: a third fixed asset route beside
app.js, with its own strongETag, revalidated like the others. The proxies already forward/assets/ekklesia-dashboard/*, so there is no deployment change. The storage boundary test now pins exactly these two storage uses.Public package / API changes
None. Core, the API, and the shell are private, and no
@ekklesia/*public package changed.apps/apigains a dev-only dependency on@ekklesia/design-tokensfor the parity test.ADR and docs
docs/development/api.md:docs/development/authorization.md: permission table and self-only note.docs/development/security.md: new section on account data and the browser cache.docs/development/testing.md: the storage guard and the account test map.docs/architecture/platform-architecture.mdandREADME.md: account area.DESIGN.mdis unchanged: "syncs across devices" was already settled (locked decision 25).Fixed in passing (stale docs, not filed as issues):
authorization.md's Core permission table was missingcore.devices.manage.README.mdandrepository-workflow.mdwere missing migration0007and showed old counts. They are replaced with realpnpm doctoroutput.module-permissions.test.tssaid Core had five permissions; it had six.Smoke harness fix.
--shm-size 512m. With Docker's default 64 MiB/dev/shm, this PR's extra page loads in one long-lived tab made Chromium fail requests withnet::ERR_INSUFFICIENT_RESOURCESpartway through. That failed the Dashboard bootstrap in 4 of 5 runs, always client-side, never an API error. With the fix, two consecutive full runs passed.Tests
account-preferences.test.ts: model, defaults, strict parser (unknown keys/values, prototype tricks), typed columns, migrationCHECKlists equal to the model, sanitized store errors.account-store.integration.ts(real PostgreSQL): defaults with no row written, saving each of the six values, partial updates, two-person isolation, persistence across a new pool, DB-levelCHECKrefusal, cascade on delete, not-found for an unknown person.account-routes.test.ts(32 tests):Origin, 413, 503s, preflight;api-http.integration.ts: two real accounts over real sessions. Defaults, saves, query and body attempts at the other account, untrustedOrigin, persistence across a new session, exactly one DB row, refused after sign-out.appearance.jswith its ownETagand no variants./account;/account/appearance: native radio groups, touch targets, applying and saving each value, immediate switch before the server answers, server answer wins, revert on failure, session check on403, cross-tab follow, phone drawer;app.jsblocked;appearance.js, and it is served;/accountand/account/appearancedeep links serve the page;Results
TURBO_FORCE=true pnpm verify(no Turbo cache, see #105): passed. shell 396, api 569, core 1191, plus every package.pnpm test:migrations:integration: passed, 428 tests, including the newaccount-store.integration.tsand the live-HTTP account suite.scripts/smoke-deploy.shsame-origin: passed, including the new account and appearance HTTP checks.EKKLESIA_SMOKE_TOPOLOGY=split scripts/smoke-deploy.sh: passed twice in a row after the/dev/shmfix, 337 checks each, including every Appearance browser step above.test:package-releaseandtest:package-publication. No public package changed; CI runs them regardless.Visual check. Headless Chromium 151 at 1280×860, in light and dark:
/account,/account/appearance, the save-failure notice, and keyboard focus on a radio row;Deferred
🤖 Generated with Claude Code