From 8487f561f647c9d63ad9ef29491ef95a36cb9638 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 26 Aug 2026 11:28:47 -0700 Subject: [PATCH 1/3] Add the ihasmail theme, and make it the default A dark theme carrying ihasmail.org's palette: a teal-navy ground rather than the blue-slate of the plain dark theme, with the orange the logo's cat is drawn in doing the work of the star and the warning colour. The values are the site's own, read from its stylesheet rather than picked by eye. It is a theme rather than an accent because it changes backgrounds, borders and text as well as the highlight -- an accent could not. It rides on data-theme="dark" and adds data-palette="ihasmail" on top, so the eleven dark-only rules further down the stylesheet keep applying without being duplicated for a second dark theme. Specificity then does something deliberate: the palette block is 0,2,0 and the accent variants are 0,3,0, so a chosen accent still wins over it -- and because the default accent ("teal") has no rule of its own, ihasmail.org's accent is what shows until someone picks another. Verified both ways in a browser. It is now what a new account starts on, so the app looks like itself before anyone has chosen anything. Only a default: a stored theme always wins, which leaves everyone already using ihasmail where they are, since the setting is saved whether or not they deliberately picked it. While here, the theme-color meta tag was fixed. There were two, both carrying media attributes, and applyTheme looks for :not([media]) -- so it matched neither and the browser chrome had never followed the chosen theme at all, only what the OS preferred. One tag now, updated from JS, starting at the default theme's background so the first paint is right too. Contrast measured rather than assumed, against the theme's own background: text 14.5:1, muted 8.6:1, faint 6.4:1, accent 8.0:1, link 9.8:1, star 7.9:1, accent-on-accent 8.4:1. All AA or better. --- README.md | 2 +- web/index.html | 13 ++- web/src/lib/__tests__/theme.test.ts | 92 +++++++++++++++++++ web/src/store/settings.ts | 34 ++++++- web/src/styles/app.css | 53 +++++++++++ web/src/views/settings/AppearanceSettings.tsx | 25 ++++- 6 files changed, 207 insertions(+), 12 deletions(-) create mode 100644 web/src/lib/__tests__/theme.test.ts 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. +

update({ themeMessageBody: v })} From 9689ac8aaefea1443ba543c88949b8ac6c9c007e Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 26 Aug 2026 11:34:45 -0700 Subject: [PATCH 2/3] 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. --- web/src/lib/__tests__/theme.test.ts | 56 ++++++++++++++++++++++++++++- web/src/store/settings.ts | 26 ++++++++++++-- web/src/views/AppShell.tsx | 27 +++++++++----- 3 files changed, 97 insertions(+), 12 deletions(-) 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 (