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.
This commit is contained in:
2026-09-02 00:14:54 -07:00
parent 6c7c6d19b3
commit 9aa0eda0d5
12 changed files with 1207 additions and 104 deletions
+86
View File
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import { effectiveMode, legacyTheme, migrateTheme, paletteMeta, PALETTES, toggleTarget } from "@/lib/palette";
describe("the palettes themselves", () => {
it("has a light and a dark half for every one of them", () => {
// The reason there is no "this palette is dark only" machinery: there is
// no such palette. ihasmail's own gained a light half, and the override,
// the toggle's memory and a greyed-out control all went with it.
expect(PALETTES.map((p) => p.id)).toEqual(["default", "ihasmail", "dracula", "gruvbox", "rose-pine", "tokyo-night"]);
});
it("credits every borrowed palette and neither of ihasmail's own", () => {
for (const p of PALETTES) {
if (p.id === "default" || p.id === "ihasmail") expect(p.credit).toBeUndefined();
else expect(p.credit).toMatch(/MIT/);
}
});
it("falls back to the default for an id it does not know", () => {
expect(paletteMeta("nonsense").id).toBe("default");
expect(paletteMeta(null).id).toBe("default");
});
});
describe("effectiveMode", () => {
it("follows the system when asked to", () => {
expect(effectiveMode("system", true)).toBe("dark");
expect(effectiveMode("system", false)).toBe("light");
});
it("takes an explicit mode over the system", () => {
expect(effectiveMode("light", true)).toBe("light");
expect(effectiveMode("dark", false)).toBe("dark");
});
});
describe("migrateTheme, which has to keep working indefinitely", () => {
it("reads every value the old enum could hold", () => {
expect(migrateTheme("ihasmail")).toEqual({ palette: "ihasmail", mode: "dark" });
expect(migrateTheme("light")).toEqual({ palette: "default", mode: "light" });
expect(migrateTheme("dark")).toEqual({ palette: "default", mode: "dark" });
expect(migrateTheme("system")).toEqual({ palette: "default", mode: "system" });
});
it("gives a new account what it would have got anyway", () => {
// Absent, unknown, or written by something newer.
for (const v of [undefined, null, "", "gruvbox-ish", "whatever"]) {
expect(migrateTheme(v)).toEqual({ palette: "ihasmail", mode: "dark" });
}
});
});
describe("legacyTheme, read by a device still on an older build", () => {
it("round-trips the four values the old enum had", () => {
for (const v of ["ihasmail", "light", "dark", "system"] as const) {
expect(legacyTheme(migrateTheme(v))).toBe(v);
}
});
it("expresses a new palette as the light or dark it actually is", () => {
// It cannot say "Gruvbox", but it can say dark, which is the half that
// stops an older device showing a theme nobody chose.
expect(legacyTheme({ palette: "gruvbox", mode: "dark" })).toBe("dark");
expect(legacyTheme({ palette: "rose-pine", mode: "light" })).toBe("light");
expect(legacyTheme({ palette: "tokyo-night", mode: "system" }, true)).toBe("dark");
expect(legacyTheme({ palette: "tokyo-night", mode: "system" }, false)).toBe("light");
// ihasmail's light half is new and has no old name, so an older build is
// told "light" rather than being handed a word it would read as dark.
expect(legacyTheme({ palette: "ihasmail", mode: "light" })).toBe("light");
expect(legacyTheme({ palette: "ihasmail", mode: "dark" })).toBe("ihasmail");
});
});
describe("toggleTarget", () => {
it("flips the mode and keeps the colours, whatever the palette", () => {
for (const palette of ["default", "ihasmail", "gruvbox", "dracula", "rose-pine", "tokyo-night"] as const) {
expect(toggleTarget({ palette, mode: "dark" }, false)).toEqual({ palette, mode: "light" });
expect(toggleTarget({ palette, mode: "light" }, false)).toEqual({ palette, mode: "dark" });
}
});
it("reads the system when the mode is system", () => {
expect(toggleTarget({ palette: "default", mode: "system" }, true).mode).toBe("light");
expect(toggleTarget({ palette: "default", mode: "system" }, false).mode).toBe("dark");
});
});