Add a curated theme system for "modern" style widgets, inspired by Tesserae
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

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.
This commit is contained in:
2026-07-31 10:34:28 +00:00
parent e331f5e5a1
commit 5f4f8f2ea7
32 changed files with 808 additions and 195 deletions
+65
View File
@@ -0,0 +1,65 @@
"""app.html_render's shared ordered-dithering primitives -- ordered_dither
and ordered_dither_regions -- exercised directly against synthetic
images, no Chromium/Playwright involved (these two functions run purely
on whatever Image render_html_to_image already handed back)."""
from __future__ import annotations
from PIL import Image
from app import html_render
from app.image_pipeline import DEFAULT_PALETTE_RGB
_PALETTE = set(DEFAULT_PALETTE_RGB)
# A muddy hue nowhere near any of the 6 exact palette colors -- at the
# "modern" style's tuned default amplitude (48), this should still snap
# flatly to a single nearest ink (see theme_tokens.py's module docstring
# for why 48 was chosen for icon/text legibility); only a much higher
# amplitude (as a rich theme's accent_amplitude would use) stipples it
# into a multi-ink approximation.
_RICH_HUE = (168, 75, 42) # a terracotta-ish RGB, not one of the 6 inks
def test_ordered_dither_output_is_exact_palette_colors():
img = Image.new("RGB", (40, 30), (128, 128, 128))
dithered = html_render.ordered_dither(img, None)
assert set(dithered.getdata()) <= _PALETTE
def test_ordered_dither_regions_outside_the_region_matches_plain_dither():
"""Pixels outside every accent_regions rect must come out identical
to a plain ordered_dither call at base_amplitude -- the region-aware
variant must not perturb anything it wasn't asked to."""
img = Image.new("RGB", (100, 80), (90, 140, 200))
base_only = html_render.ordered_dither(img, None, amplitude=48.0)
regions = html_render.ordered_dither_regions(
img, None, base_amplitude=48.0, accent_regions=[((10, 10, 40, 30), 130.0)]
)
for x in range(100):
for y in range(80):
if 10 <= x < 40 and 10 <= y < 30:
continue # inside the accent region -- expected to differ
assert regions.getpixel((x, y)) == base_only.getpixel((x, y))
def test_ordered_dither_regions_stipples_a_rich_hue_the_base_amplitude_would_flatten():
"""The whole reason ordered_dither_regions exists: a rich accent hue
dithered at the base (icon/text-safe) amplitude just snaps to one
nearest ink, but the same hue in an accent region at a theme's higher
accent_amplitude resolves to a believable multi-ink stipple instead --
assert that difference directly, not just "some image came back"."""
img = Image.new("RGB", (60, 60), _RICH_HUE)
rect = (0, 0, 60, 60)
flat = html_render.ordered_dither(img, None, amplitude=48.0)
richer = html_render.ordered_dither_regions(img, None, base_amplitude=48.0, accent_regions=[(rect, 130.0)])
assert len(set(flat.getdata())) == 1
assert len(set(richer.getdata())) > 1
assert set(richer.getdata()) <= _PALETTE
def test_ordered_dither_regions_with_no_accent_regions_matches_plain_dither():
img = Image.new("RGB", (30, 30), (50, 60, 70))
assert list(html_render.ordered_dither_regions(img, None, base_amplitude=48.0).getdata()) == \
list(html_render.ordered_dither(img, None, amplitude=48.0).getdata())
+92
View File
@@ -0,0 +1,92 @@
"""app.theme_tokens -- resolve_theme()'s fallback logic (does "classic"
stay a byte-identical no-op vs. each widget kind's pre-theme-system
look?) and the font table every preset resolves against. No rendering
here -- see test_html_render.py for ordered_dither_regions and each
widget's own test_widgets_*.py for dispatch-level "does a theme actually
change the output" coverage."""
from __future__ import annotations
from pathlib import Path
from app import panel_style, theme_tokens
def test_classic_weather_matches_historical_fixed_gradient():
"""Weather's modern style never went through panel_style.THEME --
its header was always this fixed blue gradient (see html_render.py's
removed ACCENT_START/ACCENT_END). "classic" must reproduce it
byte-for-byte so themes are additive, not a silent regression."""
resolved = theme_tokens.resolve_theme("classic", "weather", None)
assert resolved["accent_hex"] == "#1c4fd6"
assert resolved["accent_hex_dark"] == "#6fa8ff"
def test_classic_tasks_and_calendar_use_flat_theme_ink():
"""Tasks/calendar's classic accent was always a flat single ink (no
gradient) resolved through panel_style.THEME -- both ends of the
"gradient" must be that same ink, not two different shades."""
tasks = theme_tokens.resolve_theme("classic", "tasks", None)
calendar = theme_tokens.resolve_theme("classic", "calendar", None)
assert tasks["accent_hex"] == tasks["accent_hex_dark"]
assert calendar["accent_hex"] == calendar["accent_hex_dark"]
from app.image_pipeline import DEFAULT_PALETTE_RGB
expected_tasks = "#%02x%02x%02x" % tuple(DEFAULT_PALETTE_RGB[panel_style.THEME_TASKS])
expected_calendar = "#%02x%02x%02x" % tuple(DEFAULT_PALETTE_RGB[panel_style.THEME_CALENDAR])
assert tasks["accent_hex"] == expected_tasks
assert calendar["accent_hex"] == expected_calendar
def test_classic_unmapped_widget_kind_falls_back_to_black():
resolved = theme_tokens.resolve_theme("classic", "battery", None)
from app.image_pipeline import DEFAULT_PALETTE_RGB
assert resolved["accent_hex"] == "#%02x%02x%02x" % tuple(DEFAULT_PALETTE_RGB[panel_style.BLACK])
def test_unknown_theme_name_falls_back_to_classic():
assert theme_tokens.resolve_theme("not-a-real-theme", "weather", None) == \
theme_tokens.resolve_theme("classic", "weather", None)
assert theme_tokens.resolve_theme(None, "weather", None) == \
theme_tokens.resolve_theme("classic", "weather", None)
def test_every_preset_resolves_without_error_for_every_widget_kind():
widget_kinds = ["weather", "tasks", "calendar", "battery", "text", "static", "whiteboard"]
for theme_name in theme_tokens.THEMES:
for kind in widget_kinds:
resolved = theme_tokens.resolve_theme(theme_name, kind, None)
assert resolved["accent_hex"].startswith("#") and len(resolved["accent_hex"]) == 7
assert resolved["accent_hex_dark"].startswith("#") and len(resolved["accent_hex_dark"]) == 7
assert resolved["font_family"] in theme_tokens.FONT_FAMILIES
assert Path(resolved["font_regular"]).exists()
assert Path(resolved["font_bold"]).exists()
def test_non_gradient_theme_has_equal_accent_stops():
""""moss" is configured with gradient=False -- its two CSS gradient
stops must be identical (a flat fill), unlike a gradient theme's."""
resolved = theme_tokens.resolve_theme("moss", "tasks", None)
assert resolved["gradient"] is False
assert resolved["accent_hex"] == resolved["accent_hex_dark"]
def test_gradient_theme_has_a_darker_second_stop():
resolved = theme_tokens.resolve_theme("terracotta", "tasks", None)
assert resolved["gradient"] is True
assert resolved["accent_hex"] == "#a84b2a"
assert resolved["accent_hex_dark"] != resolved["accent_hex"]
def test_font_path_covers_every_family_and_style_combination():
for family in theme_tokens.FONT_FAMILIES:
for bold in (False, True):
for italic in (False, True):
assert theme_tokens.font_path(family, bold, italic).exists()
def test_font_path_falls_back_to_default_family_for_unknown_name():
assert theme_tokens.font_path("not-a-real-family", False, False) == \
theme_tokens.font_path(theme_tokens.DEFAULT_FONT_FAMILY, False, False)
+32
View File
@@ -208,3 +208,35 @@ def test_render_modern_month_falls_back_to_agenda_below_small_tier(db_session, m
img = widgets.calendar.render(db_session, frame, widget, 300, 192)
assert img.size == (300, 192)
def test_render_modern_threads_frame_theme_through_to_resolve_theme(db_session, monkeypatch):
"""Same spy-on-resolve_theme approach as test_widgets_weather.py's
equivalent test -- confirms calendar.py's render() passes frame.theme
into calendar_html_render.build (proving the widget-level threading,
not re-testing ordered_dither_regions itself, which
test_html_render.py already covers)."""
from app import calendar_html_render, theme_tokens
calls = []
real_resolve = theme_tokens.resolve_theme
def _spy(theme_name, widget_kind, palette_rgb):
calls.append((theme_name, widget_kind))
return real_resolve(theme_name, widget_kind, palette_rgb)
monkeypatch.setattr(theme_tokens, "resolve_theme", _spy)
monkeypatch.setattr(calendar_html_render, "theme_tokens", theme_tokens)
frame, widget = _make_widget(db_session, view="agenda", render_style="modern")
frame.theme = "moss"
# calendar.py's render() takes widget_locked's write path (it resets
# browse_offset on a normal wake), which commits and would otherwise
# expire-and-reload frame from the DB, discarding this uncommitted
# attribute change.
db_session.commit()
_stub_fetches(monkeypatch)
_stub_render_html_to_image(monkeypatch)
widgets.calendar.render(db_session, frame, widget, 380, 300)
assert ("moss", "calendar") in calls
+30
View File
@@ -153,3 +153,33 @@ def test_render_modern_style(db_session, monkeypatch):
img = widgets.tasks.render(db_session, frame, widget, 300, 200)
assert img.size == (300, 200)
assert img.mode == "RGB"
def test_render_modern_threads_frame_theme_through_to_resolve_theme(db_session, monkeypatch):
"""Same spy-on-resolve_theme approach as test_widgets_weather.py's
equivalent test -- confirms tasks.py's render() passes frame.theme
into html_render.build_tasks (proving the widget-level threading, not
re-testing ordered_dither_regions itself, which test_html_render.py
already covers)."""
from PIL import Image
from app import html_render, theme_tokens
calls = []
real_resolve = theme_tokens.resolve_theme
def _spy(theme_name, widget_kind, palette_rgb):
calls.append((theme_name, widget_kind))
return real_resolve(theme_name, widget_kind, palette_rgb)
monkeypatch.setattr(theme_tokens, "resolve_theme", _spy)
monkeypatch.setattr(html_render, "theme_tokens", theme_tokens)
monkeypatch.setattr(html_render, "render_html_to_image",
lambda html, target_w, target_h: Image.new("RGB", (target_w, target_h), (255, 255, 255)))
frame, widget = _make_widget(db_session, render_style="modern")
frame.theme = "slate"
monkeypatch.setattr(widgets.tasks, "get_or_refresh_tasks_for_widget", lambda db, frame, widget: [])
widgets.tasks.render(db_session, frame, widget, 300, 200)
assert ("slate", "tasks") in calls
+31
View File
@@ -187,3 +187,34 @@ def test_render_style_default_is_classic(db_session, monkeypatch):
frame, widget = _make_widget(db_session, mode="current")
cfg = db_session.get(WeatherWidgetConfig, widget.id)
assert cfg.render_style == "classic"
def test_render_modern_daily_threads_frame_theme_through_to_resolve_theme(db_session, monkeypatch):
"""widgets/weather.py's render() must pass frame.theme all the way
into html_render.build_daily's theme resolution -- spies on
theme_tokens.resolve_theme (still delegating to the real
implementation) rather than diffing final pixels, since the stubbed
render_html_to_image below never actually executes the template's CSS
(that's the whole point of stubbing out Chromium), so a theme's
accent color has nothing to visibly change in the fake screenshot."""
from app import theme_tokens
calls = []
real_resolve = theme_tokens.resolve_theme
def _spy(theme_name, widget_kind, palette_rgb):
calls.append((theme_name, widget_kind))
return real_resolve(theme_name, widget_kind, palette_rgb)
monkeypatch.setattr(theme_tokens, "resolve_theme", _spy)
monkeypatch.setattr(html_render, "theme_tokens", theme_tokens)
daily = {"2026-07-31": {"high": 75, "low": 55, "category": "clear"}}
monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data", lambda db, frame, widget: daily)
_stub_render_html_to_image(monkeypatch)
frame, widget = _make_widget(db_session, mode="daily", city_label="Denver", render_style="modern")
frame.theme = "terracotta"
widgets.weather.render(db_session, frame, widget, 200, 160)
assert ("terracotta", "weather") in calls