"""Per-account colour overrides — the theme designer.
Six colours: the accent, the two directions money can go, and the three chart
series. Surfaces, lines and ink stay with the theme, so a bad choice makes
something ugly rather than unusable (decisions.md #89).
Light and dark each have their own six, so each is measured on its own card,
against the floor for what it is drawn as — advice, not a veto.
Storage is `user.theme_colors`, mode -> token -> `#rrggbb`. Absent keys fall
through to the stylesheet, so a partial choice is a partial override.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
HEX = re.compile(r"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")
MODES = ("light", "dark")
# The surface a chosen colour has to be legible on in each mode: the panel.
# Both are `--panel-solid` from style.css.
SURFACES = {"light": "#f7f8fc", "dark": "#1b2030"}
# The floors a colour is held to, by what it is drawn as: 15px text needs
# 4.5:1 (WCAG 1.4.3), a chart's line or slice 3:1 (1.4.11).
TEXT, GRAPHIC = 4.5, 3.0
# Words on a chosen accent's buttons: the dark ground's own colour, or white.
INK = "#0d1018"
@dataclass(frozen=True)
class Swatch:
token: str # the CSS custom property, without the leading --
label: str
blurb: str
floor: float # TEXT or GRAPHIC
# Order matters: this is the order they render in, and it goes from the colour
# people will change first to the ones they may never touch.
SWATCHES: tuple[Swatch, ...] = (
Swatch("accent", "Accent",
"Buttons, links, and the highlight on the page you are looking at.", TEXT),
Swatch("up", "Gain", "Money made — totals, table cells, the up arrow.", TEXT),
Swatch("down", "Loss", "Money lost.", TEXT),
Swatch("viz-1", "Chart series 1", "The first line or slice in every chart.", GRAPHIC),
Swatch("viz-2", "Chart series 2", "The second.", GRAPHIC),
Swatch("viz-3", "Chart series 3", "The third.", GRAPHIC),
)
BY_TOKEN = {s.token: s for s in SWATCHES}
# The built-in value for each in each mode, shown as the input's placeholder.
# A test keeps them in step with style.css.
DEFAULTS = {
"light": {"accent": "#4338ca", "up": "#1a7f37", "down": "#c62828",
"viz-1": "#8959ba", "viz-2": "#1a7f37", "viz-3": "#aa5f04"},
"dark": {"accent": "#8b83f0", "up": "#2ea043", "down": "#f25050",
"viz-1": "#a37ecd", "viz-2": "#2ea043", "viz-3": "#c77f3e"},
}
def _channels(value: str) -> tuple[int, int, int]:
h = value.lstrip("#")
if len(h) == 3:
h = "".join(c * 2 for c in h)
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
def _luminance(value: str) -> float:
def channel(c: int) -> float:
s = c / 255
return s / 12.92 if s <= 0.04045 else ((s + 0.055) / 1.055) ** 2.4
r, g, b = (channel(c) for c in _channels(value))
return 0.2126 * r + 0.7152 * g + 0.0722 * b
def contrast(value: str, surface: str) -> float:
"""WCAG contrast ratio. Same arithmetic the palette checks use."""
a, b = sorted((_luminance(value), _luminance(surface)), reverse=True)
return round((a + 0.05) / (b + 0.05), 2)
def normalise(value: str) -> str | None:
"""`#abc` and `#AABBCC` in, `#aabbcc` out. None if it is not a colour.
Deliberately strict: a named colour or an `rgb()` string would work in a
browser and then not round-trip through the `` beside
it, which is a worse experience than being told no.
"""
value = (value or "").strip()
if not HEX.match(value):
return None
h = value.lstrip("#").lower()
if len(h) == 3:
h = "".join(c * 2 for c in h)
return "#" + h
def clean(submitted) -> dict[str, dict[str, str]]:
"""The stored form of what a person submitted, from fields named
`-`.
Anything else is ignored rather than stored — the colours the app
understands are `SWATCHES` in `MODES`, and a row naming something else
would be dead weight that a future version might misread. A blank value
means "back to the default", which is an absent key, not an empty string.
"""
out: dict[str, dict[str, str]] = {}
for mode in MODES:
chosen = {s.token: value for s in SWATCHES
if (value := normalise(submitted.get(f"{mode}-{s.token}", "")))}
if chosen:
out[mode] = chosen
return out
def _chosen(colors, mode: str) -> dict:
found = colors.get(mode) if isinstance(colors, dict) else None
return found if isinstance(found, dict) else {}
def _toward(colour: str, other: str, share: float) -> str:
"""`colour` moved `share` of the way to `other`, in sRGB."""
a = [int(colour[i:i + 2], 16) for i in (1, 3, 5)]
b = [int(other[i:i + 2], 16) for i in (1, 3, 5)]
return "#" + "".join(f"{round(x + (y - x) * share):02x}" for x, y in zip(a, b))
def _accent(value: str) -> list[str]:
"""A chosen accent as buttons paint it: the gradient, and the words on
it. White or ink, whichever reads better; the gradient runs from the
colour away from the words, so its far end is never harder to read."""
words = "#ffffff" if contrast("#ffffff", value) >= contrast(INK, value) else INK
far = _toward(value, "#000000" if words == "#ffffff" else "#ffffff", 0.2)
return [f"--accent-grad:linear-gradient(135deg, {value}, {far})", f"--on-accent:{words}"]
def _declarations(chosen: dict) -> str:
parts = []
for swatch in SWATCHES: # fixed order, so it is stable
# Checked again on the way out: this is written into a