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
+8 -5
View File
@@ -876,6 +876,7 @@ def api_widget_preview_calendar(
img = calendar_html_render.build(
events, ccfg.view, ccfg.browse_offset, target_w, target_h, tz, ccfg.week_start, frame.palette_rgb,
weather_cities, ccfg.weather_units, ccfg.week_days, ccfg.week_layout, ccfg.week_start_offset,
frame.theme,
)
quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0)
png = _png_bytes(quantized)
@@ -911,7 +912,7 @@ def api_widget_preview_tasks(
from .. import html_render
target_w, target_h = logical_render_size(frame.orientation)
img = html_render.build_tasks(tasks, target_w, target_h, frame.palette_rgb, title)
img = html_render.build_tasks(tasks, target_w, target_h, frame.palette_rgb, title, frame.theme)
quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0)
png = _png_bytes(quantized)
else:
@@ -1170,7 +1171,7 @@ def api_widget_preview_weather(
png = html_render.render_weather_preview_png(
wcfg.mode, data, orientation=frame.orientation, palette_rgb=frame.palette_rgb, units=wcfg.units,
city_label=wcfg.city_label or "",
city_label=wcfg.city_label or "", theme_name=frame.theme,
)
else:
png = weather_render.render_weather_preview_png(
@@ -1230,7 +1231,7 @@ def api_widget_preview_static(
composed = compose_into(source, faces=None, target_w=target_w, target_h=target_h,
display_mode=scfg.display_mode)
fitted = _enhance(composed, frame.color_boost, frame.contrast_boost)
img = html_render.build_framed_image(fitted, target_w, target_h, frame.palette_rgb)
img = html_render.build_framed_image(fitted, target_w, target_h, frame.palette_rgb, frame.theme, "static")
quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0)
png = _png_bytes(quantized)
else:
@@ -1257,7 +1258,8 @@ def api_widget_preview_text(
xcfg = db.get(TextWidgetConfig, widget.id)
if not has_text(xcfg.content):
raise HTTPException(400, "No text authored on this widget yet")
png = text_widget.render_preview_png(xcfg, orientation=frame.orientation, palette_rgb=frame.palette_rgb)
png = text_widget.render_preview_png(xcfg, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
theme_name=frame.theme)
return Response(content=png, media_type="image/png")
@@ -1379,7 +1381,8 @@ def api_widget_preview_whiteboard(
target_w, target_h = logical_render_size(frame.orientation)
composed = compose_into(source, faces=None, target_w=target_w, target_h=target_h,
display_mode="letterbox")
img = html_render.build_framed_image(composed, target_w, target_h, frame.palette_rgb)
img = html_render.build_framed_image(composed, target_w, target_h, frame.palette_rgb, frame.theme,
"whiteboard")
quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0)
png = _png_bytes(quantized)
else: