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.
This commit is contained in:
2026-08-26 11:28:47 -07:00
parent 16351abdbf
commit 8487f561f6
6 changed files with 207 additions and 12 deletions
+92
View File
@@ -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<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");
});
});
});