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..fbf86e4
--- /dev/null
+++ b/web/src/lib/__tests__/theme.test.ts
@@ -0,0 +1,92 @@
+import { describe, expect, it } from "vitest";
+import { DEFAULT_SETTINGS, isDarkTheme, 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");
+ });
+ });
+});
diff --git a/web/src/store/settings.ts b/web/src/store/settings.ts
index 29a6e85..b1b370a 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";
@@ -86,7 +91,14 @@ export interface Settings {
}
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",
@@ -247,16 +259,28 @@ 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;
+}
+
+/** 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 +302,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/settings/AppearanceSettings.tsx b/web/src/views/settings/AppearanceSettings.tsx
index 0343865..40a3f04 100644
--- a/web/src/views/settings/AppearanceSettings.tsx
+++ b/web/src/views/settings/AppearanceSettings.tsx
@@ -1,6 +1,20 @@
import { useSettings } from "@/store/settings";
import { Switch } from "@/ui/misc";
+/**
+ * The theme cards, each previewing the background it actually paints. Kept as
+ * data rather than three inline ternaries so a fourth does not mean editing a
+ * conditional in three places.
+ */
+const THEMES = [
+ { id: "system", label: "Match system", preview: "linear-gradient(90deg,#f6f8fa 50%,#0b1220 50%)" },
+ { id: "light", label: "Light", preview: "#f6f8fa" },
+ { id: "dark", label: "Dark", preview: "#0b1220" },
+ // The ihasmail.org palette: its background, with its teal and the logo's
+ // orange showing, so the card looks like what picking it does.
+ { id: "ihasmail", label: "ihasmail", preview: "linear-gradient(135deg,#0d2430 0%,#12303e 55%,#46cac3 55%,#46cac3 78%,#f9a34b 78%)" },
+] as const;
+
const ACCENTS = [
{ id: "teal", color: "#0f766e" },
{ id: "blue", color: "#2563eb" },
@@ -19,13 +33,16 @@ export function AppearanceSettings() {
Make ihasmail yours.
Theme
- {(["system", "light", "dark"] as const).map((t) => (
-
+
+ ihasmail is the palette from ihasmail.org, and what a new account starts on. It is a dark theme, so it counts as dark wherever that matters, and the accent colour below still applies on top of it.
+