diff --git a/web/src/lib/__tests__/theme.test.ts b/web/src/lib/__tests__/theme.test.ts index fbf86e4..bc62612 100644 --- a/web/src/lib/__tests__/theme.test.ts +++ b/web/src/lib/__tests__/theme.test.ts @@ -1,5 +1,5 @@ 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"; /** @@ -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"); + }); +}); diff --git a/web/src/store/settings.ts b/web/src/store/settings.ts index b1b370a..a10c409 100644 --- a/web/src/store/settings.ts +++ b/web/src/store/settings.ts @@ -88,6 +88,14 @@ export interface Settings { eventCategories: Array<{ name: string; color: string }>; /** Default sending identity per account (JMAP has no such flag). */ defaultIdentityByAccount: Record; + /** + * 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; } export const DEFAULT_SETTINGS: Settings = { @@ -152,6 +160,7 @@ export const DEFAULT_SETTINGS: Settings = { { name: "Family", color: "#9333ea" }, ], defaultIdentityByAccount: {}, + lastDarkTheme: "ihasmail", }; /** @@ -215,14 +224,18 @@ applyDateTimePrefs(initialSettings); export const useSettings = create((set, get) => ({ settings: initialSettings, 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); set({ settings }); applyTheme(settings); applyDateTimePrefs(settings); // 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. - 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)); } }, @@ -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; } +/** + * 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. */ export function isDarkTheme(theme: Theme, prefersDark = false): boolean { return theme === "dark" || theme === "ihasmail" || (theme === "system" && prefersDark); diff --git a/web/src/views/AppShell.tsx b/web/src/views/AppShell.tsx index 5fbeb25..01d4d25 100644 --- a/web/src/views/AppShell.tsx +++ b/web/src/views/AppShell.tsx @@ -2,7 +2,7 @@ import { useEffect, useState, type ReactNode } from "react"; 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 { useSession } from "@/store/session"; -import { useEffectiveTheme, useSettings } from "@/store/settings"; +import { toggleTarget, useEffectiveTheme, useSettings } from "@/store/settings"; import { useMail } from "@/store/mail"; import { draftFromMailto, useCompose } from "@/store/compose"; 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 - * is actually on screen rather than on the setting: whichever theme you can - * see, one click gives you the other one. Choosing "match system" again lives - * in Settings › Appearance, where the three-way choice belongs. + * The setting has four values and only two of them are "light", so the button + * acts on what is actually on screen rather than on the setting: if you can + * see a dark theme, one click gives you light. + * + * 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() { const effective = useEffectiveTheme(); + const lastDarkTheme = useSettings((s) => s.settings.lastDarkTheme); 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 (