Merge pull request #64 from LINUXexpert-org/theme-ihasmail

Add the ihasmail theme, and make it the default
This commit is contained in:
LINUXexpert.org
2026-08-26 11:39:56 -07:00
committed by GitHub
7 changed files with 322 additions and 23 deletions
+1 -1
View File
@@ -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)
+11 -2
View File
@@ -4,8 +4,17 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#0f766e" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#0b1220" media="(prefers-color-scheme: dark)" />
<!--
One tag, no media query: applyTheme() keeps it in step with the chosen
theme, which a media query cannot do — it only knows what the OS prefers,
not what the user picked here. There used to be two, both with media
attributes, which meant the selector in applyTheme (:not([media])) matched
neither and the colour never moved off whatever the OS implied.
The initial value is the default theme's background, so the browser chrome
is right from the first paint rather than only once JS has run.
-->
<meta name="theme-color" content="#0d2430" />
<meta name="description" content="ihasmail - fast, friendly JMAP webmail for Stalwart" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
+165
View File
@@ -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<string, string>();
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" });
});
});
+53 -7
View File
@@ -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<string, string>;
/**
* 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<Theme, "light">;
}
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<SettingsState>((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<HTMLMetaElement>('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;
+53
View File
@@ -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; }
+18 -9
View File
@@ -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 (
<button
className="icon-btn"
aria-label={`Switch to ${next} mode`}
title={`Switch to ${next} mode`}
aria-label={`Switch to ${label}`}
title={`Switch to ${label}`}
onClick={() => update({ theme: next })}
>
{effective === "dark" ? <Sun size={21} /> : <Moon size={21} />}
+21 -4
View File
@@ -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() {
<p className="lead">Make ihasmail yours.</p>
<h2>Theme</h2>
<div className="theme-grid">
{(["system", "light", "dark"] as const).map((t) => (
<button key={t} className={`theme-card ${s.theme === t ? "active" : ""}`} onClick={() => update({ theme: t })}>
<div className="preview" style={{ background: t === "dark" ? "#0b1220" : t === "light" ? "#f6f8fa" : "linear-gradient(90deg,#f6f8fa 50%,#0b1220 50%)" }} />
{t === "system" ? "Match system" : t === "light" ? "Light" : "Dark"}
{THEMES.map((t) => (
<button key={t.id} className={`theme-card ${s.theme === t.id ? "active" : ""}`} onClick={() => update({ theme: t.id })}>
<div className="preview" style={{ background: t.preview }} />
{t.label}
</button>
))}
</div>
<p className="hint" style={{ marginTop: 10 }}>
<strong>ihasmail</strong> is the palette from <a href="https://ihasmail.org" target="_blank" rel="noopener noreferrer">ihasmail.org</a>, 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.
</p>
<Switch
checked={s.themeMessageBody}
onChange={(v) => update({ themeMessageBody: v })}