"""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