The light/dark choice is stored in localStorage under nexuspuppet.theme and nowhere else. It therefore belongs to a browser rather than to a person: an operator who sets dark on their laptop gets the default on the jump host, in a private window, or after clearing site data — and on a shared console, whoever changed it last has changed it for everyone using that machine.
ThemePreference is already 'light' | 'dark' | 'system' (apps/web/src/providers/theme-provider.tsx), so the vocabulary exists. What is missing is a home for it on the user.
The constraint that shapes this
localStorage cannot simply be replaced by the database.
THEME_BOOTSTRAP is an inline script that runs before paint and sets data-theme on <html>. It has to decide synchronously — there is no API round trip available at that point in the document. Move the preference to the server and read it after hydration, and every page load flashes the wrong theme first, which is precisely the bug that bootstrap exists to prevent (and its comment says so).
So the shape is not "move it", it is:
- The user record becomes the source of truth — what an operator gets on a machine they have never used.
localStorage stays as a cache, primed from the user record on login and written through on change, so the pre-paint script keeps a synchronous answer.
Getting that backwards, or dropping the cache, trades a small annoyance for a flash on every page load.
What to build
Edge cases worth deciding
- A browser with site data blocked. The bootstrap already catches and defaults to dark. With a server preference, the user still gets a flash on every load because nothing can be cached. Acceptable, but it should be a known consequence rather than a surprise.
- Logging out. Clearing the cache on logout returns a shared machine to the default, which is arguably correct; leaving it means the next person inherits the last one's theme. Worth choosing deliberately.
- A theme change made in another tab or on another machine. Not worth reconciling live. Last write wins, and the next login settles it.
Why this has sat unbuilt
It has been on the session task list since the console overhaul (#72) and is the only item there with no issue behind it, so nothing in the tracker kept surfacing it while replication, notifications, hybrid auth and compile receipts were being built.
Filed so it stops falling off. It is also a legitimate wontfix: a preference that survives in the browser is not obviously worth a schema change, and the honest counter-argument is that operators mostly use one machine.
The light/dark choice is stored in
localStorageundernexuspuppet.themeand nowhere else. It therefore belongs to a browser rather than to a person: an operator who sets dark on their laptop gets the default on the jump host, in a private window, or after clearing site data — and on a shared console, whoever changed it last has changed it for everyone using that machine.ThemePreferenceis already'light' | 'dark' | 'system'(apps/web/src/providers/theme-provider.tsx), so the vocabulary exists. What is missing is a home for it on the user.The constraint that shapes this
localStoragecannot simply be replaced by the database.THEME_BOOTSTRAPis an inline script that runs before paint and setsdata-themeon<html>. It has to decide synchronously — there is no API round trip available at that point in the document. Move the preference to the server and read it after hydration, and every page load flashes the wrong theme first, which is precisely the bug that bootstrap exists to prevent (and its comment says so).So the shape is not "move it", it is:
localStoragestays as a cache, primed from the user record on login and written through on change, so the pre-paint script keeps a synchronous answer.Getting that backwards, or dropping the cache, trades a small annoyance for a flash on every page load.
What to build
themeonUser, defaulting tosystem— a new account should follow the machine until somebody choosesusers:managelocalStoragein the same stepTHEME_BOOTSTRAPunchanged. If a change to it looks necessary, the design has gone wrongEdge cases worth deciding
Why this has sat unbuilt
It has been on the session task list since the console overhaul (#72) and is the only item there with no issue behind it, so nothing in the tracker kept surfacing it while replication, notifications, hybrid auth and compile receipts were being built.
Filed so it stops falling off. It is also a legitimate
wontfix: a preference that survives in the browser is not obviously worth a schema change, and the honest counter-argument is that operators mostly use one machine.