diff --git a/README.md b/README.md
index 2a7375f..19450b7 100644
--- a/README.md
+++ b/README.md
@@ -62,7 +62,7 @@ seconds of downtime with nothing lost.
## Features
**Mail**
-- Gmail-style three-pane layout (reading pane right/bottom/off, **drag-to-resize splitter** in both orientations, quick layout switch in the list menu), conversation view with collapsed messages and "show quoted text", dense/cozy/comfortable density, light/dark/system theme with accent colours
+- Gmail-style three-pane layout (reading pane right/bottom/off, **drag-to-resize splitter** in both orientations, quick layout switch in the list menu), conversation view with collapsed messages and "show quoted text", dense/cozy/comfortable density, light/dark/system themes plus **ihasmail** — the palette from ihasmail.org, and what a new account starts on — each with accent colours over the top
- Virtualised, infinitely-scrolling message list; multi-select (click, ⇧-click, ⌃-click), drag & drop to folders, right-click context menus, hover actions, Gmail keyboard shortcuts (`j/k`, `e`, `#`, `r/a/f`, `g i`, `/`, `?` …)
- Archive / delete / spam / star / mark read / move / labels (IMAP keywords with colours) with **Undo**
- **"Filter messages like this…"** from the message context menu: creates a Sieve rule pre-filled from the sender/list (target folders can be created on the fly), and can **apply it immediately to the existing messages in the folder** (evaluated client-side, actions applied via JMAP)
diff --git a/web/index.html b/web/index.html
index abb8739..54e4ec5 100644
--- a/web/index.html
+++ b/web/index.html
@@ -4,8 +4,17 @@
-
-
+
+
diff --git a/web/src/lib/__tests__/theme.test.ts b/web/src/lib/__tests__/theme.test.ts
new file mode 100644
index 0000000..f73ce3e
--- /dev/null
+++ b/web/src/lib/__tests__/theme.test.ts
@@ -0,0 +1,165 @@
+import { describe, expect, it } from "vitest";
+import { DEFAULT_SETTINGS, DEVICE_KEYS, acceptRemote, isDarkTheme, syncedPart, toggleTarget, useSettings, type Theme } from "@/store/settings";
+import { loadJson, saveJson } from "@/lib/storage";
+
+/**
+ * "ihasmail" is a dark theme wearing ihasmail.org's palette. Everything that
+ * asks "is this dark?" has to say yes for it — the top-bar toggle picks its
+ * icon from the answer, and the message frame decides whether mail sits on a
+ * light card or follows the app. A theme that painted dark while reporting
+ * light would show a sun icon on a dark screen and light-card mail on it.
+ */
+
+describe("which themes paint dark", () => {
+ it("counts ihasmail as dark, regardless of the OS", () => {
+ expect(isDarkTheme("ihasmail", false)).toBe(true);
+ expect(isDarkTheme("ihasmail", true)).toBe(true);
+ });
+
+ it("still resolves the ordinary three the way it always did", () => {
+ expect(isDarkTheme("dark", false)).toBe(true);
+ expect(isDarkTheme("light", true)).toBe(false);
+ expect(isDarkTheme("system", true)).toBe(true);
+ expect(isDarkTheme("system", false)).toBe(false);
+ });
+
+ it("treats a missing OS preference as light, not as unknown", () => {
+ // matchMedia is absent in some embeddings; the default must not read dark.
+ expect(isDarkTheme("system")).toBe(false);
+ });
+
+ it("has an answer for every theme there is", () => {
+ // A theme added later without a branch here would silently paint light.
+ const all: Theme[] = ["system", "light", "dark", "ihasmail"];
+ for (const t of all) expect(typeof isDarkTheme(t, false), t).toBe("boolean");
+ });
+});
+
+describe("the default theme", () => {
+ it("is ihasmail, so a new account looks like ihasmail before anyone chooses", () => {
+ expect(DEFAULT_SETTINGS.theme).toBe("ihasmail");
+ });
+
+ /**
+ * The guarantee that matters when a default changes: it moves nobody who
+ * already has a theme stored — which is everyone using ihasmail today, since
+ * the setting is saved whether or not they deliberately picked it.
+ *
+ * `localStorage` is not available in this environment, and `saveJson`
+ * swallows that, so a plain round-trip here would pass for the wrong reason:
+ * both sides would be the fallback. Stub it, so what is under test is
+ * `loadJson`'s merge rather than the environment.
+ */
+ const withStorage = (fn: () => void) => {
+ const store = new Map();
+ Object.defineProperty(globalThis, "localStorage", {
+ configurable: true,
+ value: {
+ getItem: (k: string) => store.get(k) ?? null,
+ setItem: (k: string, v: string) => void store.set(k, v),
+ removeItem: (k: string) => void store.delete(k),
+ },
+ });
+ try {
+ fn();
+ } finally {
+ Reflect.deleteProperty(globalThis, "localStorage");
+ }
+ };
+
+ it("is only a default — a stored theme wins", () => {
+ withStorage(() => {
+ saveJson("theme-test", { ...DEFAULT_SETTINGS, theme: "light" });
+ expect(loadJson("theme-test", DEFAULT_SETTINGS).theme).toBe("light");
+ });
+ });
+
+ it("fills in from the default only for keys the stored settings lack", () => {
+ withStorage(() => {
+ // An older settings blob that predates a key must not lose the new one.
+ saveJson("theme-test-partial", { theme: "dark" });
+ const loaded = loadJson("theme-test-partial", DEFAULT_SETTINGS);
+ expect(loaded.theme).toBe("dark");
+ expect(loaded.accent).toBe(DEFAULT_SETTINGS.accent);
+ });
+ });
+
+ it("falls back to the default when nothing is stored", () => {
+ withStorage(() => {
+ expect(loadJson("theme-test-absent", DEFAULT_SETTINGS).theme).toBe("ihasmail");
+ });
+ });
+});
+
+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");
+ });
+});
+
+describe("where the theme settings live", () => {
+ it("follows the account, not the browser", () => {
+ // Both of these ride in the account's settings.json, so a theme chosen on
+ // one machine — and the toggle's way back to it — are the same everywhere.
+ // Named explicitly rather than derived from DEVICE_KEYS: the test that
+ // does derive it would still pass if one of these were moved there, since
+ // its expectation would move too.
+ const synced = syncedPart(DEFAULT_SETTINGS);
+ expect(synced).toHaveProperty("theme");
+ expect(synced).toHaveProperty("lastDarkTheme");
+ expect(DEVICE_KEYS.has("theme")).toBe(false);
+ expect(DEVICE_KEYS.has("lastDarkTheme")).toBe(false);
+ });
+
+ it("is applied from a settings file another device wrote", () => {
+ expect(acceptRemote({ theme: "dark", lastDarkTheme: "dark" })).toEqual({ theme: "dark", lastDarkTheme: "dark" });
+ });
+});
diff --git a/web/src/store/settings.ts b/web/src/store/settings.ts
index 29a6e85..a10c409 100644
--- a/web/src/store/settings.ts
+++ b/web/src/store/settings.ts
@@ -4,7 +4,12 @@ import { loadJson, saveJson } from "@/lib/storage";
import { queueSettingsPush } from "@/lib/settingsSync";
import { setDateTimePrefs, type DateFormat, type TimeFormat } from "@/lib/datetime";
-export type Theme = "system" | "light" | "dark";
+/**
+ * "ihasmail" is a dark theme carrying the palette from ihasmail.org. It is a
+ * theme rather than an accent because it changes the backgrounds, borders and
+ * text as well as the highlight colour — an accent could not.
+ */
+export type Theme = "system" | "light" | "dark" | "ihasmail";
export type Density = "comfortable" | "cozy" | "compact";
export type ReadingPane = "right" | "bottom" | "off";
export type ImagePolicy = "ask" | "always" | "contacts";
@@ -83,10 +88,25 @@ 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 = {
- theme: "system",
+ /**
+ * ihasmail's own palette is what a new account gets, so the app looks like
+ * itself before anyone has chosen anything. It is only a default: a stored
+ * theme always wins, so nobody who has picked one — including everyone
+ * already using ihasmail, whose choice is saved even if they never changed
+ * it — is moved off it.
+ */
+ theme: "ihasmail",
accent: "teal",
density: "cozy",
readingPane: "right",
@@ -140,6 +160,7 @@ export const DEFAULT_SETTINGS: Settings = {
{ name: "Family", color: "#9333ea" },
],
defaultIdentityByAccount: {},
+ lastDarkTheme: "ihasmail",
};
/**
@@ -203,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));
}
},
@@ -247,16 +272,37 @@ function applyDateTimePrefs(s: Settings): void {
setDateTimePrefs({ locale: s.locale, dateFormat: s.dateFormat, timeFormat: s.timeFormat });
}
+/** Background of each theme, for the browser chrome (`theme-color`). */
+const THEME_COLOR = { light: "#ffffff", dark: "#0b1220", ihasmail: "#0d2430" } as const;
+
export function applyTheme(s: Settings = useSettings.getState().settings): void {
const root = document.documentElement;
const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)").matches;
- const dark = s.theme === "dark" || (s.theme === "system" && prefersDark);
+ const dark = isDarkTheme(s.theme, prefersDark);
+ // ihasmail keeps data-theme="dark" and adds a palette on top, so every
+ // dark-only rule in the stylesheet applies to it without being repeated.
root.dataset.theme = dark ? "dark" : "light";
+ if (s.theme === "ihasmail") root.dataset.palette = "ihasmail";
+ else delete root.dataset.palette;
root.dataset.density = s.density;
root.dataset.accent = s.accent;
root.dataset.fontsize = s.fontSize;
const meta = document.querySelector('meta[name="theme-color"]:not([media])');
- if (meta) meta.content = dark ? "#0b1220" : "#ffffff";
+ 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);
}
if (typeof window !== "undefined") {
@@ -278,7 +324,7 @@ export function useEffectiveTheme(): "light" | "dark" {
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, []);
- return theme === "dark" || (theme === "system" && systemDark) ? "dark" : "light";
+ return isDarkTheme(theme, systemDark) ? "dark" : "light";
}
export const settings = () => useSettings.getState().settings;
diff --git a/web/src/styles/app.css b/web/src/styles/app.css
index 415cffb..b4a4f7a 100644
--- a/web/src/styles/app.css
+++ b/web/src/styles/app.css
@@ -90,6 +90,59 @@
color-scheme: dark;
}
+/*
+ * The "ihasmail" theme: the palette from ihasmail.org, which is a teal-navy
+ * rather than the blue-slate of the plain dark theme, warmed by the orange the
+ * logo's cat is drawn in.
+ *
+ * It rides on data-theme="dark" rather than replacing it, so every dark-only
+ * rule further down this file -- tooltips, toasts, the message frame -- keeps
+ * applying without being duplicated. Only the palette is overridden.
+ *
+ * Specificity is doing deliberate work here. This block is [data-palette] plus
+ * :root, so 0,2,0; the accent variants below are :root[data-theme][data-accent],
+ * so 0,3,0 and they win. That is what makes the accent swatches keep working on
+ * top of this theme -- and because the default accent ("teal") has no rule of
+ * its own, ihasmail.org's own accent is what shows until someone picks another.
+ */
+:root[data-palette="ihasmail"] {
+ --bg: #0d2430;
+ --bg-elev: #12303e;
+ --bg-sunken: #0a1c26;
+ --bg-hover: rgba(70, 202, 195, 0.10);
+ --bg-active: rgba(70, 202, 195, 0.16);
+ --fg: #eaf6f6;
+ --fg-muted: #a3c3cb;
+ --fg-faint: #86aab4;
+ --border: #21505f;
+ --border-strong: #2e6a7a;
+ --accent: #46cac3;
+ --accent-fg: #062028;
+ --accent-soft: rgba(70, 202, 195, 0.16);
+ --accent-soft-fg: #9fe6e2;
+ --danger: #f87171;
+ --danger-soft: rgba(248, 113, 113, 0.15);
+ --warn: #f9a34b;
+ --warn-soft: rgba(249, 163, 75, 0.14);
+ --success: #4ade80;
+ --success-soft: rgba(74, 222, 128, 0.15);
+ --link: #6fdcd6;
+ --unread-bg: #163a4a;
+ --read-bg: #12303e;
+ --selected-bg: rgba(70, 202, 195, 0.18);
+ --focus-ring: 0 0 0 3px rgba(70, 202, 195, 0.4);
+ /* The cat is orange; so is the star. */
+ --star: #f9a34b;
+ --q1: #6fdcd6;
+ --q2: #4ade80;
+ --q3: #c084fc;
+ --scrollbar: rgba(163, 195, 203, 0.3);
+ --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.45);
+ --shadow-2: 0 8px 24px rgba(0, 0, 0, 0.55);
+ --shadow-3: 0 22px 60px -28px rgba(0, 0, 0, 0.75);
+ color-scheme: dark;
+}
+
/* Accent variants */
:root[data-accent="blue"] { --accent: #2563eb; --accent-soft: #dbeafe; --accent-soft-fg: #1e3a8a; --selected-bg: #dbeafe; --focus-ring: 0 0 0 3px rgba(37,99,235,.35); --link:#1d4ed8; }
:root[data-accent="purple"] { --accent: #7c3aed; --accent-soft: #ede9fe; --accent-soft-fg: #4c1d95; --selected-bg: #ede9fe; --focus-ring: 0 0 0 3px rgba(124,58,237,.35); --link:#6d28d9; }
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 (