Keep the theme on the account, not in the browser

- users.theme and users.theme_mode, added by migrate(); GET /api/me returns them
  and PATCH /api/me saves them, refusing anything but a plain name and
  light/dark/auto, since index() writes them into the page's <html> tag.
- The page arrives with data-theme and data-choice already on <html> (and
  data-mode unless Auto), so it is drawn in the account's theme from the start.
- A theme a browser kept in localStorage goes up to the account once, the first
  time an account with none loads the page.
- Saves go one at a time, each with the choice as it stands: sent all at once, a
  quick run through the list could land out of order and keep a theme passed on
  the way. The browser test caught it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 14:33:59 +00:00
parent 9b2537761f
commit 9c16408d04
6 changed files with 164 additions and 27 deletions

View File

@@ -1,7 +1,9 @@
/* ---------------- theme ---------------- */
// A theme, and for those that come in both, light, dark or Auto, chosen in Settings and kept in
// ipx.theme and ipx.mode. The page gets data-theme and data-mode, light or dark, which is all
// the CSS reads: Auto is worked out here, from the system, so no palette is written twice.
// A theme, and for those that come in both, light, dark or Auto, chosen in Settings and kept on
// the account, so it follows you to another browser or computer. The server writes it onto the
// page's <html> tag (data-theme, data-choice) so the page is drawn in it from the start. The
// page gets data-mode, light or dark, which is all the CSS reads: Auto is worked out here, from
// the system, so no palette is written twice.
const THEMES: Record<string, {name: string, modes: boolean}> = {
modern: {name: 'Modern', modes: true},
classic: {name: 'Classic, the 2004 Mac app', modes: false},
@@ -13,12 +15,13 @@ const THEMES: Record<string, {name: string, modes: boolean}> = {
nordic: {name: 'Nordic', modes: true},
};
const MODES: Record<string, string> = {auto: 'Auto (matches your system)', light: 'Light', dark: 'Dark'};
// Before themes came in light and dark, ipx.theme held one of these.
// Before themes came in light and dark, ipx.theme in localStorage held one of these.
const OLD_THEMES: Record<string, [string, string]> = {dark: ['modern', 'dark'], light: ['modern', 'light'], auto: ['modern', 'auto']};
const systemDark = window.matchMedia?.('(prefers-color-scheme: dark)');
const theme = {name: 'modern', mode: 'dark'};
function setTheme(name = theme.name, mode = theme.mode){
/// `save` for a choice made in Settings, which goes to the account; not for applying one.
function setTheme(name = theme.name, mode = theme.mode, save = false){
theme.name = THEMES[name] ? name : 'modern';
theme.mode = MODES[mode] ? mode : 'dark';
const both = THEMES[theme.name].modes;
@@ -30,11 +33,26 @@ function setTheme(name = theme.name, mode = theme.mode){
const sel = $('#stheme'); if(sel) sel.value = theme.name;
const ms = $('#smode'); if(ms) ms.value = theme.mode;
const mf = $('#smodefield'); if(mf) mf.hidden = !both;
try{ localStorage.setItem('ipx.theme', theme.name); localStorage.setItem('ipx.mode', theme.mode); }catch{}
if(save) saveTheme();
}
/// One save at a time, each sending the choice as it stands when it goes. Sent as they came,
/// several at once, a quick run through the list could reach the server out of order and
/// leave the account on a theme passed on the way.
let themeSaving = Promise.resolve();
function saveTheme(){
themeSaving = themeSaving
.then(() => api('/api/me', {method: 'PATCH', body: JSON.stringify({theme: theme.name, mode: theme.mode})}))
.catch(e => toast(`Your theme was not saved: ${e.message}`, true));
}
systemDark?.addEventListener?.('change', () => { if(theme.mode === 'auto') setTheme(); });
try{
let name = localStorage.getItem('ipx.theme'), mode = localStorage.getItem('ipx.mode');
(() => {
const root = document.documentElement;
if(root.dataset.choice) return setTheme(root.dataset.theme, root.dataset.choice);
// Nothing on the account yet. A theme this browser kept, from before themes were kept on the
// account, goes up to it once, so nobody has to choose again.
let name: string | null = null, mode: string | null = null;
try{ name = localStorage.getItem('ipx.theme'); mode = localStorage.getItem('ipx.mode'); }catch{}
if(OLD_THEMES[name]) [name, mode] = OLD_THEMES[name];
setTheme(name ?? undefined, mode ?? undefined);
}catch{ setTheme(); }
setTheme(name ?? undefined, mode ?? undefined, !!name);
})();