Files
espresso_frame/server/app/theme_tokens.py
T
tfaour 5f4f8f2ea7
Build and push server image / test (push) Successful in 44s
Build and push server image / build-and-push (push) Successful in 3m33s
Build and push server image / deploy (push) Failing after 1m27s
Add a curated theme system for "modern" style widgets, inspired by Tesserae
Frame.theme (7 presets in app/theme_tokens.py) drives font family,
corner radius, drop shadow, and an accent hue for every modern-style
widget's header/accent region. Rich accent colors (not just the 6 flat
panel inks) are approximated via denser Bayer stippling confined to just
that region (html_render.ordered_dither_regions), so icon/text content
elsewhere stays exactly as crisp as it is today -- verified directly
against real Chromium renders, both in unit tests and via run-server.
"classic" is a byte-identical no-visual-change default: weather's header
keeps its original fixed blue gradient, tasks/calendar keep their flat
THEME_* ink.

Themes are purely stylistic -- battery's charge-level color, calendar/
tasks' per-owner event chips, and text's own per-widget font choice are
never touched.
2026-07-31 10:34:28 +00:00

203 lines
8.7 KiB
Python

"""Curated theme presets for "modern" (HTML/CSS) style widgets, plus the
font-family table every modern-style widget -- and widgets/text.py's own
classic PIL path -- resolves fonts through. The font table used to be
widgets/text.py's private property; it's hoisted here (text.py now
imports it back) since html_render.py's build_text/build_daily/etc. all
need to resolve a theme's font_family to real font files too, not just
the text widget.
Inspired by Tesserae's (github.com/dmellok/tesserae, AGPL-3.0) three-
layer CSS custom-property theme system -- primitives, semantic per-
theme tokens, component tokens -- reimplemented here as original Python/
CSS rather than copied (see docs/widgets.md and this repo's CLAUDE.md on
copyleft dependencies).
A theme is purely **stylistic**: font family, corner radius, drop
shadow, header gradient on/off, and an accent hue for the widgets that
have an actual header/accent region to dither richer (see
html_render.ordered_dither_regions). It never touches **functional**
color-coding -- battery's charge-level red/yellow/green, calendar/tasks'
per-owner event color chips, and text's user-authored inline run colors
are status/identity signals, not style choices, and no theme may
recolor them.
Rich accent_hex values are not restricted to the 6 exact panel inks --
`ordered_dither_regions` approximates them via denser Bayer stippling in
just the accent region (verified directly: terracotta/ochre/moss/teal/
slate-blue/plum swatches all resolve to a believable multi-ink
approximation at amplitude ~130, the same mechanism -- spatial
dithering, not flat quantization -- Tesserae's own calibrated-palette
Floyd-Steinberg uses, just ordered instead of diffused)."""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from PIL import ImageFont
from . import panel_style
_FONT_DIR = Path(__file__).resolve().parent / "fonts"
# --- Font family table (hoisted from widgets/text.py) -----------------
DEFAULT_FONT_FAMILY = "sans"
FONT_FAMILIES: dict[str, str] = {
"sans": "Sans-serif (Noto Sans)",
"inter": "Inter",
"source_sans": "Source Sans",
"serif": "Serif (Noto Serif)",
"elegant": "Elegant serif (Crimson Text)",
"slab": "Slab serif (Arvo)",
"mono": "Monospace (IBM Plex Mono)",
}
_FONT_FILES = {
"sans": {
(False, False): "NotoSans-Regular.ttf", (True, False): "NotoSans-Bold.ttf",
(False, True): "NotoSans-Italic.ttf", (True, True): "NotoSans-BoldItalic.ttf",
},
"inter": {
(False, False): "Inter-Regular.ttf", (True, False): "Inter-Bold.ttf",
(False, True): "Inter-Italic.ttf", (True, True): "Inter-BoldItalic.ttf",
},
"source_sans": {
(False, False): "SourceSans3-Regular.ttf", (True, False): "SourceSans3-Bold.ttf",
(False, True): "SourceSans3-Italic.ttf", (True, True): "SourceSans3-BoldItalic.ttf",
},
"serif": {
(False, False): "NotoSerif-Regular.ttf", (True, False): "NotoSerif-Bold.ttf",
(False, True): "NotoSerif-Italic.ttf", (True, True): "NotoSerif-BoldItalic.ttf",
},
"elegant": {
(False, False): "CrimsonText-Regular.ttf", (True, False): "CrimsonText-Bold.ttf",
(False, True): "CrimsonText-Italic.ttf", (True, True): "CrimsonText-BoldItalic.ttf",
},
"slab": {
(False, False): "Arvo-Regular.ttf", (True, False): "Arvo-Bold.ttf",
(False, True): "Arvo-Italic.ttf", (True, True): "Arvo-BoldItalic.ttf",
},
"mono": {
(False, False): "IBMPlexMono-Regular.ttf", (True, False): "IBMPlexMono-Bold.ttf",
(False, True): "IBMPlexMono-Italic.ttf", (True, True): "IBMPlexMono-BoldItalic.ttf",
},
}
def font_path(family: str, bold: bool, italic: bool) -> Path:
files = _FONT_FILES.get(family) or _FONT_FILES[DEFAULT_FONT_FAMILY]
return _FONT_DIR / files[(bold, italic)]
@lru_cache(maxsize=256)
def font(family: str, bold: bool, italic: bool, size: int) -> ImageFont.FreeTypeFont:
return ImageFont.truetype(str(font_path(family, bold, italic)), size)
def _rgb_to_hex(rgb: tuple[int, int, int]) -> str:
return "#%02x%02x%02x" % tuple(rgb)
def _darken_hex(rgb: tuple[int, int, int], factor: float = 0.75) -> str:
return _rgb_to_hex(tuple(max(0, round(c * factor)) for c in rgb))
# --- Theme presets -------------------------------------------------------
DEFAULT_THEME = "classic"
# accent_hex=None means "keep each widget's own classic THEME_* ink" --
# resolve_theme() below is what actually looks that up -- so "classic" is
# deliberately a no-visual-change default, byte-identical to how modern
# style already rendered before this theme system existed.
THEMES: dict[str, dict] = {
"classic": {
"label": "Classic", "accent_hex": None,
"font_family": "inter", "radius": panel_style.CARD_RADIUS, "shadow": True,
"gradient": True, "accent_amplitude": 48.0,
},
"terracotta": {
"label": "Terracotta", "accent_hex": "#a84b2a",
"font_family": "inter", "radius": panel_style.CARD_RADIUS, "shadow": True,
"gradient": True, "accent_amplitude": 130.0,
},
"ochre": {
"label": "Ochre", "accent_hex": "#b8892b",
"font_family": "slab", "radius": panel_style.CARD_RADIUS, "shadow": True,
"gradient": True, "accent_amplitude": 130.0,
},
"moss": {
"label": "Moss", "accent_hex": "#4f6f36",
"font_family": "serif", "radius": 4, "shadow": False,
"gradient": False, "accent_amplitude": 130.0,
},
"teal": {
"label": "Teal", "accent_hex": "#2b7a78",
"font_family": "source_sans", "radius": panel_style.CARD_RADIUS, "shadow": True,
"gradient": True, "accent_amplitude": 130.0,
},
"slate": {
"label": "Slate", "accent_hex": "#3f5a88",
"font_family": "inter", "radius": 0, "shadow": False,
"gradient": False, "accent_amplitude": 130.0,
},
"plum": {
"label": "Plum", "accent_hex": "#6a3b5e",
"font_family": "elegant", "radius": panel_style.CARD_RADIUS, "shadow": True,
"gradient": False, "accent_amplitude": 130.0,
},
}
# "classic" theme's accent_hex is None, meaning "keep this specific
# widget kind's own pre-theme-system look exactly" -- for tasks/calendar
# that's their classic THEME_TASKS/THEME_CALENDAR ink (a flat color, both
# gradient stops equal, since neither ever had a gradient header before
# this system existed); weather's modern style never went through
# panel_style.THEME at all -- its header was always this fixed blue
# gradient (see html_render.py's now-removed ACCENT_START/ACCENT_END
# constants) -- preserved here byte-for-byte so "classic" stays a
# genuinely no-visual-change default for every widget kind that shipped
# before themes existed.
_CLASSIC_WEATHER_GRADIENT = ("#1c4fd6", "#6fa8ff")
def resolve_theme(theme_name: str | None, widget_kind: str, palette_rgb: list | None) -> dict:
"""Concrete, ready-to-render values for one widget's modern-style
build_* function: accent_hex/accent_hex_dark (a gradient's two CSS
stops -- both equal when the theme has no gradient), font_family
(validated against FONT_FAMILIES) plus its resolved font_regular/
font_bold file paths, radius, shadow, gradient, and accent_amplitude
(for ordered_dither_regions' header rect -- unused by widgets that
dither their header at the base amplitude only, see module
docstring). widget_kind is one of panel_style.THEME's keys
("calendar"/"tasks"/"weather") plus "battery"/"text"/"static"/
"whiteboard" for the widgets that have no classic THEME_* ink of
their own -- those fall back to BLACK when theme_name is "classic"
or unrecognized."""
theme = THEMES.get(theme_name or DEFAULT_THEME, THEMES[DEFAULT_THEME])
accent_hex = theme["accent_hex"]
if accent_hex is None:
if widget_kind == "weather":
accent_hex, accent_hex_dark = _CLASSIC_WEATHER_GRADIENT
else:
ink_index = panel_style.THEME.get(widget_kind, panel_style.BLACK)
accent_hex = accent_hex_dark = _rgb_to_hex(panel_style.ink(palette_rgb, ink_index))
else:
accent_hex_dark = accent_hex if not theme["gradient"] else _darken_hex(
tuple(int(accent_hex[i:i + 2], 16) for i in (1, 3, 5))
)
family = theme["font_family"] if theme["font_family"] in FONT_FAMILIES else DEFAULT_FONT_FAMILY
return {
"theme_name": theme_name if theme_name in THEMES else DEFAULT_THEME,
"accent_hex": accent_hex,
"accent_hex_dark": accent_hex_dark,
"font_family": family,
"font_regular": str(font_path(family, False, False)),
"font_bold": str(font_path(family, True, False)),
"radius": theme["radius"],
"shadow": theme["shadow"],
"gradient": theme["gradient"],
"accent_amplitude": theme["accent_amplitude"],
}