Merge pull request #209 from Coffey-Labs/feat/palettes
Six palettes, each with a light half and a dark one
This commit is contained in:
@@ -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
|
||||||
+35
-6
@@ -936,12 +936,41 @@ offering to translate a page the reader cannot read.
|
|||||||
|
|
||||||
## Themes
|
## Themes
|
||||||
|
|
||||||
Follow the system, light, dark, or **ihasmail** — the palette this project's
|
Two questions, asked separately: **which palette** and **light or dark**. They
|
||||||
site is painted in, and what a new account starts on. It is a theme rather than
|
used to be one setting, which works for exactly one palette and stops working
|
||||||
an accent because it changes backgrounds, borders and text as well as the
|
at two.
|
||||||
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
|
| Palette | |
|
||||||
you to the one you were on.
|
| --- | --- |
|
||||||
|
| **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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())
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -43,20 +43,27 @@ describe("which settings follow the account", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("applying a settings file", () => {
|
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", () => {
|
it("takes known, non-device keys", () => {
|
||||||
const applied = acceptRemote({ theme: "dark", weekStart: 0, locale: "de-DE" });
|
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", () => {
|
it("ignores keys it has never heard of", () => {
|
||||||
// A newer ihasmail's settings, or a hand-edited file.
|
// 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", () => {
|
it("refuses device keys even when the file carries them", () => {
|
||||||
// An earlier build wrote the whole settings object up; that file must not
|
// An earlier build wrote the whole settings object up; that file must not
|
||||||
// now drag one machine's pane width onto every other one.
|
// 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", () => {
|
it("does not invent keys from an empty file", () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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";
|
import { loadJson, saveJson, setDeviceTrusted } from "@/lib/storage";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -98,55 +99,49 @@ describe("the default theme", () => {
|
|||||||
|
|
||||||
describe("the top-bar toggle", () => {
|
describe("the top-bar toggle", () => {
|
||||||
it("goes to light from anything dark", () => {
|
it("goes to light from anything dark", () => {
|
||||||
expect(toggleTarget("dark", "ihasmail")).toBe("light");
|
expect(toggleTarget({ palette: "ihasmail", mode: "dark" }, false).mode).toBe("light");
|
||||||
expect(toggleTarget("dark", "dark")).toBe("light");
|
expect(toggleTarget({ palette: "default", mode: "dark" }, false).mode).toBe("light");
|
||||||
expect(toggleTarget("dark", "system")).toBe("light");
|
expect(toggleTarget({ palette: "default", mode: "system" }, true).mode).toBe("light");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("comes back to the theme you were actually on", () => {
|
it("comes back to the palette you were actually on", () => {
|
||||||
// The whole point: two clicks from ihasmail must return to ihasmail, not
|
// The whole point: two presses from ihasmail must return to ihasmail, not
|
||||||
// deposit you on plain dark.
|
// deposit you on plain dark.
|
||||||
expect(toggleTarget("light", "ihasmail")).toBe("ihasmail");
|
const away = toggleTarget({ palette: "ihasmail", mode: "dark" }, false);
|
||||||
expect(toggleTarget("light", "dark")).toBe("dark");
|
expect(toggleTarget(away, false).palette).toBe("ihasmail");
|
||||||
});
|
expect(toggleTarget({ palette: "default", mode: "light" }, false)).toMatchObject({ palette: "default", mode: "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);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("remembering which dark theme you were on", () => {
|
describe("remembering the palette you were on", () => {
|
||||||
const setTheme = (t: Theme) => {
|
const set = (palette: PaletteId, mode: Mode) => {
|
||||||
useSettings.getState().update({ theme: t });
|
useSettings.getState().update({ palette, mode });
|
||||||
return useSettings.getState().settings;
|
return useSettings.getState().settings;
|
||||||
};
|
};
|
||||||
|
|
||||||
it("records a dark theme chosen from Settings, not just from the toggle", () => {
|
it("derives the legacy theme from whatever set the palette or mode", () => {
|
||||||
// update() is the single path every way of choosing a theme goes through,
|
// `theme` is no longer chosen; it is kept in step so a device on an older
|
||||||
// which is why the remembering lives there rather than at the call sites.
|
// build is not stranded on a theme nobody picked.
|
||||||
expect(setTheme("dark").lastDarkTheme).toBe("dark");
|
expect(set("default", "dark").theme).toBe("dark");
|
||||||
expect(setTheme("ihasmail").lastDarkTheme).toBe("ihasmail");
|
expect(set("ihasmail", "dark").theme).toBe("ihasmail");
|
||||||
expect(setTheme("system").lastDarkTheme).toBe("system");
|
expect(set("default", "system").theme).toBe("system");
|
||||||
});
|
expect(set("gruvbox", "light").theme).toBe("light");
|
||||||
|
expect(set("dracula", "dark").theme).toBe("dark");
|
||||||
it("does not let light overwrite it — that is the theme being toggled away from", () => {
|
|
||||||
setTheme("ihasmail");
|
|
||||||
expect(setTheme("light").lastDarkTheme).toBe("ihasmail");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("survives a there-and-back through the toggle", () => {
|
it("survives a there-and-back through the toggle", () => {
|
||||||
setTheme("ihasmail");
|
// Two presses return you exactly where you started, and the palette never
|
||||||
const away = setTheme(toggleTarget("dark", useSettings.getState().settings.lastDarkTheme));
|
// moves -- which is the whole of what the old lastDarkTheme existed for.
|
||||||
expect(away.theme).toBe("light");
|
set("ihasmail", "dark");
|
||||||
const back = setTheme(toggleTarget("light", away.lastDarkTheme));
|
const away = toggleTarget({ palette: "ihasmail", mode: "dark" }, false);
|
||||||
expect(back.theme).toBe("ihasmail");
|
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.
|
// its expectation would move too.
|
||||||
const synced = syncedPart(DEFAULT_SETTINGS);
|
const synced = syncedPart(DEFAULT_SETTINGS);
|
||||||
expect(synced).toHaveProperty("theme");
|
expect(synced).toHaveProperty("theme");
|
||||||
expect(synced).toHaveProperty("lastDarkTheme");
|
expect(synced).toHaveProperty("palette");
|
||||||
expect(DEVICE_KEYS.has("theme")).toBe(false);
|
expect(synced).toHaveProperty("mode");
|
||||||
expect(DEVICE_KEYS.has("lastDarkTheme")).toBe(false);
|
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", () => {
|
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",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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" };
|
||||||
|
}
|
||||||
+60
-29
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { hasCachedJson, loadJson, saveJson } from "@/lib/storage";
|
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 type { SortLevel, SortPreset } from "@/lib/listSort";
|
||||||
import { pendingSettingsKeys, queueSettingsPush } from "@/lib/settingsSync";
|
import { pendingSettingsKeys, queueSettingsPush } from "@/lib/settingsSync";
|
||||||
import { setDateTimePrefs, setUiLanguageForFormatting, type DateFormat, type TimeFormat } from "@/lib/datetime";
|
import { setDateTimePrefs, setUiLanguageForFormatting, type DateFormat, type TimeFormat } from "@/lib/datetime";
|
||||||
@@ -48,7 +49,16 @@ export interface Template {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Settings {
|
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;
|
theme: Theme;
|
||||||
|
/** The colours. */
|
||||||
|
palette: PaletteId;
|
||||||
|
/** Light, dark, or whatever the system says. */
|
||||||
|
mode: Mode;
|
||||||
accent: string;
|
accent: string;
|
||||||
density: Density;
|
density: Density;
|
||||||
readingPane: ReadingPane;
|
readingPane: ReadingPane;
|
||||||
@@ -203,14 +213,6 @@ export interface Settings {
|
|||||||
* id belonging to another account simply never matches.
|
* id belonging to another account simply never matches.
|
||||||
*/
|
*/
|
||||||
hiddenIdentities: string[];
|
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<Theme, "light">;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: Settings = {
|
export const DEFAULT_SETTINGS: Settings = {
|
||||||
@@ -222,6 +224,8 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
* it — is moved off it.
|
* it — is moved off it.
|
||||||
*/
|
*/
|
||||||
theme: "ihasmail",
|
theme: "ihasmail",
|
||||||
|
palette: "ihasmail",
|
||||||
|
mode: "dark",
|
||||||
accent: "teal",
|
accent: "teal",
|
||||||
density: "cozy",
|
density: "cozy",
|
||||||
readingPane: "right",
|
readingPane: "right",
|
||||||
@@ -294,7 +298,6 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
],
|
],
|
||||||
defaultIdentityByAccount: {},
|
defaultIdentityByAccount: {},
|
||||||
hiddenIdentities: [],
|
hiddenIdentities: [],
|
||||||
lastDarkTheme: "ihasmail",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -339,6 +342,20 @@ export function acceptRemote(remote: Record<string, unknown>): Partial<Settings>
|
|||||||
if (value === undefined) continue;
|
if (value === undefined) continue;
|
||||||
out[key] = value;
|
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<Settings>;
|
return out as Partial<Settings>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,11 +406,16 @@ applyDateTimePrefs(initialSettings);
|
|||||||
export const useSettings = create<SettingsState>((set, get) => ({
|
export const useSettings = create<SettingsState>((set, get) => ({
|
||||||
settings: initialSettings,
|
settings: initialSettings,
|
||||||
update(patch) {
|
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
|
* `theme` is derived, never chosen: whatever set the palette or the mode --
|
||||||
// than at the call sites means a fourth way to set a theme cannot forget.
|
* the toggle, Appearance, an imported file -- the legacy field is brought
|
||||||
const next = patch.theme && patch.theme !== "light" ? { ...patch, lastDarkTheme: patch.theme } : patch;
|
* back into line here rather than at the call sites, so a fourth way to
|
||||||
const settings = { ...get().settings, ...next };
|
* 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);
|
saveJson("settings", settings);
|
||||||
set({ settings });
|
set({ settings });
|
||||||
applyTheme(settings);
|
applyTheme(settings);
|
||||||
@@ -401,7 +423,7 @@ export const useSettings = create<SettingsState>((set, get) => ({
|
|||||||
applyLang(settings);
|
applyLang(settings);
|
||||||
// Dragging a splitter changes a device key on every frame and must not put
|
// 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.
|
// 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));
|
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 {
|
export function applyTheme(s: Settings = useSettings.getState().settings): void {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)").matches;
|
const prefersDark = Boolean(window.matchMedia?.("(prefers-color-scheme: dark)").matches);
|
||||||
const dark = isDarkTheme(s.theme, prefersDark);
|
const mode = effectiveMode(s.mode, 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.
|
* Two attributes, because they answer two questions. `data-theme` is the
|
||||||
root.dataset.theme = dark ? "dark" : "light";
|
* mode, and every dark-only rule in the stylesheet keys off it without
|
||||||
if (s.theme === "ihasmail") root.dataset.palette = "ihasmail";
|
* 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;
|
else delete root.dataset.palette;
|
||||||
root.dataset.density = s.density;
|
root.dataset.density = s.density;
|
||||||
root.dataset.accent = s.accent;
|
root.dataset.accent = s.accent;
|
||||||
root.dataset.fontsize = s.fontSize;
|
root.dataset.fontsize = s.fontSize;
|
||||||
const meta = document.querySelector<HTMLMetaElement>('meta[name="theme-color"]:not([media])');
|
const meta = document.querySelector<HTMLMetaElement>('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
|
* The browser chrome colour, read from the palette's own background so it does
|
||||||
* from light is wherever you last were, which is the whole point of
|
* not have to be listed twice and cannot drift from it.
|
||||||
* remembering it.
|
|
||||||
*/
|
*/
|
||||||
export function toggleTarget(effective: "light" | "dark", lastDarkTheme: Settings["lastDarkTheme"]): Theme {
|
function paletteThemeColor(palette: PaletteId, mode: "light" | "dark"): string {
|
||||||
return effective === "dark" ? "light" : lastDarkTheme;
|
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. */
|
/** Whether a theme paints dark, resolving "system" against the OS. */
|
||||||
export function isDarkTheme(theme: Theme, prefersDark = false): boolean {
|
export function isDarkTheme(theme: Theme, prefersDark = false): boolean {
|
||||||
return theme === "dark" || theme === "ihasmail" || (theme === "system" && prefersDark);
|
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.
|
* resolves to whatever the OS is doing right now, and follows it as it changes.
|
||||||
*/
|
*/
|
||||||
export function useEffectiveTheme(): "light" | "dark" {
|
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);
|
const [systemDark, setSystemDark] = useState(() => window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const mq = window.matchMedia?.("(prefers-color-scheme: dark)");
|
const mq = window.matchMedia?.("(prefers-color-scheme: dark)");
|
||||||
@@ -532,7 +563,7 @@ export function useEffectiveTheme(): "light" | "dark" {
|
|||||||
mq.addEventListener("change", onChange);
|
mq.addEventListener("change", onChange);
|
||||||
return () => mq.removeEventListener("change", onChange);
|
return () => mq.removeEventListener("change", onChange);
|
||||||
}, []);
|
}, []);
|
||||||
return isDarkTheme(theme, systemDark) ? "dark" : "light";
|
return effectiveMode(mode, systemDark);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const settings = () => useSettings.getState().settings;
|
export const settings = () => useSettings.getState().settings;
|
||||||
|
|||||||
+368
-1
@@ -105,7 +105,7 @@
|
|||||||
* top of this theme -- and because the default accent ("teal") has no rule of
|
* 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.
|
* 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: #0d2430;
|
||||||
--bg-elev: #12303e;
|
--bg-elev: #12303e;
|
||||||
--bg-sunken: #0a1c26;
|
--bg-sunken: #0a1c26;
|
||||||
@@ -143,6 +143,368 @@
|
|||||||
color-scheme: dark;
|
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 */
|
/* 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="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; }
|
: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. */
|
/* 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); }
|
.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; }
|
||||||
|
|||||||
@@ -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 { 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 { useSession } from "@/store/session";
|
||||||
import { withBase } from "@/lib/basePath";
|
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 { useMail } from "@/store/mail";
|
||||||
import { draftFromMailto, useCompose } from "@/store/compose";
|
import { draftFromMailto, useCompose } from "@/store/compose";
|
||||||
import { Avatar, useIsMobile } from "@/ui/misc";
|
import { Avatar, useIsMobile } from "@/ui/misc";
|
||||||
@@ -268,18 +269,20 @@ function QuotaBar() {
|
|||||||
*/
|
*/
|
||||||
function ThemeToggle() {
|
function ThemeToggle() {
|
||||||
const effective = useEffectiveTheme();
|
const effective = useEffectiveTheme();
|
||||||
const lastDarkTheme = useSettings((s) => s.settings.lastDarkTheme);
|
const settings = useSettings((s) => s.settings);
|
||||||
const update = useSettings((s) => s.update);
|
const update = useSettings((s) => s.update);
|
||||||
const next = toggleTarget(effective, lastDarkTheme);
|
const prefersDark = Boolean(window.matchMedia?.("(prefers-color-scheme: dark)").matches);
|
||||||
// The label names where you are going, and going back is not always "dark"
|
const next = toggleTarget({ palette: settings.palette, mode: settings.mode }, prefersDark);
|
||||||
// any more -- it is whichever theme you were on before flipping to light.
|
// Name where it is going, and by the palette when the palette is changing --
|
||||||
const label = next === "light" ? "light mode" : next === "system" ? "your system theme" : next === "ihasmail" ? "the ihasmail theme" : "dark mode";
|
// 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 (
|
return (
|
||||||
<button
|
<button
|
||||||
className="icon-btn"
|
className="icon-btn"
|
||||||
aria-label={t("Switch to {theme}", { theme: label })}
|
aria-label={t("Switch to {theme}", { theme: label })}
|
||||||
title={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} />}
|
{effective === "dark" ? <Sun size={21} /> : <Moon size={21} />}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,22 +1,44 @@
|
|||||||
import { useSettings } from "@/store/settings";
|
import { useSettings } from "@/store/settings";
|
||||||
|
import { PALETTES, effectiveMode, type Mode, type PaletteId } from "@/lib/palette";
|
||||||
import { Switch, useIsTouch } from "@/ui/misc";
|
import { Switch, useIsTouch } from "@/ui/misc";
|
||||||
import { SWIPE_CHOICES, type SwipeAction } from "@/lib/swipe";
|
import { SWIPE_CHOICES, type SwipeAction } from "@/lib/swipe";
|
||||||
import { TRANSLATION_ISSUE_URL, UI_LANGUAGES } from "@/lib/languages";
|
import { TRANSLATION_ISSUE_URL, UI_LANGUAGES } from "@/lib/languages";
|
||||||
import { t as translate, tNode } from "@/lib/i18n";
|
import { t as translate, tNode } from "@/lib/i18n";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The theme cards, each previewing the background it actually paints. Kept as
|
* A swatch for each palette, drawn from the colours that palette actually
|
||||||
* data rather than three inline ternaries so a fourth does not mean editing a
|
* paints, so a card looks like what picking it does. Kept as data rather than
|
||||||
* conditional in three places.
|
* inline ternaries so a sixth palette does not mean editing a conditional in
|
||||||
|
* three places.
|
||||||
*/
|
*/
|
||||||
const THEMES = [
|
const PALETTE_PREVIEW: Record<PaletteId, { light: string; dark: string }> = {
|
||||||
{ id: "system", label: "Match system", preview: "linear-gradient(90deg,#f6f8fa 50%,#0b1220 50%)" },
|
default: { light: "#f6f8fa", dark: "#0b1220" },
|
||||||
{ id: "light", label: "Light", preview: "#f6f8fa" },
|
|
||||||
{ id: "dark", label: "Dark", preview: "#0b1220" },
|
|
||||||
// The ihasmail.org palette: its background, with its teal and the logo's
|
// The ihasmail.org palette: its background, with its teal and the logo's
|
||||||
// orange showing, so the card looks like what picking it does.
|
// orange showing.
|
||||||
{ id: "ihasmail", label: "ihasmail", preview: "linear-gradient(135deg,#0d2430 0%,#12303e 55%,#46cac3 55%,#46cac3 78%,#f9a34b 78%)" },
|
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%)" },
|
||||||
] as const;
|
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 = [
|
const ACCENTS = [
|
||||||
{ id: "teal", color: "#0f766e" },
|
{ id: "teal", color: "#0f766e" },
|
||||||
@@ -30,6 +52,7 @@ const ACCENTS = [
|
|||||||
export function AppearanceSettings() {
|
export function AppearanceSettings() {
|
||||||
const s = useSettings((st) => st.settings);
|
const s = useSettings((st) => st.settings);
|
||||||
const update = useSettings((st) => st.update);
|
const update = useSettings((st) => st.update);
|
||||||
|
const prefersDark = Boolean(window.matchMedia?.("(prefers-color-scheme: dark)").matches);
|
||||||
const isTouch = useIsTouch();
|
const isTouch = useIsTouch();
|
||||||
const chosen = UI_LANGUAGES.find((l) => l.tag === s.uiLanguage);
|
const chosen = UI_LANGUAGES.find((l) => l.tag === s.uiLanguage);
|
||||||
const betaChosen = Boolean(chosen?.beta);
|
const betaChosen = Boolean(chosen?.beta);
|
||||||
@@ -38,16 +61,26 @@ export function AppearanceSettings() {
|
|||||||
<h1>{translate("Appearance")}</h1>
|
<h1>{translate("Appearance")}</h1>
|
||||||
<p className="lead">{translate("Make ihasmail yours.")}</p>
|
<p className="lead">{translate("Make ihasmail yours.")}</p>
|
||||||
<h2>{translate("Theme")}</h2>
|
<h2>{translate("Theme")}</h2>
|
||||||
<div className="theme-grid">
|
<div className="mode-switch" role="group" aria-label={translate("Light or dark")}>
|
||||||
{THEMES.map((t) => (
|
{MODES.map((m) => (
|
||||||
<button key={t.id} className={`theme-card ${s.theme === t.id ? "active" : ""}`} onClick={() => update({ theme: t.id })}>
|
<button key={m.id} className={s.mode === m.id ? "active" : ""} onClick={() => update({ mode: m.id })}>
|
||||||
<div className="preview" style={{ background: t.preview }} />
|
{translate(m.label)}
|
||||||
{translate(t.label)}
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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 }}>
|
<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>
|
</p>
|
||||||
<Switch
|
<Switch
|
||||||
checked={s.themeMessageBody}
|
checked={s.themeMessageBody}
|
||||||
|
|||||||
Reference in New Issue
Block a user