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 })}