Files
ihasmail-inbuxa/web/src/lib/palette.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

114 lines
4.6 KiB
TypeScript

/**
* Palettes, and the two axes they replaced.
*
* The theme used to be one enum — `system | light | dark | ihasmail` — where
* "ihasmail" carried a whole palette and implied dark. That works for exactly
* one palette. With several, the two questions come apart: **which palette**
* (the colours) and **which mode** (light or dark), and they are chosen
* separately.
*
* Every palette here is taken from the project that publishes it, all MIT, and
* from that project's own repository rather than from anyone's reimplementation
* of it. The values are recorded in `.palette-sources/palettes-upstream.md` so
* the derivation can be checked rather than taken on trust.
*/
export type PaletteId = "default" | "ihasmail" | "dracula" | "gruvbox" | "rose-pine" | "tokyo-night";
export type Mode = "system" | "light" | "dark";
/** What a mode resolves to once the system has been asked. */
export type ResolvedMode = "light" | "dark";
export interface PaletteMeta {
id: PaletteId;
name: string;
/** Shown in Settings and in NOTICE; who to credit and under what. */
credit?: string;
}
export const PALETTES: PaletteMeta[] = [
{ id: "default", name: "Classic" },
{ id: "ihasmail", name: "ihasmail" },
{ id: "dracula", name: "Dracula", credit: "Dracula Theme (MIT) — dark: Dracula, light: Alucard" },
{ id: "gruvbox", name: "Gruvbox", credit: "gruvbox by morhetz (MIT)" },
{ id: "rose-pine", name: "Rosé Pine", credit: "Rosé Pine (MIT) — light variant is Dawn" },
{ id: "tokyo-night", name: "Tokyo Night", credit: "Tokyo Night by enkia (MIT) — light variant is Day" },
];
const byId = new Map(PALETTES.map((p) => [p.id, p]));
export function paletteMeta(id: PaletteId | string | null | undefined): PaletteMeta {
return byId.get(id as PaletteId) ?? byId.get("default")!;
}
/**
* Which of light and dark is actually being drawn.
*
* Every palette has both halves, so this is only ever resolving "system"
* against the OS. That was not true while `ihasmail` was dark-only: the mode
* then had to be overridden by the palette, and the toggle had to remember
* which palette it had set aside on the way to light. Giving that palette a
* light half removed the override, the memory and the greyed-out control in
* one go.
*/
export function effectiveMode(mode: Mode, prefersDark: boolean): ResolvedMode {
if (mode === "system") return prefersDark ? "dark" : "light";
return mode;
}
export interface ThemeChoice {
palette: PaletteId;
mode: Mode;
}
/**
* The old enum, read as the two axes.
*
* Settings are stored in the account's own Files and are read by whatever
* version happens to open them next, so this has to keep working indefinitely
* rather than for one release.
*/
export function migrateTheme(theme: string | null | undefined): ThemeChoice {
switch (theme) {
case "ihasmail":
return { palette: "ihasmail", mode: "dark" };
case "light":
return { palette: "default", mode: "light" };
case "dark":
return { palette: "default", mode: "dark" };
case "system":
return { palette: "default", mode: "system" };
default:
// Unknown, absent, or written by something newer: the default is what a
// new account gets, and is never wrong in a way that hides mail.
return { palette: "ihasmail", mode: "dark" };
}
}
/**
* The old enum, written back alongside the new fields.
*
* A device still running an older build reads `theme` and ignores everything
* it does not know, so leaving it stale would show that device a theme nobody
* chose. It cannot express "Gruvbox", but it can express light or dark, which
* is the half that matters.
*/
export function legacyTheme(choice: ThemeChoice, prefersDark = false): "system" | "light" | "dark" | "ihasmail" {
// Only the dark half of ihasmail's own palette has an old name; its light
// half is new, and an older build has no word for it beyond "light".
if (choice.palette === "ihasmail" && effectiveMode(choice.mode, prefersDark) === "dark") return "ihasmail";
if (choice.palette === "default" && choice.mode === "system") return "system";
return effectiveMode(choice.mode, prefersDark);
}
/**
* Where the top-bar toggle goes.
*
* The palette never changes: only the mode flips. This used to be the awkward
* part -- leaving a dark-only palette for light meant changing palette too,
* and remembering which one to come back to -- and it stopped being awkward
* when every palette gained both halves.
*/
export function toggleTarget(current: ThemeChoice, prefersDark: boolean): ThemeChoice {
return { palette: current.palette, mode: effectiveMode(current.mode, prefersDark) === "dark" ? "light" : "dark" };
}