Sync UI preferences across devices via UserSettings #60

Closed
opened 2026-08-14 11:14:20 +00:00 by nalum · 0 comments
Owner

Summary

Sync every per-member UI preference across a member's devices through UserSettings. Today theme family, mode, custom tokens, locale, and tour-seen version already sync. Text scale and reduce-motion do not — they live only in the device-local eagraiclainne.prefs.<uid> bag. Bring those into the synced model, add a card-tilt flag for #48 to build on, and make the server the source of truth with localStorage demoted to a device cache.

Motivation

A member who sets their text scale, motion, or theme on one device expects the same on every device. Theme already behaves that way. Text scale and reduce-motion do not, because they were never added to the proto. This is a split-brain: half the display preferences sync, half reset per device.

Current state:

  • Synced (proto/api/core/v1/user.proto): UserTheme.family, UserTheme.mode, UserTheme.custom_tokens, UserSettings.locale, UserSettings.tour_seen_version.
  • Local only (web/src/theme/theme.tsx:22-23, the eagraiclainne.prefs.<uid> bag): textScale, reduceMotion. Not in the proto, never synced.

Proposed design

Extend UserSettings with the display preferences that are missing:

message UserSettings {
  // ... existing fields 1-5 ...
  TextScale text_scale = 6;    // UNSPECIFIED = 1.0
  bool reduce_motion = 7;      // false = motion on (current behavior)
  bool flatten_cards = 8;      // false = tilted (current behavior); true = straight
}
  • Every new field defaults to current behavior when unset. An old record or a fresh member keeps today's look.
  • flatten_cards is the knob #48 needs. It is defined here so the tilt work rides on this synced model instead of a new local flag.

Source of truth and cache:

  • The server record is the source of truth. Writes go through the existing UserService.Update RPC and the svc.Mutate seam. No new RPC.
  • localStorage stays as a device cache. It serves two jobs: apply preferences instantly on load before the settings arrive (no flash), and hold the pre-auth device defaults on the sign-in board and tour, where no member exists yet.
  • On login, hydrate from the server record, apply, and write through to the cache.

Migration:

  • On the first authenticated load after this ships, if a member's server settings do not carry the new fields and the local cache does, push the local values to the server once. After that the server wins.

Scope

Proto and server:

  • Add the three fields to UserSettings (TextScale enum, two bools). Regenerate Go, TypeScript, and Android.
  • UserService.Update accepts and persists them through the mutation seam. No sensitive fields, no scrub-map change.

Web (reference surface):

  • Read the three preferences from the member record, not from localStorage alone.
  • Write through to both server and cache on change.
  • Run the one-time local-to-server migration.

Android:

  • Read the same three preferences from the synced member record. Keep SessionStore as the device cache.

Open questions for the ADR

  • text_scale as a TextScale enum (validated set 1.0 / 1.15 / 1.33) versus a raw double. Recommendation: enum, to keep the validated set the web already uses.
  • Keep localStorage as a cache versus drop it. Recommendation: keep it, for no-flash apply and the pre-auth device bag.
  • Conflict resolution when two devices change a preference while offline. Recommendation: last write wins through the mutation trail. Preferences are low-stakes.

ADR

Add docs/adr/00NN-sync-ui-preferences.md. Record the schema addition, the server-as-source-of-truth decision, the localStorage-as-cache role, the migration, and the conflict rule. Number it after the push ADR (0028) once that lands.

Surface parity

Web is the reference. Android carries the same three synced preferences. MCP and CLI do not carry UI preferences. Per system rule 1.

CLAUDE.md

The frontend section currently states that scale and motion persist in namespaced localStorage. Update it to say the server record is the source of truth and localStorage is the device cache (system rule 5).

Blocks

  • #48 depends on the flatten_cards field defined here.

Definition of done

  • text_scale, reduce_motion, and flatten_cards sync across a member's devices.
  • The one-time migration moves existing local values to the server without a member noticing.
  • ADR merged. CLAUDE.md updated. make check passes. Verified across two devices against the live deploy.
