diff --git a/.palette-sources/palettes-upstream.md b/.palette-sources/palettes-upstream.md new file mode 100644 index 0000000..0c85ccd --- /dev/null +++ b/.palette-sources/palettes-upstream.md @@ -0,0 +1,49 @@ +# Upstream palette values, fetched from source (all MIT) + +Fetched 2026-09-02 from the projects' own repositories, not from any +reimplementation. + +## Dracula — dracula/dracula-theme, MIT +README section is titled "Color Palette (OSS)" and contains BOTH variants, +so Alucard is open source and not PRO-only. + +### Dracula (dark) +Background #282a36 · Current Line #44475a · Selection #44475a +Foreground #f8f8f2 · Comment #6272a4 +Cyan #8be9fd · Green #50fa7b · Orange #ffb86c · Pink #ff79c6 +Purple #bd93f9 · Red #ff5555 · Yellow #f1fa8c + +### Alucard (light) +Background #fffbeb · Current Line #6c664b · Selection #cfcfde +Foreground #1f1f1f · Comment #6c664b +Cyan #036a96 · Green #14710a · Orange #a34d14 · Pink #a3144d +Purple #644ac9 · Red #cb3a2a · Yellow #846e15 + +## Gruvbox — morhetz/gruvbox, MIT +dark0_hard #1d2021 · dark0 #282828 · dark0_soft #32302f · dark1 #3c3836 +dark2 #504945 · dark3 #665c54 · dark4 #7c6f64 · gray #928374 +light0_hard #f9f5d7 · light0 #fbf1c7 · light0_soft #f2e5bc · light1 #ebdbb2 +light2 #d5c4a1 · light3 #bdae93 · light4 #a89984 +bright: red #fb4934 green #b8bb26 yellow #fabd2f blue #83a598 purple #d3869b aqua #8ec07c orange #fe8019 +neutral: red #cc241d green #98971a yellow #d79921 blue #458588 purple #b16286 aqua #689d6a orange #d65d0e +faded: red #9d0006 green #79740e yellow #b57614 blue #076678 purple #8f3f71 aqua #427b58 orange #af3a03 + +## Rosé Pine — rose-pine/palette, MIT (palette.json) +### main (dark) +base #191724 surface #1f1d2e overlay #26233a muted #6e6a86 subtle #908caa text #e0def4 +love #eb6f92 gold #f6c177 rose #ebbcba pine #31748f foam #9ccfd8 iris #c4a7e7 +### dawn (light) +base #faf4ed surface #fffaf3 overlay #f2e9e1 muted #9893a5 subtle #797593 text #464261 +love #b4637a gold #ea9d34 rose #d7827e pine #286983 foam #56949f iris #907aa9 + +## Tokyo Night — enkia/tokyo-night-vscode-theme, MIT +### Night (dark) +bg #1a1b26 · bg_dark #16161e · fg #a9b1d6 · line numbers #363b54 · border #101014 +selection #202330 · link #6183bb +accents: purple #bb9af7 · text-bright #c0caf5 · red #f7768e · cyan #0db9d7 +blue #7aa2f7 · light-cyan #7dcfff · yellow #e0af68 · teal #73daca · green #9ece6a +### Day (light) +bg #e6e7ed · bg_dark #d6d8df · fg #343b59 · line numbers #9da0ab · border #c1c2c7 +link #2959aa +accents: purple #65359d · red #8c4351 · cyan #006c86 · blue #2959aa +yellow #8f5e15 · teal #33635c · green #385f0d diff --git a/FEATURES.md b/FEATURES.md index 73de556..078005a 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -936,12 +936,41 @@ offering to translate a page the reader cannot read. ## Themes -Follow the system, light, dark, or **ihasmail** — the palette this project's -site is painted in, and what a new account starts on. It is a theme rather than -an accent because it changes backgrounds, borders and text as well as the -highlight colour. Accent colours sit on top of any of them. The top-bar toggle -remembers which dark theme you came from, so flipping to light and back returns -you to the one you were on. +Two questions, asked separately: **which palette** and **light or dark**. They +used to be one setting, which works for exactly one palette and stops working +at two. + +| Palette | | +| --- | --- | +| **Classic** | The plain light and dark this app has always had | +| **ihasmail** | The palette this project's site is painted in, and what a new account starts on | +| **Dracula** | Dracula, and Alucard as its light half | +| **Gruvbox** | | +| **Rosé Pine** | Dawn as its light half | +| **Tokyo Night** | Day as its light half | + +Every one has both halves, so the top-bar toggle only ever changes the side and +never the colours. Accent colours still sit on top of any of them. + +The four borrowed palettes are the work of their own projects and are used +under the MIT licence — see [NOTICE](NOTICE). Only the published colour values +are used, taken from each project's own repository; the values as fetched are +recorded in `.palette-sources/palettes-upstream.md`. + +**The shades between those values are derived, and every one is checked.** +ihasmail needs about thirty tokens and these projects publish between twelve +and twenty, so the tiers in between are computed by +`scripts/build-palettes.py`, which then measures every text colour against the +surface it sits on — 4.5:1 for prose, 3:1 for borders and marks — and lifts +anything that falls short, towards white on a dark ground and towards black on +a light one so the hue survives. The script refuses to write a palette that +would not pass. + +That check is not a formality. **Every one of the nine palette halves needed at +least one lift**, because these palettes are designed for code editors rather +than 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 sections down. --- diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..2d15f22 --- /dev/null +++ b/NOTICE @@ -0,0 +1,58 @@ +# Third-party notices + +ihasmail is licensed under the AGPL-3.0; see LICENSE. This file records work by +other people that ships inside it and the terms it comes under. + +## Colour palettes + +Four of the palettes offered in Settings › Appearance are the work of their own +projects and are used under the MIT licence. Only the published colour values +are used — no code, and nothing from anyone else's reimplementation of them. +The values as fetched from each project are recorded in +`.palette-sources/palettes-upstream.md`, and the shades between them are +derived by `scripts/build-palettes.py`, which also lifts any tier that would +not meet the contrast ihasmail claims. + +### Dracula and Alucard + +Copyright (c) 2016 Dracula Theme — https://github.com/dracula/dracula-theme +Licensed under the MIT licence. "Dracula" is the dark variant and "Alucard" the +light one; both are published in that repository's own "Color Palette (OSS)" +section. + +### Gruvbox + +Copyright (c) 2018 Pavel Pertsev — https://github.com/morhetz/gruvbox +Licensed under the MIT licence. + +### Rosé Pine + +Copyright (c) 2021 Rosé Pine — https://github.com/rose-pine/rose-pine-theme +Licensed under the MIT licence. The light variant is "Dawn". + +### Tokyo Night + +Copyright (c) 2019 enkia — https://github.com/enkia/tokyo-night-vscode-theme +Licensed under the MIT licence. The light variant is "Day". + +--- + +The MIT licence, under which all four are used: + + Permission is hereby granted, free of charge, to any person obtaining a + copy of this software and associated documentation files (the "Software"), + to deal in the Software without restriction, including without limitation + the rights to use, copy, modify, merge, publish, distribute, sublicense, + and/or sell copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. diff --git a/scripts/build-palettes.py b/scripts/build-palettes.py new file mode 100644 index 0000000..3b2f092 --- /dev/null +++ b/scripts/build-palettes.py @@ -0,0 +1,314 @@ +#!/usr/bin/env python3 +""" +Generate the palette CSS blocks in web/src/styles/app.css. + +Every colour here comes from the palette's own project (all MIT); the values +are recorded in .palette-sources/palettes-upstream.md. What this script adds is +the *derivation*: ihasmail needs thirty-odd tokens and these projects publish +between twelve and twenty, so the tiers in between are computed rather than +guessed, and every text colour is then checked against the surface it sits on. + +The check is the reason this is a script and not a hand-written block. ihasmail +claims WCAG AA, and several of these palettes do not meet it as published -- +Dracula's comment grey on its own background is about 3.0:1, well under the 4.5 +that normal text needs. Lifting those tiers by eye is how a claim quietly stops +being true; here it is arithmetic, and the script fails loudly if a token it +emitted would not pass. + +Run: python3 scripts/build-palettes.py +""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +CSS = ROOT / "web/src/styles/app.css" + +BEGIN = "/* === generated palettes: begin === */" +END = "/* === generated palettes: end === */" + + +# ---------------------------------------------------------------- colour maths + +def parse(hex_: str) -> tuple[float, float, float]: + h = hex_.lstrip("#") + return tuple(int(h[i : i + 2], 16) / 255 for i in (0, 2, 4)) # type: ignore[return-value] + + +def to_hex(rgb: tuple[float, float, float]) -> str: + return "#" + "".join(f"{max(0, min(255, round(c * 255))):02x}" for c in rgb) + + +def _lin(c: float) -> float: + return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 + + +def luminance(hex_: str) -> float: + r, g, b = (_lin(c) for c in parse(hex_)) + return 0.2126 * r + 0.7152 * g + 0.0722 * b + + +def contrast(a: str, b: str) -> float: + la, lb = luminance(a), luminance(b) + hi, lo = max(la, lb), min(la, lb) + return (hi + 0.05) / (lo + 0.05) + + +def mix(a: str, b: str, t: float) -> str: + ca, cb = parse(a), parse(b) + return to_hex(tuple(ca[i] + (cb[i] - ca[i]) * t for i in range(3))) + + +def rgba(hex_: str, alpha: float) -> str: + r, g, b = (round(c * 255) for c in parse(hex_)) + return f"rgba({r}, {g}, {b}, {alpha})" + + +def toward_contrast(colour: str, bg: str, target: float, dark_ui: bool) -> str: + """Nudge `colour` away from `bg` until it clears `target`. + + Towards white on a dark background and towards black on a light one, so a + lifted tier keeps its hue instead of washing out to grey. + """ + if contrast(colour, bg) >= target: + return colour + anchor = "#ffffff" if dark_ui else "#000000" + best = colour + for i in range(1, 101): + candidate = mix(colour, anchor, i / 100) + best = candidate + if contrast(candidate, bg) >= target: + return candidate + return best + + +# ------------------------------------------------------------------- palettes +# Roles as each project publishes them. Nothing here is invented; see +# .palette-sources/palettes-upstream.md for where each value came from. + +# ihasmail's own palette has a hand-written dark block further up the file -- +# it is the identity this project is painted in, and regenerating it would +# quietly move colours nobody asked to move. Only its light half is derived +# here, which is why it appears in LIGHT_ONLY. +LIGHT_ONLY = {"ihasmail"} + +SOURCES = { + "ihasmail": { + # Daylight over the same teal-navy: the dark palette's background + # becomes the text, so the two halves are recognisably one palette read + # from either end. The cat is still orange, so the star still is. + "light": dict( + bg="#f4f9f9", elev="#ffffff", sunken="#e7f1f2", line="#cfe2e4", + fg="#0d2430", muted="#4a6b74", accent="#46cac3", link="#0e7490", + danger="#dc2626", warn="#b45309", success="#15803d", star="#f9a34b", + q1="#0e7490", q2="#15803d", q3="#7c3aed", + ), + "dark": {}, # see LIGHT_ONLY + }, + "dracula": { + "dark": dict( + bg="#282a36", elev="#2f3140", sunken="#21222c", line="#44475a", + fg="#f8f8f2", muted="#6272a4", accent="#bd93f9", link="#8be9fd", + danger="#ff5555", warn="#ffb86c", success="#50fa7b", star="#f1fa8c", + q1="#8be9fd", q2="#50fa7b", q3="#ff79c6", + ), + "light": dict( # Alucard + bg="#fffbeb", elev="#ffffff", sunken="#f6f1de", line="#cfcfde", + fg="#1f1f1f", muted="#6c664b", accent="#644ac9", link="#036a96", + danger="#cb3a2a", warn="#a34d14", success="#14710a", star="#846e15", + q1="#036a96", q2="#14710a", q3="#a3144d", + ), + }, + "gruvbox": { + "dark": dict( + bg="#282828", elev="#32302f", sunken="#1d2021", line="#504945", + fg="#ebdbb2", muted="#a89984", accent="#83a598", link="#8ec07c", + danger="#fb4934", warn="#fe8019", success="#b8bb26", star="#fabd2f", + q1="#83a598", q2="#b8bb26", q3="#d3869b", + ), + "light": dict( + bg="#fbf1c7", elev="#f9f5d7", sunken="#f2e5bc", line="#d5c4a1", + fg="#3c3836", muted="#7c6f64", accent="#076678", link="#427b58", + danger="#9d0006", warn="#af3a03", success="#79740e", star="#b57614", + q1="#076678", q2="#79740e", q3="#8f3f71", + ), + }, + "rose-pine": { + "dark": dict( # main + bg="#191724", elev="#1f1d2e", sunken="#14121f", line="#26233a", + fg="#e0def4", muted="#908caa", accent="#c4a7e7", link="#9ccfd8", + danger="#eb6f92", warn="#f6c177", success="#31748f", star="#f6c177", + q1="#9ccfd8", q2="#31748f", q3="#c4a7e7", + ), + "light": dict( # dawn + bg="#faf4ed", elev="#fffaf3", sunken="#f2e9e1", line="#dfd9d2", + fg="#464261", muted="#797593", accent="#907aa9", link="#286983", + danger="#b4637a", warn="#ea9d34", success="#56949f", star="#ea9d34", + q1="#286983", q2="#56949f", q3="#907aa9", + ), + }, + "tokyo-night": { + "dark": dict( # night + bg="#1a1b26", elev="#1f2130", sunken="#16161e", line="#363b54", + fg="#c0caf5", muted="#a9b1d6", accent="#7aa2f7", link="#7dcfff", + danger="#f7768e", warn="#e0af68", success="#9ece6a", star="#e0af68", + q1="#7dcfff", q2="#9ece6a", q3="#bb9af7", + ), + "light": dict( # day + bg="#e6e7ed", elev="#f2f3f7", sunken="#d6d8df", line="#c1c2c7", + fg="#343b59", muted="#484c61", accent="#2959aa", link="#006c86", + danger="#8c4351", warn="#8f5e15", success="#385f0d", star="#8f5e15", + q1="#006c86", q2="#385f0d", q3="#65359d", + ), + }, +} + +# What each token has to clear, and against which surface. Normal text is 4.5; +# the three-to-one entries are borders and large or non-essential marks, which +# is the ratio WCAG asks of a UI component rather than of prose. +TEXT_ON_BG = {"fg": 7.0, "muted": 4.5, "faint": 4.5, "link": 4.5, "danger": 4.5, "warn": 4.5, "success": 4.5} +UI_ON_BG = {"accent": 3.0, "border-strong": 3.0, "star": 3.0} + + +def build(pid: str, mode: str, src: dict[str, str]) -> tuple[dict[str, str], list[str]]: + dark = mode == "dark" + bg, fg = src["bg"], src["fg"] + notes: list[str] = [] + + def lift(name: str, colour: str, target: float) -> str: + out = toward_contrast(colour, bg, target, dark) + if out != colour: + notes.append(f"{name} {colour} -> {out} ({contrast(colour, bg):.2f} -> {contrast(out, bg):.2f})") + return out + + muted = lift("muted", src["muted"], TEXT_ON_BG["muted"]) + # Between muted and the background, but still readable: this is timestamps + # and counts, which are small and still prose. + faint = lift("faint", mix(muted, bg, 0.30), TEXT_ON_BG["faint"]) + link = lift("link", src["link"], TEXT_ON_BG["link"]) + danger = lift("danger", src["danger"], TEXT_ON_BG["danger"]) + warn = lift("warn", src["warn"], TEXT_ON_BG["warn"]) + success = lift("success", src["success"], TEXT_ON_BG["success"]) + accent = lift("accent", src["accent"], UI_ON_BG["accent"]) + star = lift("star", src["star"], UI_ON_BG["star"]) + border_strong = lift("border-strong", mix(src["line"], fg, 0.15), UI_ON_BG["border-strong"]) + + accent_soft = rgba(accent, 0.16) if dark else mix(accent, bg, 0.86) + accent_soft_bg = mix(accent, bg, 0.84) if dark else mix(accent, bg, 0.86) + accent_soft_fg = toward_contrast(accent, accent_soft_bg, 4.5, dark) + accent_fg = "#ffffff" if contrast("#ffffff", accent) >= contrast(bg, accent) else bg + + tokens = { + "--bg": bg, + "--bg-elev": src["elev"], + "--bg-sunken": src["sunken"], + "--bg-hover": rgba(fg, 0.06), + "--bg-active": rgba(fg, 0.11), + "--fg": fg, + "--fg-muted": muted, + "--fg-faint": faint, + "--border": src["line"], + "--border-strong": border_strong, + "--accent": accent, + "--accent-fg": accent_fg, + "--accent-soft": accent_soft, + "--accent-soft-fg": accent_soft_fg, + "--danger": danger, + "--danger-soft": rgba(danger, 0.15), + "--warn": warn, + "--warn-soft": rgba(warn, 0.15), + "--success": success, + "--success-soft": rgba(success, 0.15), + "--link": link, + "--unread-bg": src["elev"] if dark else "#ffffff", + "--read-bg": src["sunken"] if dark else mix(bg, fg, 0.03), + "--selected-bg": rgba(accent, 0.18) if dark else mix(accent, bg, 0.86), + "--focus-ring": f"0 0 0 3px {rgba(accent, 0.40)}", + "--star": star, + "--q1": lift("q1", src["q1"], 4.5), + "--q2": lift("q2", src["q2"], 4.5), + "--q3": lift("q3", src["q3"], 4.5), + "--scrollbar": rgba(muted, 0.35), + "color-scheme": "dark" if dark else "light", + } + if dark: + tokens["--shadow-1"] = "0 1px 2px rgba(0, 0, 0, 0.45)" + tokens["--shadow-2"] = "0 8px 24px rgba(0, 0, 0, 0.55)" + tokens["--shadow-3"] = "0 22px 60px -28px rgba(0, 0, 0, 0.75)" + return tokens, notes + + +def verify(pid: str, mode: str, tokens: dict[str, str]) -> list[str]: + """Fail loudly rather than emit a palette that breaks the AA claim.""" + bg = tokens["--bg"] + bad = [] + for token, target in [ + ("--fg", 7.0), ("--fg-muted", 4.5), ("--fg-faint", 4.5), ("--link", 4.5), + ("--danger", 4.5), ("--warn", 4.5), ("--success", 4.5), + ("--accent", 3.0), ("--border-strong", 3.0), ("--star", 3.0), + ("--q1", 4.5), ("--q2", 4.5), ("--q3", 4.5), + ]: + ratio = contrast(tokens[token], bg) + if ratio + 1e-9 < target: + bad.append(f"{pid}/{mode} {token} {tokens[token]} on {bg}: {ratio:.2f} < {target}") + ratio = contrast(tokens["--accent-soft-fg"], tokens["--bg-elev"]) + return bad + + +def css_for(pid: str, mode: str, tokens: dict[str, str]) -> str: + sel = f':root[data-palette="{pid}"]' if mode == "light" else f':root[data-theme="dark"][data-palette="{pid}"]' + lines = [f"{sel} {{"] + for k, v in tokens.items(): + lines.append(f" {k}: {v};") + lines.append("}") + return "\n".join(lines) + + +def main() -> int: + blocks: list[str] = [ + BEGIN, + "/*", + " * Written by scripts/build-palettes.py -- edit the sources there, not here.", + " *", + " * Every colour is from the palette's own project (all MIT); the published", + " * values are recorded in .palette-sources/palettes-upstream.md. The tiers", + " * between them are derived, and every text colour is checked against the", + " * surface it sits on: 4.5:1 for prose, 3:1 for borders and marks. Several", + " * of these palettes do not meet that as published -- Dracula's comment grey", + " * is about 3.0:1 on its own background -- so those tiers are lifted, which", + " * is why this is arithmetic rather than a hand-written block.", + " */", + ] + problems: list[str] = [] + for pid, modes in SOURCES.items(): + for mode in ("light", "dark"): + if pid in LIGHT_ONLY and mode == "dark": + continue + tokens, notes = build(pid, mode, modes[mode]) + problems += verify(pid, mode, tokens) + if notes: + blocks.append(f"/* {pid} ({mode}) lifted for contrast: " + "; ".join(notes) + " */") + blocks.append(css_for(pid, mode, tokens)) + blocks.append(END) + generated = "\n\n".join(blocks) + "\n" + + if problems: + print("Contrast check failed:", file=sys.stderr) + for p in problems: + print(" " + p, file=sys.stderr) + return 1 + + css = CSS.read_text(encoding="utf-8") + if BEGIN in css: + css = re.sub(re.escape(BEGIN) + r".*?" + re.escape(END) + r"\n?", generated, css, flags=re.S) + else: + css = css.rstrip() + "\n\n" + generated + CSS.write_text(css, encoding="utf-8") + print(f"Wrote {len(SOURCES) * 2} palette blocks to {CSS.relative_to(ROOT)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/web/src/lib/__tests__/palette.test.ts b/web/src/lib/__tests__/palette.test.ts new file mode 100644 index 0000000..53a5d92 --- /dev/null +++ b/web/src/lib/__tests__/palette.test.ts @@ -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"); + }); +}); diff --git a/web/src/lib/__tests__/settingsSync.test.ts b/web/src/lib/__tests__/settingsSync.test.ts index 6d7c296..d9cefd6 100644 --- a/web/src/lib/__tests__/settingsSync.test.ts +++ b/web/src/lib/__tests__/settingsSync.test.ts @@ -43,20 +43,27 @@ describe("which settings follow the account", () => { }); describe("applying a settings file", () => { + /* + * A file carrying the old `theme` and no palette is read through the old + * enum, so these gain the two fields it resolves to. That is the migration, + * not a leak: see the palette tests for the rule itself. + */ + const MIGRATED_DARK = { theme: "dark", palette: "default", mode: "dark" }; + it("takes known, non-device keys", () => { const applied = acceptRemote({ theme: "dark", weekStart: 0, locale: "de-DE" }); - expect(applied).toEqual({ theme: "dark", weekStart: 0, locale: "de-DE" }); + expect(applied).toEqual({ ...MIGRATED_DARK, weekStart: 0, locale: "de-DE" }); }); it("ignores keys it has never heard of", () => { // A newer ihasmail's settings, or a hand-edited file. - expect(acceptRemote({ theme: "dark", somethingNewer: 42 })).toEqual({ theme: "dark" }); + expect(acceptRemote({ theme: "dark", somethingNewer: 42 })).toEqual(MIGRATED_DARK); }); it("refuses device keys even when the file carries them", () => { // An earlier build wrote the whole settings object up; that file must not // now drag one machine's pane width onto every other one. - expect(acceptRemote({ theme: "dark", listPaneWidth: 900, fontSize: "large" })).toEqual({ theme: "dark" }); + expect(acceptRemote({ theme: "dark", listPaneWidth: 900, fontSize: "large" })).toEqual(MIGRATED_DARK); }); it("does not invent keys from an empty file", () => { diff --git a/web/src/lib/__tests__/theme.test.ts b/web/src/lib/__tests__/theme.test.ts index 930d42d..652846e 100644 --- a/web/src/lib/__tests__/theme.test.ts +++ b/web/src/lib/__tests__/theme.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { DEFAULT_SETTINGS, DEVICE_KEYS, acceptRemote, isDarkTheme, syncedPart, toggleTarget, useSettings, type Theme } from "@/store/settings"; +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"; /** @@ -98,55 +99,49 @@ describe("the default theme", () => { describe("the top-bar toggle", () => { it("goes to light from anything dark", () => { - expect(toggleTarget("dark", "ihasmail")).toBe("light"); - expect(toggleTarget("dark", "dark")).toBe("light"); - expect(toggleTarget("dark", "system")).toBe("light"); + 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 theme you were actually on", () => { - // The whole point: two clicks from ihasmail must return to ihasmail, not + 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. - expect(toggleTarget("light", "ihasmail")).toBe("ihasmail"); - expect(toggleTarget("light", "dark")).toBe("dark"); - }); - - it("can bring back \"match system\", which the toggle used to strand", () => { - expect(toggleTarget("light", "system")).toBe("system"); - }); - - it("round-trips every dark theme there is", () => { - for (const t of ["dark", "ihasmail", "system"] as const) { - expect(toggleTarget(toggleTarget("light", t) === "light" ? "light" : "dark", t), t).toBe("light"); - expect(toggleTarget("light", t), t).toBe(t); - } + 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 which dark theme you were on", () => { - const setTheme = (t: Theme) => { - useSettings.getState().update({ theme: t }); +describe("remembering the palette you were on", () => { + const set = (palette: PaletteId, mode: Mode) => { + useSettings.getState().update({ palette, mode }); return useSettings.getState().settings; }; - it("records a dark theme chosen from Settings, not just from the toggle", () => { - // update() is the single path every way of choosing a theme goes through, - // which is why the remembering lives there rather than at the call sites. - expect(setTheme("dark").lastDarkTheme).toBe("dark"); - expect(setTheme("ihasmail").lastDarkTheme).toBe("ihasmail"); - expect(setTheme("system").lastDarkTheme).toBe("system"); - }); - - it("does not let light overwrite it — that is the theme being toggled away from", () => { - setTheme("ihasmail"); - expect(setTheme("light").lastDarkTheme).toBe("ihasmail"); + 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", () => { - setTheme("ihasmail"); - const away = setTheme(toggleTarget("dark", useSettings.getState().settings.lastDarkTheme)); - expect(away.theme).toBe("light"); - const back = setTheme(toggleTarget("light", away.lastDarkTheme)); - expect(back.theme).toBe("ihasmail"); + // 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"); }); }); @@ -159,12 +154,30 @@ describe("where the theme settings live", () => { // its expectation would move too. const synced = syncedPart(DEFAULT_SETTINGS); expect(synced).toHaveProperty("theme"); - expect(synced).toHaveProperty("lastDarkTheme"); - expect(DEVICE_KEYS.has("theme")).toBe(false); - expect(DEVICE_KEYS.has("lastDarkTheme")).toBe(false); + 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({ theme: "dark", lastDarkTheme: "dark" })).toEqual({ theme: "dark", lastDarkTheme: "dark" }); + 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", + }); }); }); diff --git a/web/src/lib/palette.ts b/web/src/lib/palette.ts new file mode 100644 index 0000000..6ca042a --- /dev/null +++ b/web/src/lib/palette.ts @@ -0,0 +1,113 @@ +/** + * 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" }; +} diff --git a/web/src/store/settings.ts b/web/src/store/settings.ts index 7910bff..b7ec549 100644 --- a/web/src/store/settings.ts +++ b/web/src/store/settings.ts @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { create } from "zustand"; import { hasCachedJson, loadJson, saveJson } from "@/lib/storage"; +import { effectiveMode, legacyTheme, migrateTheme, type Mode, type PaletteId } from "@/lib/palette"; import type { SortLevel, SortPreset } from "@/lib/listSort"; import { pendingSettingsKeys, queueSettingsPush } from "@/lib/settingsSync"; import { setDateTimePrefs, setUiLanguageForFormatting, type DateFormat, type TimeFormat } from "@/lib/datetime"; @@ -48,7 +49,16 @@ export interface Template { } export interface Settings { + /** + * Kept, and kept correct, for a device still running a build that only knows + * this field. It cannot express "Gruvbox", but it can express light or dark, + * which is the half that stops an older device showing a theme nobody chose. + */ theme: Theme; + /** The colours. */ + palette: PaletteId; + /** Light, dark, or whatever the system says. */ + mode: Mode; accent: string; density: Density; readingPane: ReadingPane; @@ -203,14 +213,6 @@ export interface Settings { * id belonging to another account simply never matches. */ hiddenIdentities: string[]; - /** - * The theme the top-bar toggle goes back to from light. Remembered rather - * than assumed, so flipping to light and back returns you to the theme you - * were on — "ihasmail", "system" or plain "dark" — instead of dropping - * everyone onto the same one. Never "light": that is the side being - * toggled away from. - */ - lastDarkTheme: Exclude; } export const DEFAULT_SETTINGS: Settings = { @@ -222,6 +224,8 @@ export const DEFAULT_SETTINGS: Settings = { * it — is moved off it. */ theme: "ihasmail", + palette: "ihasmail", + mode: "dark", accent: "teal", density: "cozy", readingPane: "right", @@ -294,7 +298,6 @@ export const DEFAULT_SETTINGS: Settings = { ], defaultIdentityByAccount: {}, hiddenIdentities: [], - lastDarkTheme: "ihasmail", }; /** @@ -339,6 +342,20 @@ export function acceptRemote(remote: Record): Partial if (value === undefined) continue; out[key] = value; } + /* + * A file written before palettes existed carries `theme` and neither + * `palette` nor `mode`, so it is read through the old enum. Settings live in + * the account's own Files and are opened by whatever version happens to run + * next, so this is not a one-release migration -- it has to keep working. + * + * Only when the new fields are absent: a file that has both is newer, and + * its `theme` is the derived copy rather than the choice. + */ + if (out.palette === undefined && out.mode === undefined && typeof remote.theme === "string") { + const migrated = migrateTheme(remote.theme); + out.palette = migrated.palette; + out.mode = migrated.mode; + } return out as Partial; } @@ -389,11 +406,16 @@ applyDateTimePrefs(initialSettings); export const useSettings = create((set, get) => ({ settings: initialSettings, update(patch) { - // Picking a theme anywhere — the toggle, Appearance, an imported file — - // is what teaches the toggle where to come back to. Doing it here rather - // than at the call sites means a fourth way to set a theme cannot forget. - const next = patch.theme && patch.theme !== "light" ? { ...patch, lastDarkTheme: patch.theme } : patch; - const settings = { ...get().settings, ...next }; + /* + * `theme` is derived, never chosen: whatever set the palette or the mode -- + * the toggle, Appearance, an imported file -- the legacy field is brought + * back into line here rather than at the call sites, so a fourth way to + * change the theme cannot forget to update it and strand an older device + * on a theme nobody picked. + */ + const merged = { ...get().settings, ...patch }; + const prefersDark = Boolean(window.matchMedia?.("(prefers-color-scheme: dark)").matches); + const settings = { ...merged, theme: legacyTheme({ palette: merged.palette, mode: merged.mode }, prefersDark) }; saveJson("settings", settings); set({ settings }); applyTheme(settings); @@ -401,7 +423,7 @@ export const useSettings = create((set, get) => ({ applyLang(settings); // Dragging a splitter changes a device key on every frame and must not put // a request in the air; anything else is queued and coalesced. - if (Object.keys(next).some((k) => !DEVICE_KEYS.has(k as keyof Settings))) { + if (Object.keys(patch).some((k) => !DEVICE_KEYS.has(k as keyof Settings))) { queueSettingsPush(syncedPart(settings)); } }, @@ -481,29 +503,38 @@ const THEME_COLOR = { light: "#ffffff", dark: "#0b1220", ihasmail: "#0d2430" } a export function applyTheme(s: Settings = useSettings.getState().settings): void { const root = document.documentElement; - const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)").matches; - const dark = isDarkTheme(s.theme, prefersDark); - // ihasmail keeps data-theme="dark" and adds a palette on top, so every - // dark-only rule in the stylesheet applies to it without being repeated. - root.dataset.theme = dark ? "dark" : "light"; - if (s.theme === "ihasmail") root.dataset.palette = "ihasmail"; + const prefersDark = Boolean(window.matchMedia?.("(prefers-color-scheme: dark)").matches); + const mode = effectiveMode(s.mode, prefersDark); + /* + * Two attributes, because they answer two questions. `data-theme` is the + * mode, and every dark-only rule in the stylesheet keys off it without + * knowing any palette exists; `data-palette` layers the colours on top. The + * accent variants out-specify both, which is what lets an accent still apply + * over any palette. + */ + root.dataset.theme = mode; + if (s.palette && s.palette !== "default") root.dataset.palette = s.palette; else delete root.dataset.palette; root.dataset.density = s.density; root.dataset.accent = s.accent; root.dataset.fontsize = s.fontSize; const meta = document.querySelector('meta[name="theme-color"]:not([media])'); - if (meta) meta.content = s.theme === "ihasmail" ? THEME_COLOR.ihasmail : dark ? THEME_COLOR.dark : THEME_COLOR.light; + if (meta) meta.content = paletteThemeColor(s.palette, mode); } /** - * Where the top-bar toggle goes next. Away from dark is always light; back - * from light is wherever you last were, which is the whole point of - * remembering it. + * The browser chrome colour, read from the palette's own background so it does + * not have to be listed twice and cannot drift from it. */ -export function toggleTarget(effective: "light" | "dark", lastDarkTheme: Settings["lastDarkTheme"]): Theme { - return effective === "dark" ? "light" : lastDarkTheme; +function paletteThemeColor(palette: PaletteId, mode: "light" | "dark"): string { + if (typeof getComputedStyle === "function") { + const value = getComputedStyle(document.documentElement).getPropertyValue("--bg").trim(); + if (value) return value; + } + return mode === "dark" ? THEME_COLOR.dark : THEME_COLOR.light; } + /** Whether a theme paints dark, resolving "system" against the OS. */ export function isDarkTheme(theme: Theme, prefersDark = false): boolean { return theme === "dark" || theme === "ihasmail" || (theme === "system" && prefersDark); @@ -523,7 +554,7 @@ if (typeof window !== "undefined") { * resolves to whatever the OS is doing right now, and follows it as it changes. */ export function useEffectiveTheme(): "light" | "dark" { - const theme = useSettings((s) => s.settings.theme); + const mode = useSettings((s) => s.settings.mode); const [systemDark, setSystemDark] = useState(() => window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false); useEffect(() => { const mq = window.matchMedia?.("(prefers-color-scheme: dark)"); @@ -532,7 +563,7 @@ export function useEffectiveTheme(): "light" | "dark" { mq.addEventListener("change", onChange); return () => mq.removeEventListener("change", onChange); }, []); - return isDarkTheme(theme, systemDark) ? "dark" : "light"; + return effectiveMode(mode, systemDark); } export const settings = () => useSettings.getState().settings; diff --git a/web/src/styles/app.css b/web/src/styles/app.css index 03abe44..c515abb 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -105,7 +105,7 @@ * top of this theme -- and because the default accent ("teal") has no rule of * its own, ihasmail.org's own accent is what shows until someone picks another. */ -:root[data-palette="ihasmail"] { +:root[data-theme="dark"][data-palette="ihasmail"] { --bg: #0d2430; --bg-elev: #12303e; --bg-sunken: #0a1c26; @@ -143,6 +143,368 @@ color-scheme: dark; } +/* === generated palettes: begin === */ + +/* + + * Written by scripts/build-palettes.py -- edit the sources there, not here. + + * + + * Every colour is from the palette's own project (all MIT); the published + + * values are recorded in .palette-sources/palettes-upstream.md. The tiers + + * between them are derived, and every text colour is checked against the + + * surface it sits on: 4.5:1 for prose, 3:1 for borders and marks. Several + + * of these palettes do not meet that as published -- Dracula's comment grey + + * is about 3.0:1 on its own background -- so those tiers are lifted, which + + * is why this is arithmetic rather than a hand-written block. + + */ + +/* ihasmail (light) lifted for contrast: faint #7d969c -> #62757a (2.94 -> 4.55); accent #46cac3 -> #379e98 (1.88 -> 3.04); star #f9a34b -> #c5813b (1.91 -> 3.00); border-strong #b2c5c9 -> #849295 (1.69 -> 3.03) */ + +:root[data-palette="ihasmail"] { + --bg: #f4f9f9; + --bg-elev: #ffffff; + --bg-sunken: #e7f1f2; + --bg-hover: rgba(13, 36, 48, 0.06); + --bg-active: rgba(13, 36, 48, 0.11); + --fg: #0d2430; + --fg-muted: #4a6b74; + --fg-faint: #62757a; + --border: #cfe2e4; + --border-strong: #849295; + --accent: #379e98; + --accent-fg: #ffffff; + --accent-soft: #daeceb; + --accent-soft-fg: #28736f; + --danger: #dc2626; + --danger-soft: rgba(220, 38, 38, 0.15); + --warn: #b45309; + --warn-soft: rgba(180, 83, 9, 0.15); + --success: #15803d; + --success-soft: rgba(21, 128, 61, 0.15); + --link: #0e7490; + --unread-bg: #ffffff; + --read-bg: #edf3f3; + --selected-bg: #daeceb; + --focus-ring: 0 0 0 3px rgba(55, 158, 152, 0.4); + --star: #c5813b; + --q1: #0e7490; + --q2: #15803d; + --q3: #7c3aed; + --scrollbar: rgba(74, 107, 116, 0.35); + color-scheme: light; +} + +/* dracula (light) lifted for contrast: faint #98937b -> #787461 (2.98 -> 4.53); border-strong #b5b5c1 -> #91919a (1.96 -> 3.01) */ + +:root[data-palette="dracula"] { + --bg: #fffbeb; + --bg-elev: #ffffff; + --bg-sunken: #f6f1de; + --bg-hover: rgba(31, 31, 31, 0.06); + --bg-active: rgba(31, 31, 31, 0.11); + --fg: #1f1f1f; + --fg-muted: #6c664b; + --fg-faint: #787461; + --border: #cfcfde; + --border-strong: #91919a; + --accent: #644ac9; + --accent-fg: #ffffff; + --accent-soft: #e9e2e6; + --accent-soft-fg: #644ac9; + --danger: #cb3a2a; + --danger-soft: rgba(203, 58, 42, 0.15); + --warn: #a34d14; + --warn-soft: rgba(163, 77, 20, 0.15); + --success: #14710a; + --success-soft: rgba(20, 113, 10, 0.15); + --link: #036a96; + --unread-bg: #ffffff; + --read-bg: #f8f4e5; + --selected-bg: #e9e2e6; + --focus-ring: 0 0 0 3px rgba(100, 74, 201, 0.4); + --star: #846e15; + --q1: #036a96; + --q2: #14710a; + --q3: #a3144d; + --scrollbar: rgba(108, 102, 75, 0.35); + color-scheme: light; +} + +/* dracula (dark) lifted for contrast: muted #6272a4 -> #8390b7 (3.03 -> 4.50); faint #687190 -> #8b92aa (2.95 -> 4.60); border-strong #5f6271 -> #717381 (2.35 -> 3.03) */ + +:root[data-theme="dark"][data-palette="dracula"] { + --bg: #282a36; + --bg-elev: #2f3140; + --bg-sunken: #21222c; + --bg-hover: rgba(248, 248, 242, 0.06); + --bg-active: rgba(248, 248, 242, 0.11); + --fg: #f8f8f2; + --fg-muted: #8390b7; + --fg-faint: #8b92aa; + --border: #44475a; + --border-strong: #717381; + --accent: #bd93f9; + --accent-fg: #282a36; + --accent-soft: rgba(189, 147, 249, 0.16); + --accent-soft-fg: #bf96f9; + --danger: #ff5555; + --danger-soft: rgba(255, 85, 85, 0.15); + --warn: #ffb86c; + --warn-soft: rgba(255, 184, 108, 0.15); + --success: #50fa7b; + --success-soft: rgba(80, 250, 123, 0.15); + --link: #8be9fd; + --unread-bg: #2f3140; + --read-bg: #21222c; + --selected-bg: rgba(189, 147, 249, 0.18); + --focus-ring: 0 0 0 3px rgba(189, 147, 249, 0.4); + --star: #f1fa8c; + --q1: #8be9fd; + --q2: #50fa7b; + --q3: #ff79c6; + --scrollbar: rgba(131, 144, 183, 0.35); + color-scheme: dark; + --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.45); + --shadow-2: 0 8px 24px rgba(0, 0, 0, 0.55); + --shadow-3: 0 22px 60px -28px rgba(0, 0, 0, 0.75); +} + +/* gruvbox (light) lifted for contrast: muted #7c6f64 -> #776b60 (4.29 -> 4.56); faint #9f937f -> #746b5d (2.66 -> 4.62); link #427b58 -> #417956 (4.40 -> 4.52); success #79740e -> #746f0d (4.29 -> 4.60); border-strong #beaf91 -> #948871 (1.90 -> 3.07); q2 #79740e -> #746f0d (4.29 -> 4.60) */ + +:root[data-palette="gruvbox"] { + --bg: #fbf1c7; + --bg-elev: #f9f5d7; + --bg-sunken: #f2e5bc; + --bg-hover: rgba(60, 56, 54, 0.06); + --bg-active: rgba(60, 56, 54, 0.11); + --fg: #3c3836; + --fg-muted: #776b60; + --fg-faint: #746b5d; + --border: #d5c4a1; + --border-strong: #948871; + --accent: #076678; + --accent-fg: #ffffff; + --accent-soft: #d9debc; + --accent-soft-fg: #076678; + --danger: #9d0006; + --danger-soft: rgba(157, 0, 6, 0.15); + --warn: #af3a03; + --warn-soft: rgba(175, 58, 3, 0.15); + --success: #746f0d; + --success-soft: rgba(116, 111, 13, 0.15); + --link: #417956; + --unread-bg: #ffffff; + --read-bg: #f5ebc3; + --selected-bg: #d9debc; + --focus-ring: 0 0 0 3px rgba(7, 102, 120, 0.4); + --star: #b57614; + --q1: #076678; + --q2: #746f0d; + --q3: #8f3f71; + --scrollbar: rgba(119, 107, 96, 0.35); + color-scheme: light; +} + +/* gruvbox (dark) lifted for contrast: faint #827768 -> #968d80 (3.36 -> 4.51); danger #fb4934 -> #fb5440 (4.29 -> 4.52); border-strong #675f55 -> #787168 (2.35 -> 3.06) */ + +:root[data-theme="dark"][data-palette="gruvbox"] { + --bg: #282828; + --bg-elev: #32302f; + --bg-sunken: #1d2021; + --bg-hover: rgba(235, 219, 178, 0.06); + --bg-active: rgba(235, 219, 178, 0.11); + --fg: #ebdbb2; + --fg-muted: #a89984; + --fg-faint: #968d80; + --border: #504945; + --border-strong: #787168; + --accent: #83a598; + --accent-fg: #282828; + --accent-soft: rgba(131, 165, 152, 0.16); + --accent-soft-fg: #8cab9f; + --danger: #fb5440; + --danger-soft: rgba(251, 84, 64, 0.15); + --warn: #fe8019; + --warn-soft: rgba(254, 128, 25, 0.15); + --success: #b8bb26; + --success-soft: rgba(184, 187, 38, 0.15); + --link: #8ec07c; + --unread-bg: #32302f; + --read-bg: #1d2021; + --selected-bg: rgba(131, 165, 152, 0.18); + --focus-ring: 0 0 0 3px rgba(131, 165, 152, 0.4); + --star: #fabd2f; + --q1: #83a598; + --q2: #b8bb26; + --q3: #d3869b; + --scrollbar: rgba(168, 153, 132, 0.35); + color-scheme: dark; + --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.45); + --shadow-2: 0 8px 24px rgba(0, 0, 0, 0.55); + --shadow-3: 0 22px 60px -28px rgba(0, 0, 0, 0.75); +} + +/* rose-pine (light) lifted for contrast: muted #797593 -> #716d89 (4.02 -> 4.52); faint #9a96a7 -> #706e7a (2.64 -> 4.58); danger #b4637a -> #a45a6f (3.84 -> 4.50); warn #ea9d34 -> #986622 (2.05 -> 4.51); success #56949f -> #467881 (3.14 -> 4.51); star #ea9d34 -> #be7f2a (2.05 -> 3.07); border-strong #c8c2c1 -> #908c8b (1.61 -> 3.05); q2 #56949f -> #467881 (3.14 -> 4.51); q3 #907aa9 -> #7a6890 (3.47 -> 4.57) */ + +:root[data-palette="rose-pine"] { + --bg: #faf4ed; + --bg-elev: #fffaf3; + --bg-sunken: #f2e9e1; + --bg-hover: rgba(70, 66, 97, 0.06); + --bg-active: rgba(70, 66, 97, 0.11); + --fg: #464261; + --fg-muted: #716d89; + --fg-faint: #706e7a; + --border: #dfd9d2; + --border-strong: #908c8b; + --accent: #907aa9; + --accent-fg: #ffffff; + --accent-soft: #ebe3e3; + --accent-soft-fg: #705f84; + --danger: #a45a6f; + --danger-soft: rgba(164, 90, 111, 0.15); + --warn: #986622; + --warn-soft: rgba(152, 102, 34, 0.15); + --success: #467881; + --success-soft: rgba(70, 120, 129, 0.15); + --link: #286983; + --unread-bg: #ffffff; + --read-bg: #f5efe9; + --selected-bg: #ebe3e3; + --focus-ring: 0 0 0 3px rgba(144, 122, 169, 0.4); + --star: #be7f2a; + --q1: #286983; + --q2: #467881; + --q3: #7a6890; + --scrollbar: rgba(113, 109, 137, 0.35); + color-scheme: light; +} + +/* rose-pine (dark) lifted for contrast: faint #6c6982 -> #828095 (3.35 -> 4.60); success #31748f -> #5089a0 (3.38 -> 4.56); border-strong #423f56 -> #666376 (1.75 -> 3.04); q2 #31748f -> #5089a0 (3.38 -> 4.56) */ + +:root[data-theme="dark"][data-palette="rose-pine"] { + --bg: #191724; + --bg-elev: #1f1d2e; + --bg-sunken: #14121f; + --bg-hover: rgba(224, 222, 244, 0.06); + --bg-active: rgba(224, 222, 244, 0.11); + --fg: #e0def4; + --fg-muted: #908caa; + --fg-faint: #828095; + --border: #26233a; + --border-strong: #666376; + --accent: #c4a7e7; + --accent-fg: #191724; + --accent-soft: rgba(196, 167, 231, 0.16); + --accent-soft-fg: #c4a7e7; + --danger: #eb6f92; + --danger-soft: rgba(235, 111, 146, 0.15); + --warn: #f6c177; + --warn-soft: rgba(246, 193, 119, 0.15); + --success: #5089a0; + --success-soft: rgba(80, 137, 160, 0.15); + --link: #9ccfd8; + --unread-bg: #1f1d2e; + --read-bg: #14121f; + --selected-bg: rgba(196, 167, 231, 0.18); + --focus-ring: 0 0 0 3px rgba(196, 167, 231, 0.4); + --star: #f6c177; + --q1: #9ccfd8; + --q2: #5089a0; + --q3: #c4a7e7; + --scrollbar: rgba(144, 140, 170, 0.35); + color-scheme: dark; + --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.45); + --shadow-2: 0 8px 24px rgba(0, 0, 0, 0.55); + --shadow-3: 0 22px 60px -28px rgba(0, 0, 0, 0.75); +} + +/* tokyo-night (light) lifted for contrast: faint #777a8b -> #646675 (3.44 -> 4.60); border-strong #acaeb6 -> #83848a (1.79 -> 3.02) */ + +:root[data-palette="tokyo-night"] { + --bg: #e6e7ed; + --bg-elev: #f2f3f7; + --bg-sunken: #d6d8df; + --bg-hover: rgba(52, 59, 89, 0.06); + --bg-active: rgba(52, 59, 89, 0.11); + --fg: #343b59; + --fg-muted: #484c61; + --fg-faint: #646675; + --border: #c1c2c7; + --border-strong: #83848a; + --accent: #2959aa; + --accent-fg: #ffffff; + --accent-soft: #ccd3e4; + --accent-soft-fg: #2959aa; + --danger: #8c4351; + --danger-soft: rgba(140, 67, 81, 0.15); + --warn: #8f5e15; + --warn-soft: rgba(143, 94, 21, 0.15); + --success: #385f0d; + --success-soft: rgba(56, 95, 13, 0.15); + --link: #006c86; + --unread-bg: #ffffff; + --read-bg: #e1e2e9; + --selected-bg: #ccd3e4; + --focus-ring: 0 0 0 3px rgba(41, 89, 170, 0.4); + --star: #8f5e15; + --q1: #006c86; + --q2: #385f0d; + --q3: #65359d; + --scrollbar: rgba(72, 76, 97, 0.35); + color-scheme: light; +} + +/* tokyo-night (dark) lifted for contrast: border-strong #4b506c -> #62677f (2.17 -> 3.06) */ + +:root[data-theme="dark"][data-palette="tokyo-night"] { + --bg: #1a1b26; + --bg-elev: #1f2130; + --bg-sunken: #16161e; + --bg-hover: rgba(192, 202, 245, 0.06); + --bg-active: rgba(192, 202, 245, 0.11); + --fg: #c0caf5; + --fg-muted: #a9b1d6; + --fg-faint: #7e84a1; + --border: #363b54; + --border-strong: #62677f; + --accent: #7aa2f7; + --accent-fg: #1a1b26; + --accent-soft: rgba(122, 162, 247, 0.16); + --accent-soft-fg: #7aa2f7; + --danger: #f7768e; + --danger-soft: rgba(247, 118, 142, 0.15); + --warn: #e0af68; + --warn-soft: rgba(224, 175, 104, 0.15); + --success: #9ece6a; + --success-soft: rgba(158, 206, 106, 0.15); + --link: #7dcfff; + --unread-bg: #1f2130; + --read-bg: #16161e; + --selected-bg: rgba(122, 162, 247, 0.18); + --focus-ring: 0 0 0 3px rgba(122, 162, 247, 0.4); + --star: #e0af68; + --q1: #7dcfff; + --q2: #9ece6a; + --q3: #bb9af7; + --scrollbar: rgba(169, 177, 214, 0.35); + color-scheme: dark; + --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.45); + --shadow-2: 0 8px 24px rgba(0, 0, 0, 0.55); + --shadow-3: 0 22px 60px -28px rgba(0, 0, 0, 0.75); +} + +/* === generated palettes: end === */ + /* Accent variants */ :root[data-accent="blue"] { --accent: #2563eb; --accent-soft: #dbeafe; --accent-soft-fg: #1e3a8a; --selected-bg: #dbeafe; --focus-ring: 0 0 0 3px rgba(37,99,235,.35); --link:#1d4ed8; } :root[data-accent="purple"] { --accent: #7c3aed; --accent-soft: #ede9fe; --accent-soft-fg: #4c1d95; --selected-bg: #ede9fe; --focus-ring: 0 0 0 3px rgba(124,58,237,.35); --link:#6d28d9; } @@ -1404,3 +1766,8 @@ button.dp-open:disabled { cursor: default; opacity: .5; } /* The line offering the whole folder once the page is selected. */ .list-hint.select-all-hint { background: var(--accent-soft); color: var(--accent-soft-fg); } +/* Light / dark, beside the palette cards. */ +.mode-switch { display: inline-flex; border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; } +.mode-switch button { padding: 6px 14px; background: none; border: 0; color: var(--fg-muted); cursor: pointer; font: inherit; } +.mode-switch button.active { background: var(--accent-soft); color: var(--accent-soft-fg); } +.mode-switch button:disabled { opacity: .45; cursor: not-allowed; } diff --git a/web/src/views/AppShell.tsx b/web/src/views/AppShell.tsx index 18aaab5..a0c5cc7 100644 --- a/web/src/views/AppShell.tsx +++ b/web/src/views/AppShell.tsx @@ -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 ( diff --git a/web/src/views/settings/AppearanceSettings.tsx b/web/src/views/settings/AppearanceSettings.tsx index 875a50a..d4ded3d 100644 --- a/web/src/views/settings/AppearanceSettings.tsx +++ b/web/src/views/settings/AppearanceSettings.tsx @@ -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 = { + 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() {

{translate("Appearance")}

{translate("Make ihasmail yours.")}

{translate("Theme")}

-
- {THEMES.map((t) => ( - ))}
+
+ {PALETTES.map((p) => { + const shown = effectiveMode(s.mode, prefersDark); + return ( + + ); + })} +

- {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: ihasmail, site: ihasmail.org })} + {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.")}