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:
@@ -3,7 +3,8 @@ import { Link, useLocation } from "wouter";
|
||||
import { BookOpen, Calendar, ChevronsUpDown, FolderOpen, Globe, HelpCircle, LogOut, Mail, Menu as MenuIcon, Moon, PenSquare, Plus, RefreshCw, Settings, Sun, Upload, Users, X } from "lucide-react";
|
||||
import { useSession } from "@/store/session";
|
||||
import { withBase } from "@/lib/basePath";
|
||||
import { toggleTarget, useEffectiveTheme, useSettings } from "@/store/settings";
|
||||
import { useEffectiveTheme, useSettings } from "@/store/settings";
|
||||
import { toggleTarget } from "@/lib/palette";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { draftFromMailto, useCompose } from "@/store/compose";
|
||||
import { Avatar, useIsMobile } from "@/ui/misc";
|
||||
@@ -268,18 +269,20 @@ function QuotaBar() {
|
||||
*/
|
||||
function ThemeToggle() {
|
||||
const effective = useEffectiveTheme();
|
||||
const lastDarkTheme = useSettings((s) => s.settings.lastDarkTheme);
|
||||
const settings = useSettings((s) => s.settings);
|
||||
const update = useSettings((s) => s.update);
|
||||
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";
|
||||
const prefersDark = Boolean(window.matchMedia?.("(prefers-color-scheme: dark)").matches);
|
||||
const next = toggleTarget({ palette: settings.palette, mode: settings.mode }, prefersDark);
|
||||
// Name where it is going, and by the palette when the palette is changing --
|
||||
// going back to ihasmail's own colours is not the same as "dark mode".
|
||||
// The palette never changes now, so the label is only ever the side.
|
||||
const label = next.mode === "light" ? t("light mode") : t("dark mode");
|
||||
return (
|
||||
<button
|
||||
className="icon-btn"
|
||||
aria-label={t("Switch to {theme}", { theme: label })}
|
||||
title={t("Switch to {theme}", { theme: label })}
|
||||
onClick={() => update({ theme: next })}
|
||||
onClick={() => update(next)}
|
||||
>
|
||||
{effective === "dark" ? <Sun size={21} /> : <Moon size={21} />}
|
||||
</button>
|
||||
|
||||
@@ -1,22 +1,44 @@
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { PALETTES, effectiveMode, type Mode, type PaletteId } from "@/lib/palette";
|
||||
import { Switch, useIsTouch } from "@/ui/misc";
|
||||
import { SWIPE_CHOICES, type SwipeAction } from "@/lib/swipe";
|
||||
import { TRANSLATION_ISSUE_URL, UI_LANGUAGES } from "@/lib/languages";
|
||||
import { t as translate, tNode } from "@/lib/i18n";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* A swatch for each palette, drawn from the colours that palette actually
|
||||
* paints, so a card looks like what picking it does. Kept as data rather than
|
||||
* inline ternaries so a sixth palette 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" },
|
||||
const PALETTE_PREVIEW: Record<PaletteId, { light: string; dark: string }> = {
|
||||
default: { light: "#f6f8fa", dark: "#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;
|
||||
// orange showing.
|
||||
ihasmail: { light: "linear-gradient(135deg,#f4f9f9 0%,#e7f1f2 55%,#379e98 55%,#379e98 78%,#c5813b 78%)", dark: "linear-gradient(135deg,#0d2430 0%,#12303e 55%,#46cac3 55%,#46cac3 78%,#f9a34b 78%)" },
|
||||
dracula: {
|
||||
light: "linear-gradient(135deg,#fffbeb 0%,#fffbeb 55%,#644ac9 55%,#644ac9 78%,#a3144d 78%)",
|
||||
dark: "linear-gradient(135deg,#282a36 0%,#2f3140 55%,#bd93f9 55%,#bd93f9 78%,#ff79c6 78%)",
|
||||
},
|
||||
gruvbox: {
|
||||
light: "linear-gradient(135deg,#fbf1c7 0%,#f2e5bc 55%,#076678 55%,#076678 78%,#af3a03 78%)",
|
||||
dark: "linear-gradient(135deg,#282828 0%,#32302f 55%,#83a598 55%,#83a598 78%,#fe8019 78%)",
|
||||
},
|
||||
"rose-pine": {
|
||||
light: "linear-gradient(135deg,#faf4ed 0%,#fffaf3 55%,#907aa9 55%,#907aa9 78%,#d7827e 78%)",
|
||||
dark: "linear-gradient(135deg,#191724 0%,#1f1d2e 55%,#c4a7e7 55%,#c4a7e7 78%,#ebbcba 78%)",
|
||||
},
|
||||
"tokyo-night": {
|
||||
light: "linear-gradient(135deg,#e6e7ed 0%,#d6d8df 55%,#2959aa 55%,#2959aa 78%,#8c4351 78%)",
|
||||
dark: "linear-gradient(135deg,#1a1b26 0%,#1f2130 55%,#7aa2f7 55%,#7aa2f7 78%,#bb9af7 78%)",
|
||||
},
|
||||
};
|
||||
|
||||
const MODES: Array<{ id: Mode; label: string }> = [
|
||||
{ id: "system", label: "Match system" },
|
||||
{ id: "light", label: "Light" },
|
||||
{ id: "dark", label: "Dark" },
|
||||
];
|
||||
|
||||
const ACCENTS = [
|
||||
{ id: "teal", color: "#0f766e" },
|
||||
@@ -30,6 +52,7 @@ const ACCENTS = [
|
||||
export function AppearanceSettings() {
|
||||
const s = useSettings((st) => st.settings);
|
||||
const update = useSettings((st) => st.update);
|
||||
const prefersDark = Boolean(window.matchMedia?.("(prefers-color-scheme: dark)").matches);
|
||||
const isTouch = useIsTouch();
|
||||
const chosen = UI_LANGUAGES.find((l) => l.tag === s.uiLanguage);
|
||||
const betaChosen = Boolean(chosen?.beta);
|
||||
@@ -38,16 +61,26 @@ export function AppearanceSettings() {
|
||||
<h1>{translate("Appearance")}</h1>
|
||||
<p className="lead">{translate("Make ihasmail yours.")}</p>
|
||||
<h2>{translate("Theme")}</h2>
|
||||
<div className="theme-grid">
|
||||
{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 }} />
|
||||
{translate(t.label)}
|
||||
<div className="mode-switch" role="group" aria-label={translate("Light or dark")}>
|
||||
{MODES.map((m) => (
|
||||
<button key={m.id} className={s.mode === m.id ? "active" : ""} onClick={() => update({ mode: m.id })}>
|
||||
{translate(m.label)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="theme-grid" style={{ marginTop: 12 }}>
|
||||
{PALETTES.map((p) => {
|
||||
const shown = effectiveMode(s.mode, prefersDark);
|
||||
return (
|
||||
<button key={p.id} className={`theme-card ${s.palette === p.id ? "active" : ""}`} onClick={() => update({ palette: p.id })}>
|
||||
<div className="preview" style={{ background: PALETTE_PREVIEW[p.id][shown] || PALETTE_PREVIEW[p.id].dark }} />
|
||||
<span className="notranslate" translate="no">{p.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="hint" style={{ marginTop: 10 }}>
|
||||
{tNode("{name} is the palette from {site}, 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.", { name: <strong className="notranslate" translate="no">ihasmail</strong>, site: <a href="https://ihasmail.org" target="_blank" rel="noopener noreferrer">ihasmail.org</a> })}
|
||||
{translate("Dracula, Gruvbox, Rosé Pine and Tokyo Night are the work of their own projects and are used under the MIT licence; the shades between their published colours are derived, and every one of them is checked for contrast. The accent colour below still applies over any of them.")}
|
||||
</p>
|
||||
<Switch
|
||||
checked={s.themeMessageBody}
|
||||
|
||||
Reference in New Issue
Block a user