Files
ihasmail/web/src/lib/__tests__/theme.test.ts
T
jcoffey-dev 9aa0eda0d5 Six palettes, each with a light half and a dark one
The theme was one enum -- system, light, dark, ihasmail -- where one value
carried a whole palette and implied dark. That works for exactly one
palette. The two questions now come apart: which palette, and which side.

Classic is the plain light and dark this app has always had. ihasmail's
own palette gains a day version, so the background of the dark one becomes
the text of the light one and the two read as one palette from either end.
Dracula, Gruvbox, Rosé Pine and Tokyo Night are the work of their own
projects, used under the MIT licence, and taken from each project's own
repository rather than from anyone's reimplementation. What was fetched is
recorded in .palette-sources/ and credited in NOTICE.

Giving ihasmail's palette a light half removed a whole special case.
Nothing is one-sided any more, so a palette can no longer override the
mode, the toggle no longer has to set a palette aside on the way to light
and remember it, and the greyed-out control that explained all that is
gone. The old lastDarkTheme, which existed only for that, is gone with it.

The shades between the published colours are derived rather than guessed:
these projects publish twelve to twenty values and ihasmail needs about
thirty. scripts/build-palettes.py computes the tiers and then measures
every text colour against the surface it sits on -- 4.5:1 for prose, 3:1
for borders and marks -- lifting anything short towards white on a dark
ground and towards black on a light one, so a lifted tier keeps its hue.
It refuses to write a palette that would not pass.

Every one of the nine halves needed at least one lift. These palettes are
built for code editors, not for prose at this size: Dracula's comment grey
is 3.03:1 on its own background and Rosé Pine's gold is 2.7:1 on Dawn.
Shipping them as published would have quietly ended the WCAG AA claim.

Two things caught while checking rather than while writing. The generated
blocks were appended to the end of the stylesheet, which put them after
the accent variants at equal specificity -- so choosing an accent over one
of the new palettes did nothing at all. They now sit before those rules,
where the existing ihasmail block's own comment says they have to. And
that block was unqualified, so it would have shadowed the new light half;
it is now explicitly the dark one.

Settings written before this carry `theme` and no palette, and are read
through the old enum. `theme` is still written back, derived, because a
device on an older build reads it and would otherwise be stranded on a
theme nobody chose.
2026-09-02 00:14:54 -07:00

184 lines
7.9 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { DEFAULT_SETTINGS, DEVICE_KEYS, acceptRemote, isDarkTheme, syncedPart, useSettings, type Theme } from "@/store/settings";
import { toggleTarget, type Mode, type PaletteId } from "@/lib/palette";
import { loadJson, saveJson, setDeviceTrusted } 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),
},
});
// Reads and writes are gated on device trust now, and the gate defaults to
// closed. These tests are about `loadJson`'s merge, so open it and put it
// back -- an untrusted device is covered by storage.test.ts instead.
setDeviceTrusted(true);
try {
fn();
} finally {
setDeviceTrusted(false);
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({ palette: "ihasmail", mode: "dark" }, false).mode).toBe("light");
expect(toggleTarget({ palette: "default", mode: "dark" }, false).mode).toBe("light");
expect(toggleTarget({ palette: "default", mode: "system" }, true).mode).toBe("light");
});
it("comes back to the palette you were actually on", () => {
// The whole point: two presses from ihasmail must return to ihasmail, not
// deposit you on plain dark.
const away = toggleTarget({ palette: "ihasmail", mode: "dark" }, false);
expect(toggleTarget(away, false).palette).toBe("ihasmail");
expect(toggleTarget({ palette: "default", mode: "light" }, false)).toMatchObject({ palette: "default", mode: "dark" });
});
});
describe("remembering the palette you were on", () => {
const set = (palette: PaletteId, mode: Mode) => {
useSettings.getState().update({ palette, mode });
return useSettings.getState().settings;
};
it("derives the legacy theme from whatever set the palette or mode", () => {
// `theme` is no longer chosen; it is kept in step so a device on an older
// build is not stranded on a theme nobody picked.
expect(set("default", "dark").theme).toBe("dark");
expect(set("ihasmail", "dark").theme).toBe("ihasmail");
expect(set("default", "system").theme).toBe("system");
expect(set("gruvbox", "light").theme).toBe("light");
expect(set("dracula", "dark").theme).toBe("dark");
});
it("survives a there-and-back through the toggle", () => {
// Two presses return you exactly where you started, and the palette never
// moves -- which is the whole of what the old lastDarkTheme existed for.
set("ihasmail", "dark");
const away = toggleTarget({ palette: "ihasmail", mode: "dark" }, false);
expect(away).toEqual({ palette: "ihasmail", mode: "light" });
expect(toggleTarget(away, false)).toEqual({ palette: "ihasmail", mode: "dark" });
});
it("keeps the colours when the palette has both sides", () => {
const away = toggleTarget({ palette: "gruvbox", mode: "dark" }, false);
expect(away.palette).toBe("gruvbox");
expect(away.mode).toBe("light");
});
});
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("palette");
expect(synced).toHaveProperty("mode");
for (const k of ["theme", "palette", "mode"] as const) {
expect(DEVICE_KEYS.has(k)).toBe(false);
}
});
it("is applied from a settings file another device wrote", () => {
expect(acceptRemote({ palette: "gruvbox", mode: "light" })).toEqual({ palette: "gruvbox", mode: "light" });
});
it("reads a file written before palettes existed through the old enum", () => {
// Settings live in the account's Files and are opened by whatever version
// runs next, so this is not a one-release migration.
expect(acceptRemote({ theme: "ihasmail" })).toMatchObject({ palette: "ihasmail", mode: "dark" });
expect(acceptRemote({ theme: "light" })).toMatchObject({ palette: "default", mode: "light" });
});
it("prefers the new fields when a file carries both", () => {
// A file with both is newer, and its `theme` is the derived copy rather
// than the choice -- so it must not overrule the palette beside it.
expect(acceptRemote({ theme: "dark", palette: "rose-pine", mode: "light" })).toMatchObject({
palette: "rose-pine",
mode: "light",
});
});
});