From 8487f561f647c9d63ad9ef29491ef95a36cb9638 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 26 Aug 2026 11:28:47 -0700 Subject: [PATCH] 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 })}