### Summary Sync every per-member UI preference across a member's devices through `UserSettings`. Today theme family, mode, custom tokens, locale, and tour-seen version already sync. Text scale and reduce-motion do not — they live only in the device-local `eagraiclainne.prefs.<uid>` bag. Bring those into the synced model, add a card-tilt flag for #48 to build on, and make the server the source of truth with localStorage demoted to a device cache. ### Motivation A member who sets their text scale, motion, or theme on one device expects the same on every device. Theme already behaves that way. Text scale and reduce-motion do not, because they were never added to the proto. This is a split-brain: half the display preferences sync, half reset per device. Current state: - **Synced** (`proto/api/core/v1/user.proto`): `UserTheme.family`, `UserTheme.mode`, `UserTheme.custom_tokens`, `UserSettings.locale`, `UserSettings.tour_seen_version`. - **Local only** (`web/src/theme/theme.tsx:22-23`, the `eagraiclainne.prefs.<uid>` bag): `textScale`, `reduceMotion`. Not in the proto, never synced. ### Proposed design Extend `UserSettings` with the display preferences that are missing: ``` message UserSettings { // ... existing fields 1-5 ... TextScale text_scale = 6; // UNSPECIFIED = 1.0 bool reduce_motion = 7; // false = motion on (current behavior) bool flatten_cards = 8; // false = tilted (current behavior); true = straight } ``` - Every new field defaults to current behavior when unset. An old record or a fresh member keeps today's look. - `flatten_cards` is the knob #48 needs. It is defined here so the tilt work rides on this synced model instead of a new local flag. Source of truth and cache: - The server record is the source of truth. Writes go through the existing `UserService.Update` RPC and the `svc.Mutate` seam. No new RPC. - localStorage stays as a device cache. It serves two jobs: apply preferences instantly on load before the settings arrive (no flash), and hold the pre-auth device defaults on the sign-in board and tour, where no member exists yet. - On login, hydrate from the server record, apply, and write through to the cache. Migration: - On the first authenticated load after this ships, if a member's server settings do not carry the new fields and the local cache does, push the local values to the server once. After that the server wins. ### Scope Proto and server: - Add the three fields to `UserSettings` (`TextScale` enum, two bools). Regenerate Go, TypeScript, and Android. - `UserService.Update` accepts and persists them through the mutation seam. No sensitive fields, no scrub-map change. Web (reference surface): - Read the three preferences from the member record, not from localStorage alone. - Write through to both server and cache on change. - Run the one-time local-to-server migration. Android: - Read the same three preferences from the synced member record. Keep `SessionStore` as the device cache. ### Open questions for the ADR - `text_scale` as a `TextScale` enum (validated set 1.0 / 1.15 / 1.33) versus a raw `double`. Recommendation: enum, to keep the validated set the web already uses. - Keep localStorage as a cache versus drop it. Recommendation: keep it, for no-flash apply and the pre-auth device bag. - Conflict resolution when two devices change a preference while offline. Recommendation: last write wins through the mutation trail. Preferences are low-stakes. ### ADR Add `docs/adr/00NN-sync-ui-preferences.md`. Record the schema addition, the server-as-source-of-truth decision, the localStorage-as-cache role, the migration, and the conflict rule. Number it after the push ADR (0028) once that lands. ### Surface parity Web is the reference. Android carries the same three synced preferences. MCP and CLI do not carry UI preferences. Per system rule 1. ### CLAUDE.md The frontend section currently states that scale and motion persist in namespaced localStorage. Update it to say the server record is the source of truth and localStorage is the device cache (system rule 5). ### Blocks - #48 depends on the `flatten_cards` field defined here. ### Definition of done - `text_scale`, `reduce_motion`, and `flatten_cards` sync across a member's devices. - The one-time migration moves existing local values to the server without a member noticing. - ADR merged. CLAUDE.md updated. `make check` passes. Verified across two devices against the live deploy.
nalum added reference refs/tags/v1.3.0 2026-08-14 11:17:16 +00:00
nalum added this to the (deleted) project 2026-08-14 12:20:29 +00:00
nalum closed this issue 2026-08-16 12:37:55 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
eagraiclainne/app#60
No description provided.