Roll out "modern" HTML/CSS render style to every widget except photos
Build and push server image / test (push) Successful in 43s
Build and push server image / build-and-push (push) Successful in 3m51s
Build and push server image / deploy (push) Failing after 1m57s

Extends weather's experimental Chromium+Jinja2 render style to battery,
text, tasks, static image, whiteboard, and calendar (all four view
modes -- agenda/today_tomorrow/week/month), and gives the photos widget
its own genuinely independent palette + dithering strength.

Photos: Frame.photo_palette_rgb/photo_dither_strength (mirroring the
existing palette_rgb/dither_strength), with a second "Photos
configuration" card in Advanced Configuration. widgets/photos.py's
render() quantizes itself against these before returning -- no
render_panel changes needed, since photos is the only widget that
genuinely needs a different reference palette and can carry that
itself, the same way modern-style widgets already self-dither via
ordered_dither.

Battery/text/tasks/static image/whiteboard: same render_style pattern
weather established (render_style column, html_render.py build
function, Jinja2 template, dialog toggle). Static image/whiteboard get
their first-ever visual chrome (a rounded-corner shadowed card,
shared framed_image.html.jinja) since classic draws them with zero
frame at all. Fixed the same "preview endpoint bypasses render_style"
bug weather originally shipped with, for tasks/static/whiteboard/
calendar's preview endpoints.

Calendar: own module (app/calendar_html_render.py, mirroring
calendar_render.py's separation from the simpler widgets) covering all
four view modes, not just agenda -- reuses calendar_render's own
private helpers so event colors/times/weather/month-grid math match
classic exactly. Found and fixed two real cross-day layout bugs along
the way: a per-day header height that varied based on whether that
specific day had a weather entry (misaligning where every other day's
event rows started across the week/month grid), and regular-weight
small text being fragile under Bayer ordered dithering (out-of-month
day numbers degraded into unrecognizable speckle) -- fixed by using
bold everywhere and de-emphasizing via size instead of weight/gray,
since gray text has the same dithering fragility this project's PIL
renderers already avoid for exactly this reason.

Migrations 32-38 (Frame's two new columns, then one render_style column
per widget config table). 452 tests passing, including new dispatch/
migration coverage per widget type and a dedicated photos test proving
photo_palette_rgb produces genuinely independent quantization from the
frame's main palette_rgb.
This commit is contained in:
2026-07-31 03:52:19 +00:00
parent c6dad191fb
commit e331f5e5a1
47 changed files with 1662 additions and 116 deletions
+29 -1
View File
@@ -178,6 +178,14 @@ class Frame(Base):
# 0.0-1.0, see image_pipeline._quantize -- 1.0 matches this project's
# original always-on full-strength Floyd-Steinberg dithering.
dither_strength: Mapped[float] = mapped_column(Float, default=1.0)
# Same shape as palette_rgb/dither_strength above, but scoped to only
# the photos widget (widgets/photos.py quantizes against these itself,
# before returning -- see its own docstring) -- lets a frame tune the
# rest of its widgets' palette/dithering (e.g. a "modern" HTML-
# rendered dashboard look) independently of what actually looks best
# for real photographs. NULL/1.0 = same defaults as the main fields.
photo_palette_rgb: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
photo_dither_strength: Mapped[float] = mapped_column(Float, default=1.0)
# -- calendar mode (see calendar_feed.py, calendar_render.py,
# routers/device.py's RENDERERS["calendar"]) --
@@ -542,6 +550,10 @@ class CalendarWidgetConfig(Base):
week_days: Mapped[int] = mapped_column(Integer, default=7)
week_layout: Mapped[str] = mapped_column(String, default="horizontal")
week_start_offset: Mapped[int] = mapped_column(Integer, default=0)
# classic (calendar_render.py) vs modern (app/calendar_html_render.py,
# agenda mode only so far -- see that module's docstring) -- see
# widgets/calendar.py's render().
render_style: Mapped[str] = mapped_column(String, default="classic")
class TaskWidgetConfig(Base):
@@ -580,6 +592,9 @@ class TaskWidgetConfig(Base):
# outstanding ones -- off by default, same "opt into more" posture
# as calendar_weather_enabled.
show_completed: Mapped[bool] = mapped_column(Boolean, default=False)
# classic (hand-drawn PIL, calendar_render._build_tasks) vs modern
# (app/html_render.py) -- see widgets/tasks.py's render().
render_style: Mapped[str] = mapped_column(String, default="classic")
class WhiteboardWidgetConfig(Base):
@@ -593,6 +608,10 @@ class WhiteboardWidgetConfig(Base):
url: Mapped[str] = mapped_column(String, default="")
checked_at: Mapped[float] = mapped_column(Float, default=0.0)
cached_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
# classic (no chrome, unchanged) vs modern (app/html_render.py's
# rounded-corner shadowed card) -- see widgets/whiteboard.py's
# render().
render_style: Mapped[str] = mapped_column(String, default="classic")
class WeatherWidgetConfig(Base):
@@ -668,6 +687,9 @@ class TextWidgetConfig(Base):
font_family: Mapped[str] = mapped_column(String, default="sans")
align: Mapped[str] = mapped_column(String, default="left") # "left" | "center" | "right"
background_color: Mapped[str] = mapped_column(String, default="#ffffff")
# classic (hand-drawn PIL) vs modern (app/html_render.py) -- see
# widgets/text.py's render()/render_preview_png().
render_style: Mapped[str] = mapped_column(String, default="classic")
class StaticWidgetConfig(Base):
@@ -689,6 +711,10 @@ class StaticWidgetConfig(Base):
# minus crop_faces -- no face detection for an uploaded image (see
# image_pipeline.STATIC_DISPLAY_MODES).
display_mode: Mapped[str] = mapped_column(String, default="crop_fill")
# classic (no chrome, unchanged) vs modern (app/html_render.py's
# rounded-corner shadowed card) -- see widgets/static_image.py's
# render().
render_style: Mapped[str] = mapped_column(String, default="classic")
class BatteryWidgetConfig(Base):
@@ -699,12 +725,14 @@ class BatteryWidgetConfig(Base):
of anything the widget itself fetches or the user authors. `mode`
"compact" is icon + percent only; "detailed" (default) adds the
routers.common.battery_estimate_s time-remaining estimate and the
last report's age."""
last report's age. `render_style` picks classic (hand-drawn PIL) vs
modern (app/html_render.py) -- see widgets/battery.py's render()."""
__tablename__ = "battery_widget_configs"
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
mode: Mapped[str] = mapped_column(String, default="detailed") # compact | detailed
render_style: Mapped[str] = mapped_column(String, default="classic") # classic | modern
# widget_type -> its per-type extension table, keyed by widget_id. Used