Let the toggle come back to the theme you were on

The top-bar toggle went to light from anything dark, and back to plain
"dark" -- which quietly moved an ihasmail user onto a theme they had
never chosen, two clicks and no way to tell what had happened. It did
the same to "match system", which the toggle could not restore at all;
the comment above it conceded as much and sent people to Settings.

There is more than one way to be dark now, so the way back is
remembered: lastDarkTheme holds whichever non-light theme was last
chosen, and the toggle returns to that.

The remembering lives in update(), the single path every way of setting
a theme goes through -- the toggle, Appearance, an imported settings
file -- so a fourth way to choose one cannot forget to record it. Light
never overwrites it, since light is the side being toggled away from.

The button's label follows: "Switch to the ihasmail theme", "Switch to
your system theme", rather than claiming everything dark is "dark mode".

Confirmed in a browser, not only in tests: from a fresh profile the
round trip ihasmail -> light -> ihasmail returns to ihasmail, and
system -> light -> system returns to system, with the label naming the
destination each time.
This commit is contained in:
2026-08-26 11:34:45 -07:00
parent 8487f561f6
commit 9689ac8aae
3 changed files with 97 additions and 12 deletions
+55 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { DEFAULT_SETTINGS, isDarkTheme, type Theme } from "@/store/settings"; import { DEFAULT_SETTINGS, isDarkTheme, toggleTarget, useSettings, type Theme } from "@/store/settings";
import { loadJson, saveJson } from "@/lib/storage"; import { loadJson, saveJson } from "@/lib/storage";
/** /**
@@ -90,3 +90,57 @@ describe("the default theme", () => {
}); });
}); });
}); });
describe("the top-bar toggle", () => {
it("goes to light from anything dark", () => {
expect(toggleTarget("dark", "ihasmail")).toBe("light");
expect(toggleTarget("dark", "dark")).toBe("light");
expect(toggleTarget("dark", "system")).toBe("light");
});
it("comes back to the theme you were actually on", () => {
// The whole point: two clicks from ihasmail must return to ihasmail, not
// deposit you on plain dark.
expect(toggleTarget("light", "ihasmail")).toBe("ihasmail");
expect(toggleTarget("light", "dark")).toBe("dark");
});
it("can bring back \"match system\", which the toggle used to strand", () => {
expect(toggleTarget("light", "system")).toBe("system");
});
it("round-trips every dark theme there is", () => {
for (const t of ["dark", "ihasmail", "system"] as const) {
expect(toggleTarget(toggleTarget("light", t) === "light" ? "light" : "dark", t), t).toBe("light");
expect(toggleTarget("light", t), t).toBe(t);
}
});
});
describe("remembering which dark theme you were on", () => {
const setTheme = (t: Theme) => {
useSettings.getState().update({ theme: t });
return useSettings.getState().settings;
};
it("records a dark theme chosen from Settings, not just from the toggle", () => {
// update() is the single path every way of choosing a theme goes through,
// which is why the remembering lives there rather than at the call sites.
expect(setTheme("dark").lastDarkTheme).toBe("dark");
expect(setTheme("ihasmail").lastDarkTheme).toBe("ihasmail");
expect(setTheme("system").lastDarkTheme).toBe("system");
});
it("does not let light overwrite it — that is the theme being toggled away from", () => {
setTheme("ihasmail");
expect(setTheme("light").lastDarkTheme).toBe("ihasmail");
});
it("survives a there-and-back through the toggle", () => {
setTheme("ihasmail");
const away = setTheme(toggleTarget("dark", useSettings.getState().settings.lastDarkTheme));
expect(away.theme).toBe("light");
const back = setTheme(toggleTarget("light", away.lastDarkTheme));
expect(back.theme).toBe("ihasmail");
});
});
+24 -2
View File
@@ -88,6 +88,14 @@ export interface Settings {
eventCategories: Array<{ name: string; color: string }>; eventCategories: Array<{ name: string; color: string }>;
/** Default sending identity per account (JMAP has no such flag). */ /** Default sending identity per account (JMAP has no such flag). */
defaultIdentityByAccount: Record<string, string>; defaultIdentityByAccount: Record<string, string>;
/**
* The theme the top-bar toggle goes back to from light. Remembered rather
* than assumed, so flipping to light and back returns you to the theme you
* were on — "ihasmail", "system" or plain "dark" — instead of dropping
* everyone onto the same one. Never "light": that is the side being
* toggled away from.
*/
lastDarkTheme: Exclude<Theme, "light">;
} }
export const DEFAULT_SETTINGS: Settings = { export const DEFAULT_SETTINGS: Settings = {
@@ -152,6 +160,7 @@ export const DEFAULT_SETTINGS: Settings = {
{ name: "Family", color: "#9333ea" }, { name: "Family", color: "#9333ea" },
], ],
defaultIdentityByAccount: {}, defaultIdentityByAccount: {},
lastDarkTheme: "ihasmail",
}; };
/** /**
@@ -215,14 +224,18 @@ applyDateTimePrefs(initialSettings);
export const useSettings = create<SettingsState>((set, get) => ({ export const useSettings = create<SettingsState>((set, get) => ({
settings: initialSettings, settings: initialSettings,
update(patch) { update(patch) {
const settings = { ...get().settings, ...patch }; // Picking a theme anywhere — the toggle, Appearance, an imported file —
// is what teaches the toggle where to come back to. Doing it here rather
// than at the call sites means a fourth way to set a theme cannot forget.
const next = patch.theme && patch.theme !== "light" ? { ...patch, lastDarkTheme: patch.theme } : patch;
const settings = { ...get().settings, ...next };
saveJson("settings", settings); saveJson("settings", settings);
set({ settings }); set({ settings });
applyTheme(settings); applyTheme(settings);
applyDateTimePrefs(settings); applyDateTimePrefs(settings);
// Dragging a splitter changes a device key on every frame and must not put // Dragging a splitter changes a device key on every frame and must not put
// a request in the air; anything else is queued and coalesced. // a request in the air; anything else is queued and coalesced.
if (Object.keys(patch).some((k) => !DEVICE_KEYS.has(k as keyof Settings))) { if (Object.keys(next).some((k) => !DEVICE_KEYS.has(k as keyof Settings))) {
queueSettingsPush(syncedPart(settings)); queueSettingsPush(syncedPart(settings));
} }
}, },
@@ -278,6 +291,15 @@ export function applyTheme(s: Settings = useSettings.getState().settings): void
if (meta) meta.content = s.theme === "ihasmail" ? THEME_COLOR.ihasmail : dark ? THEME_COLOR.dark : THEME_COLOR.light; if (meta) meta.content = s.theme === "ihasmail" ? THEME_COLOR.ihasmail : dark ? THEME_COLOR.dark : THEME_COLOR.light;
} }
/**
* Where the top-bar toggle goes next. Away from dark is always light; back
* from light is wherever you last were, which is the whole point of
* remembering it.
*/
export function toggleTarget(effective: "light" | "dark", lastDarkTheme: Settings["lastDarkTheme"]): Theme {
return effective === "dark" ? "light" : lastDarkTheme;
}
/** Whether a theme paints dark, resolving "system" against the OS. */ /** Whether a theme paints dark, resolving "system" against the OS. */
export function isDarkTheme(theme: Theme, prefersDark = false): boolean { export function isDarkTheme(theme: Theme, prefersDark = false): boolean {
return theme === "dark" || theme === "ihasmail" || (theme === "system" && prefersDark); return theme === "dark" || theme === "ihasmail" || (theme === "system" && prefersDark);
+18 -9
View File
@@ -2,7 +2,7 @@ import { useEffect, useState, type ReactNode } from "react";
import { Link, useLocation } from "wouter"; import { Link, useLocation } from "wouter";
import { Calendar, ChevronsUpDown, FolderOpen, HelpCircle, Mail, Menu as MenuIcon, Moon, PenSquare, Settings, Sun, Users, LogOut, Plus, RefreshCw } from "lucide-react"; import { Calendar, ChevronsUpDown, FolderOpen, HelpCircle, Mail, Menu as MenuIcon, Moon, PenSquare, Settings, Sun, Users, LogOut, Plus, RefreshCw } from "lucide-react";
import { useSession } from "@/store/session"; import { useSession } from "@/store/session";
import { useEffectiveTheme, useSettings } from "@/store/settings"; import { toggleTarget, useEffectiveTheme, useSettings } from "@/store/settings";
import { useMail } from "@/store/mail"; import { useMail } from "@/store/mail";
import { draftFromMailto, useCompose } from "@/store/compose"; import { draftFromMailto, useCompose } from "@/store/compose";
import { Avatar, useIsMobile } from "@/ui/misc"; import { Avatar, useIsMobile } from "@/ui/misc";
@@ -205,22 +205,31 @@ function QuotaBar() {
} }
/** /**
* Flip between light and dark from the top bar. * Flip to light and back from the top bar.
* *
* The stored setting has a third value, "system", so the button acts on what * The setting has four values and only two of them are "light", so the button
* is actually on screen rather than on the setting: whichever theme you can * acts on what is actually on screen rather than on the setting: if you can
* see, one click gives you the other one. Choosing "match system" again lives * see a dark theme, one click gives you light.
* in Settings Appearance, where the three-way choice belongs. *
* Coming back is the part that needs remembering. There is more than one way
* to be dark — "dark", "ihasmail", or "system" while the OS is — so the way
* back is whichever you were on, kept in `lastDarkTheme`, rather than plain
* "dark" for everyone. Without that, two clicks would quietly move an
* ihasmail user onto a theme they never chose.
*/ */
function ThemeToggle() { function ThemeToggle() {
const effective = useEffectiveTheme(); const effective = useEffectiveTheme();
const lastDarkTheme = useSettings((s) => s.settings.lastDarkTheme);
const update = useSettings((s) => s.update); const update = useSettings((s) => s.update);
const next = effective === "dark" ? "light" : "dark"; const next = toggleTarget(effective, lastDarkTheme);
// The label names where you are going, and going back is not always "dark"
// any more -- it is whichever theme you were on before flipping to light.
const label = next === "light" ? "light mode" : next === "system" ? "your system theme" : next === "ihasmail" ? "the ihasmail theme" : "dark mode";
return ( return (
<button <button
className="icon-btn" className="icon-btn"
aria-label={`Switch to ${next} mode`} aria-label={`Switch to ${label}`}
title={`Switch to ${next} mode`} title={`Switch to ${label}`}
onClick={() => update({ theme: next })} onClick={() => update({ theme: next })}
> >
{effective === "dark" ? <Sun size={21} /> : <Moon size={21} />} {effective === "dark" ? <Sun size={21} /> : <Moon size={21} />}