App reference

ADR-0029: Sync UI preferences across devices via UserSettings

Metadata

  • Status: Accepted
  • Date: 2026-08-14
  • Deciders: Eagraí Clainne Team
  • Related: ADR-0012 (profile self-service — the settings surface these preferences ride in); ADR-0020 (profile switching — the per-member device cache these preferences share)

Context

A member's display preferences did not sync the same way. Theme family, mode, custom tokens, locale and tour-seen version already live in UserSettings and follow the member to every device. Text scale and reduce-motion did not. They lived only in the device-local eagraiclainne.prefs.<uid> bag on the web and the eagraiclainne.theme DataStore on Android. They were never added to the proto.

The result was a split brain. A member who set a larger text size on one device found it reset on the next. Half the display preferences synced, half did not, and nothing told the member which was which.

Issue #48 also needs a card-tilt toggle. Without a home for it, that flag would land as a new device-local value and repeat the same split.

Decision

Extend UserSettings with the three display preferences that were missing or about to be added:

enum TextScale {
  TEXT_SCALE_UNSPECIFIED = 0;   // 1.0 — the standard size
  TEXT_SCALE_LARGE = 1;         // 1.15
  TEXT_SCALE_EXTRA_LARGE = 2;   // 1.33
}

message UserSettings {
  // ... existing fields 1-5 ...
  TextScale text_scale = 6;    // UNSPECIFIED = standard 1.0
  bool reduce_motion = 7;      // false = motion on (current behavior)
  bool flatten_cards = 8;      // false = tilted (current behavior)
}

Every new field defaults to today's behavior when unset. An old record or a fresh member keeps the standard size, motion on, and tilted cards. No member sees a change until they choose one.

text_scale is an enum, not a raw double. The web already ships a validated set of three sizes. An enum pins that set at the schema, so a client cannot store 1.07 or a negative scale, and no extra server validation is needed.

flatten_cards is the knob #48 needs. It is defined here so the tilt work rides on this synced model instead of a fourth device-local flag.

The server record is the source of truth. Writes go through the existing UserService.Update RPC and the svc.Mutate seam. No new RPC. The settings message replaces whole on every write, so each client sends the full message with the changed field — the same discipline the theme and mark writes already follow.

localStorage stays as a device cache. It does two jobs the server cannot. It applies the member's preferences on load before the server response arrives, so there is no flash of the wrong size. It also holds the pre-auth device defaults on the sign-in board and the tour, where no member exists yet.

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

Conflict resolution is last write wins. Two devices that change a preference while offline resolve through the mutation trail, newest write kept. These preferences are low stakes, so no merge or vector clock is warranted.

Consequences

  • Text scale, reduce-motion and card-tilt follow a member across devices, the same as theme. The split brain is closed.
  • The migration moves a member's existing local text size and motion choice to the server without the member noticing.
  • No new RPC, no scrub-map change. The three fields carry no secret, so the sanitize and hydrate interceptors are untouched.
  • flatten_cards exists but drives no rendering yet. Cards still tilt. Issue #48 wires the field to the tilt and adds its toggle. This ADR only provides the synced home.
  • Android surface parity gap, deliberate. Android syncs reduce_motion through its existing motion toggle. Its writes carry the whole settings message and leave text_scale and flatten_cards untouched, so a value the web set is never dropped when Android writes. Android does not yet render a text-scale control or card tilt, so it has no UI for those two. The web is the reference surface (rule 1). Android gains the controls when it gains the features; the synced values already survive it.
  • MCP and CLI carry no UI preferences. Per rule 1 that is correct — neither surface renders the app.

Alternatives considered

  • text_scale as a raw double. Rejected. It would let any value reach the record and force server-side range validation to hold the set the web already fixes at three sizes. The enum states the set once, at the schema.
  • Drop localStorage, read only from the server. Rejected. The cache prevents a flash of the wrong size before the server response lands, and it is the only place to hold the pre-auth device defaults the sign-in board and tour need before a member exists.
  • Leave text scale and reduce-motion device-local. Rejected. It is the status quo the issue set out to fix. A member expects the size and motion they picked to follow them, the way their theme already does.
  • Per-field update mask instead of whole-message writes. Rejected here. It is a larger change to the settings write path that every synced preference shares, and last write wins already gives an acceptable conflict rule for low-stakes preferences.