From e331f5e5a146c6ebe371ebc9a1cb54f4b3fc4d97 Mon Sep 17 00:00:00 2001
From: Thomas Faour
Date: Fri, 31 Jul 2026 03:52:19 +0000
Subject: [PATCH] Roll out "modern" HTML/CSS render style to every widget
except photos
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.
---
docs/widgets.md | 90 ++++--
server/app/calendar_html_render.py | 291 ++++++++++++++++++
server/app/html_render.py | 213 ++++++++++++-
server/app/migration.py | 73 +++++
server/app/models.py | 30 +-
server/app/routers/api_frames.py | 14 +
server/app/routers/api_layouts.py | 12 +-
server/app/routers/api_widgets.py | 112 +++++--
server/app/routers/frame_pages.py | 1 +
server/app/static/frame_config.js | 81 +++--
server/app/static/widget_dialog_battery.js | 1 +
server/app/static/widget_dialog_calendar.js | 1 +
server/app/static/widget_dialog_static.js | 1 +
server/app/static/widget_dialog_tasks.js | 1 +
server/app/static/widget_dialog_text.js | 1 +
server/app/static/widget_dialog_whiteboard.js | 19 ++
.../app/templates/_widget_dialog_battery.html | 6 +
.../templates/_widget_dialog_calendar.html | 6 +
.../app/templates/_widget_dialog_static.html | 6 +
.../app/templates/_widget_dialog_tasks.html | 6 +
server/app/templates/_widget_dialog_text.html | 6 +
.../templates/_widget_dialog_whiteboard.html | 13 +
server/app/templates/frame_config.html | 50 ++-
.../_calendar_day_section.html.jinja | 32 ++
.../templates/widget_html/battery.html.jinja | 53 ++++
.../widget_html/calendar_agenda.html.jinja | 36 +++
.../widget_html/calendar_month.html.jinja | 65 ++++
.../calendar_today_tomorrow.html.jinja | 39 +++
.../calendar_week_horizontal.html.jinja | 60 ++++
.../widget_html/framed_image.html.jinja | 27 ++
.../templates/widget_html/tasks.html.jinja | 62 ++++
.../app/templates/widget_html/text.html.jinja | 35 +++
server/app/widgets/battery.py | 51 ++-
server/app/widgets/calendar.py | 14 +
server/app/widgets/photos.py | 23 +-
server/app/widgets/static_image.py | 10 +-
server/app/widgets/tasks.py | 10 +-
server/app/widgets/text.py | 24 +-
server/app/widgets/whiteboard.py | 13 +-
server/tests/test_migrations.py | 10 +
server/tests/test_widgets_battery.py | 24 +-
server/tests/test_widgets_calendar.py | 45 +++
server/tests/test_widgets_photos.py | 29 ++
server/tests/test_widgets_static.py | 15 +
server/tests/test_widgets_tasks.py | 24 ++
server/tests/test_widgets_text.py | 21 ++
server/tests/test_widgets_whiteboard.py | 22 +-
47 files changed, 1662 insertions(+), 116 deletions(-)
create mode 100644 server/app/calendar_html_render.py
create mode 100644 server/app/templates/widget_html/_calendar_day_section.html.jinja
create mode 100644 server/app/templates/widget_html/battery.html.jinja
create mode 100644 server/app/templates/widget_html/calendar_agenda.html.jinja
create mode 100644 server/app/templates/widget_html/calendar_month.html.jinja
create mode 100644 server/app/templates/widget_html/calendar_today_tomorrow.html.jinja
create mode 100644 server/app/templates/widget_html/calendar_week_horizontal.html.jinja
create mode 100644 server/app/templates/widget_html/framed_image.html.jinja
create mode 100644 server/app/templates/widget_html/tasks.html.jinja
create mode 100644 server/app/templates/widget_html/text.html.jinja
diff --git a/docs/widgets.md b/docs/widgets.md
index 15939e1..9c48461 100644
--- a/docs/widgets.md
+++ b/docs/widgets.md
@@ -148,6 +148,70 @@ grid footprint, rather than continuously scaling constants tuned for a
full ~800x480 canvas -- falls back to agenda view if a widget is too small
for month view to stay legible.
+### "Modern" render style (experimental)
+
+Every widget type except photos has a `render_style` column (`"classic"`
+default | `"modern"`) that swaps its hand-drawn PIL primitives for an
+HTML/CSS render: a Jinja2 template (`app/templates/widget_html/`) drawn
+through a persistent headless-Chromium browser (`app/html_render.py`,
+Playwright) instead of `ImageDraw` -- gradients, shadows, and soft icon
+shading PIL can't easily do. Calendar's own modern-style builders (all
+four view modes) live in `app/calendar_html_render.py` rather than
+`html_render.py` itself, mirroring `calendar_render.py`'s own separation
+from the simpler widget types.
+
+Every modern-style builder runs its own `ordered_dither` (Bayer/ordered,
+not Floyd-Steinberg) before returning, committing the widget to exact
+palette colors *before* compositing -- safe to mix with photo/other
+classic-rendered widgets on the same frame without a Floyd-Steinberg
+seam at the boundary, because ordered dithering has no cross-pixel error
+term the way Floyd-Steinberg's diffusion does (see `html_render.py`'s
+module docstring). No `Frame`-level dithering setting was needed to make
+this work.
+
+Not offered for the **photos** widget -- a real photograph isn't a
+synthesized dashboard card, and photos has a different concern instead:
+its own independent palette/dithering strength (`Frame.photo_palette_rgb`
+/ `photo_dither_strength`, a second "Photos configuration" card in
+Advanced Configuration, separate from the main `palette_rgb`/
+`dither_strength` every other widget uses). `widgets/photos.py`'s
+`render()` quantizes itself against these before returning, so a frame
+can tune the rest of its widgets' look (e.g. a calibrated palette for
+modern-style dashboard widgets) independently of what actually looks
+best for real photographs, with no `render_panel` changes needed --
+see that module's own docstring for the one small, accepted edge case
+(a border on a photos widget whose palette genuinely diverges from the
+frame's main one).
+
+Playwright/Chromium is a real, heavyweight runtime dependency imported
+lazily only when a widget actually uses modern style. Its browser binary
+is fetched by `start.sh` at container startup rather than baked into the
+image (see `server/Dockerfile`'s own comment) -- a single ~181MB
+`chrome-headless-shell` binary can't be split across Docker layers the
+way this project's pip/npm installs were, and confirmed-failed to push
+to the registry as a build-time layer; cached on the `/data` volume
+(`PLAYWRIGHT_BROWSERS_PATH`) so only the very first boot on a fresh
+volume actually downloads it. Still real-panel-unverified -- treat every
+"modern" style as experimental regardless of deploy status.
+
+Per-widget-type notes:
+
+- **weather**: `current`/`daily` modes only -- `hourly`/`multi_city`
+ always render classic regardless of this setting (see the Weather
+ widget section below).
+- **calendar**: all four view modes (agenda/today_tomorrow/week/month)
+ have a modern builder -- the only widget type with full modern-style
+ coverage from the start, rather than a partial rollout like weather's.
+ Month view's "falls back to agenda below a size threshold" behavior
+ (`_month_view_fits`) is honored identically in both styles.
+- **battery/text/tasks**: full coverage (both battery modes; text reuses
+ its own `_fit()` shrink-to-fit sizing logic, only the drawing differs).
+- **static image/whiteboard**: modern style is the *first* visual chrome
+ either widget type has ever had (classic draws the image with zero
+ frame/card at all) -- a rounded-corner, shadowed card
+ (`framed_image.html.jinja`, shared between the two) wrapping the
+ already-composed image.
+
## Button actions
Each physical button (NEXT/BACK) runs the `(widget, action)` binding of
@@ -270,29 +334,9 @@ modes (`WeatherWidgetConfig.mode`, switchable in the widget's dialog like
widget's whole content instead of a strip above an agenda day.
**Render style** (`WeatherWidgetConfig.render_style`, `"classic"` default
-| `"modern"`, experimental): `current`/`daily` only -- `hourly`/
-`multi_city` always render classic regardless of this setting. `modern`
-draws the widget as an HTML/CSS card (Jinja2 templates under
-`app/templates/widget_html/`) through a persistent headless Chromium
-browser (`app/html_render.py`, Playwright) instead of `app/weather_render.
-py`'s hand-drawn PIL primitives -- gradients/shadows/soft icon shading
-PIL can't easily do. Its own `ordered_dither` (Bayer/ordered, not Floyd-
-Steinberg) commits the rendered widget to exact palette colors *before*
-compositing, so it's safe to mix with photo/other classic-rendered
-widgets on the same frame without a Floyd-Steinberg seam at the boundary
-(see that module's docstring for why ordered dithering doesn't have this
-problem and Floyd-Steinberg does) -- no `Frame`-level dithering setting
-was needed. Playwright/Chromium is a real, heavyweight runtime dependency
-imported lazily only when a weather widget actually uses this style.
-Its browser binary is fetched by `start.sh` at container startup rather
-than baked into the image (see `server/Dockerfile`'s own comment) --
-a single ~181MB `chrome-headless-shell` binary can't be split across
-Docker layers the way this project's pip/npm installs were, and
-confirmed-failed to push to the registry as a build-time layer; cached
-on the `/data` volume (`PLAYWRIGHT_BROWSERS_PATH`) so only the very
-first boot on a fresh volume actually downloads it. Still real-panel-
-unverified (see this widget's own render_style rollout notes/PR) --
-treat "modern" style as experimental regardless of deploy status.
+| `"modern"`, experimental) -- see "Modern render style" above; weather's
+own modern coverage is `current`/`daily` only, `hourly`/`multi_city`
+always render classic regardless of this setting.
`current`/`hourly`/`daily` share one configured location
(`city_label`/`city_latitude`/`city_longitude`, set via `POST .../
diff --git a/server/app/calendar_html_render.py b/server/app/calendar_html_render.py
new file mode 100644
index 0000000..0a33a0a
--- /dev/null
+++ b/server/app/calendar_html_render.py
@@ -0,0 +1,291 @@
+"""Calendar widget's "modern" render style -- all four view modes
+(agenda/today_tomorrow/week/month), mirroring calendar_render.py's own
+_build dispatch shape exactly so app/widgets/calendar.py and the
+calendar preview endpoint can call either module identically. Kept in
+its own module rather than joining app/html_render.py's other build_*
+functions, mirroring calendar_render.py's own separation from the
+simpler widgets (calendar is the one case where html_render.py growing
+a 5th unrelated builder starts to hurt readability).
+
+Reuses calendar_render's own private helpers (_events_on_day/_event_
+colors/_event_start/_fmt_time/_weather_for_day/_month_view_fits/
+_add_months) so a modern-style view's event list/colors/times/weather/
+month-grid math match the classic renderer's data exactly -- only the
+drawing differs, same relationship weather's build_current/build_daily
+have with weather_render.py."""
+
+from __future__ import annotations
+
+import calendar as calendar_module
+from datetime import date, datetime, timedelta
+from zoneinfo import ZoneInfo
+
+from PIL import Image
+
+from . import html_render, panel_style
+from .calendar_render import (
+ MARGIN,
+ WEEKDAY_NAMES,
+ _add_months,
+ _event_colors,
+ _event_start,
+ _events_on_day,
+ _fmt_time,
+ _month_view_fits,
+ _weather_for_day,
+)
+
+
+def _weather_row(weather_cities, day, units) -> list[dict]:
+ entries = _weather_for_day(weather_cities, day)
+ return [
+ {"emoji": html_render.CATEGORY_EMOJI.get(e["category"], ""), "high": round(e["high"]), "low": round(e["low"])}
+ for e in entries
+ ]
+
+
+def _day_section_data(day: date, events: list[dict], tz: ZoneInfo, palette_rgb, weather_cities,
+ weather_units: str, owners_seen: list[str], rows_avail_h: int, row_h: int) -> dict:
+ """One day's {header, weather_entries, rows, more_count} -- shared by
+ build_agenda/build_today_tomorrow/build_week's vertical layout, same
+ reuse relationship calendar_render._draw_agenda_day has with
+ _build_agenda/_build_today_tomorrow. `rows_avail_h` is the *rows*
+ area's own pixel budget only -- the caller has already reserved a
+ separate, uniform header_h (which is where weather actually renders,
+ see the day-header macro) for every section, so this function
+ doesn't need to account for weather space itself."""
+ header = day.strftime("%A, %B ") + str(day.day)
+ weather_entries = _weather_row(weather_cities, day, weather_units)
+ max_rows = max(0, rows_avail_h // row_h)
+
+ day_events = _events_on_day(events, day, tz)
+ rows = []
+ for event in day_events[:max_rows]:
+ colors = _event_colors(event, owners_seen, palette_rgb)
+ time_str = "All day" if event["all_day"] else _fmt_time(_event_start(event, tz))
+ rows.append({"colors": [html_render._rgb_to_hex(c) for c in colors], "time": time_str,
+ "summary": event["summary"]})
+ return {"header": header, "weather_entries": weather_entries, "rows": rows,
+ "more_count": max(0, len(day_events) - max_rows)}
+
+
+def build_agenda(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
+ palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
+ weather_units: str = "fahrenheit") -> Image.Image:
+ """HTML/CSS-rendered analogue of calendar_render._build_agenda."""
+ day = datetime.now(tz).date() + timedelta(days=browse_offset)
+ title_size = max(14, min(target_w, target_h) // 12)
+ body_size = max(11, min(target_w, target_h) // 20)
+ weather_size = max(10, body_size - 2)
+ row_h = body_size + 14
+ unit_suffix = "F" if weather_units == "fahrenheit" else "C"
+
+ # Single day -- no cross-section alignment concern, so header_h can
+ # simply reflect whether THIS day actually has weather (unlike
+ # build_today_tomorrow/build_week's vertical layout, which must
+ # reserve the same header_h for every stacked section regardless).
+ has_weather = bool(_weather_row(weather_cities, day, weather_units))
+ header_h = title_size + 24 + ((weather_size + 12) if has_weather else 0)
+
+ owners_seen: list[str] = []
+ data = _day_section_data(day, events, tz, palette_rgb, weather_cities, weather_units, owners_seen,
+ target_h - header_h - MARGIN, row_h)
+
+ accent = html_render._rgb_to_hex(panel_style.ink(palette_rgb, panel_style.THEME_CALENDAR))
+ template = html_render._jinja_env.get_template("calendar_agenda.html.jinja")
+ html = template.render(
+ w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=panel_style.CARD_RADIUS,
+ font_dir=str(html_render._FONT_DIR), header=data["header"], title_size=title_size, header_h=header_h,
+ accent_start=accent, accent_end=accent, weather_entries=data["weather_entries"],
+ weather_size=weather_size, unit_suffix=unit_suffix, rows=data["rows"],
+ more_count=data["more_count"], row_h=row_h, body_size=body_size,
+ )
+ rendered = html_render.render_html_to_image(html, target_w, target_h)
+ return html_render.ordered_dither(rendered, palette_rgb)
+
+
+def build_today_tomorrow(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
+ palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
+ weather_units: str = "fahrenheit") -> Image.Image:
+ """HTML/CSS-rendered analogue of calendar_render._build_today_tomorrow
+ -- two day-sections stacked (see _day_section_data)."""
+ start_day = datetime.now(tz).date() + timedelta(days=browse_offset)
+ section_h = target_h // 2
+ title_size = max(13, section_h // 8)
+ body_size = max(10, min(target_w, target_h) // 26)
+ weather_size = max(9, body_size - 2)
+ row_h = body_size + 12
+ unit_suffix = "F" if weather_units == "fahrenheit" else "C"
+
+ day_dates = [start_day + timedelta(days=i) for i in range(2)]
+ # Uniform across both stacked sections regardless of which day(s)
+ # actually have weather -- see _day_section_data's own docstring for
+ # why a per-day header height misaligns where rows start.
+ any_weather = any(_weather_row(weather_cities, d, weather_units) for d in day_dates)
+ header_h = title_size + 16 + ((weather_size + 10) if any_weather else 0)
+
+ owners_seen: list[str] = []
+ days = [
+ _day_section_data(d, events, tz, palette_rgb, weather_cities, weather_units, owners_seen,
+ section_h - header_h, row_h)
+ for d in day_dates
+ ]
+
+ accent = html_render._rgb_to_hex(panel_style.ink(palette_rgb, panel_style.THEME_CALENDAR))
+ template = html_render._jinja_env.get_template("calendar_today_tomorrow.html.jinja")
+ html = template.render(
+ w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=panel_style.CARD_RADIUS,
+ font_dir=str(html_render._FONT_DIR), days=days, title_size=title_size, header_h=header_h,
+ accent_start=accent, accent_end=accent, weather_size=weather_size, unit_suffix=unit_suffix,
+ row_h=row_h, body_size=body_size,
+ )
+ rendered = html_render.render_html_to_image(html, target_w, target_h)
+ return html_render.ordered_dither(rendered, palette_rgb)
+
+
+def build_week(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
+ week_start: int, palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
+ weather_units: str = "fahrenheit", days: int = 7, layout: str = "horizontal",
+ start_offset: int = 0) -> Image.Image:
+ """HTML/CSS-rendered analogue of calendar_render._build_week -- both
+ the vertical (stacked day-sections, reusing build_today_tomorrow's
+ template with an arbitrary day count) and horizontal (side-by-side
+ columns) layouts."""
+ today = datetime.now(tz).date()
+ if days == 7:
+ days_since_start = (today.weekday() - week_start) % 7
+ week_first_day = today - timedelta(days=days_since_start) + timedelta(days=days * browse_offset)
+ else:
+ week_first_day = today + timedelta(days=start_offset) + timedelta(days=days * browse_offset)
+ unit_suffix = "F" if weather_units == "fahrenheit" else "C"
+ accent = html_render._rgb_to_hex(panel_style.ink(palette_rgb, panel_style.THEME_CALENDAR))
+ owners_seen: list[str] = []
+
+ if layout == "vertical":
+ section_h = target_h // days
+ title_size = max(11, min(20, section_h // 6))
+ body_size = max(9, min(target_w, target_h) // (18 + days))
+ weather_size = max(8, body_size - 2)
+ row_h = body_size + 10
+ day_dates = [week_first_day + timedelta(days=i) for i in range(days)]
+ # Uniform across all `days` stacked sections -- see
+ # _day_section_data's own docstring for why a per-day header
+ # height misaligns where rows start.
+ any_weather = any(_weather_row(weather_cities, d, weather_units) for d in day_dates)
+ header_h = title_size + 12 + ((weather_size + 8) if any_weather else 0)
+ day_sections = [
+ _day_section_data(d, events, tz, palette_rgb, weather_cities, weather_units, owners_seen,
+ section_h - header_h, row_h)
+ for d in day_dates
+ ]
+ template = html_render._jinja_env.get_template("calendar_today_tomorrow.html.jinja")
+ html = template.render(
+ w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=panel_style.CARD_RADIUS,
+ font_dir=str(html_render._FONT_DIR), days=day_sections, title_size=title_size, header_h=header_h,
+ accent_start=accent, accent_end=accent, weather_size=weather_size, unit_suffix=unit_suffix,
+ row_h=row_h, body_size=body_size,
+ )
+ rendered = html_render.render_html_to_image(html, target_w, target_h)
+ return html_render.ordered_dither(rendered, palette_rgb)
+
+ header_size = max(10, min(16, (target_w // days) // 6))
+ chip_size = max(9, header_size - 3)
+ weather_size = max(8, chip_size - 1)
+ col_w = max(1, (target_w - panel_style.GUTTER * 2) // days)
+ row_h = chip_size + 8
+ # Reserve weather-line room in every column's header uniformly
+ # (whether or not THIS specific day has a cached forecast) -- a
+ # per-column height that depends on that day's own data would
+ # misalign where each column's event rows start across the week
+ # grid the moment any single day lacks a forecast entry.
+ header_h = header_size + 22 + (weather_size + 6 if weather_cities else 0)
+ max_rows = max(0, (target_h - panel_style.GUTTER * 2 - header_h) // row_h)
+
+ cols = []
+ for i in range(days):
+ day = week_first_day + timedelta(days=i)
+ label = day.strftime("%a %-d") if day != today else f"★ {day.strftime('%a %-d')}"
+ weather_entries = _weather_row(weather_cities, day, weather_units)
+ day_events = _events_on_day(events, day, tz)
+ rows = []
+ for event in day_events[:max_rows]:
+ color = html_render._rgb_to_hex(_event_colors(event, owners_seen, palette_rgb)[0])
+ summary = event["summary"] if event["all_day"] else f"{_fmt_time(_event_start(event, tz))[:-3]} {event['summary']}"
+ rows.append({"color": color, "summary": summary})
+ cols.append({
+ "label": label, "weather": weather_entries[0] if weather_entries else None,
+ "rows": rows, "more_count": max(0, len(day_events) - max_rows),
+ })
+
+ template = html_render._jinja_env.get_template("calendar_week_horizontal.html.jinja")
+ html = template.render(
+ w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=panel_style.CARD_RADIUS,
+ font_dir=str(html_render._FONT_DIR), cols=cols, header_size=header_size, chip_size=chip_size,
+ header_h=header_h, weather_size=weather_size, unit_suffix=unit_suffix, accent_start=accent, accent_end=accent,
+ )
+ rendered = html_render.render_html_to_image(html, target_w, target_h)
+ return html_render.ordered_dither(rendered, palette_rgb)
+
+
+def build_month(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
+ week_start: int, palette_rgb: list | None = None) -> Image.Image:
+ """HTML/CSS-rendered analogue of calendar_render._build_month --
+ density dots per day, not literal event text, same reasoning as the
+ classic renderer (real text at typical month-cell size is close to
+ unreadable on a 6-color dithered e-ink panel)."""
+ today = datetime.now(tz).date()
+ target_month = _add_months(date(today.year, today.month, 1), browse_offset)
+ weeks_dates = list(
+ calendar_module.Calendar(firstweekday=week_start).monthdatescalendar(target_month.year, target_month.month)
+ )
+ day_names = [n[:3] for n in (WEEKDAY_NAMES[week_start:] + WEEKDAY_NAMES[:week_start])]
+
+ header_size = max(11, min(16, target_h // 30))
+ day_size = max(10, min(15, target_w // 55))
+ dot_size = max(4, day_size // 2)
+ accent = html_render._rgb_to_hex(panel_style.ink(palette_rgb, panel_style.THEME_CALENDAR))
+
+ owners_seen: list[str] = []
+ weeks = []
+ for week in weeks_dates:
+ row = []
+ for day in week:
+ day_events = _events_on_day(events, day, tz)
+ dots = [html_render._rgb_to_hex(_event_colors(e, owners_seen, palette_rgb)[0]) for e in day_events[:4]]
+ row.append({
+ "day_num": day.day, "in_month": day.month == target_month.month,
+ "is_today": day == today, "dots": dots, "more_count": max(0, len(day_events) - 4),
+ })
+ weeks.append(row)
+
+ template = html_render._jinja_env.get_template("calendar_month.html.jinja")
+ html = template.render(
+ w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=panel_style.CARD_RADIUS,
+ font_dir=str(html_render._FONT_DIR), day_names=day_names, weeks=weeks,
+ header_size=header_size, day_size=day_size, dot_size=dot_size, accent_start=accent,
+ )
+ rendered = html_render.render_html_to_image(html, target_w, target_h)
+ return html_render.ordered_dither(rendered, palette_rgb)
+
+
+def build(events: list[dict], view: str, browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
+ week_start: int, palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
+ weather_units: str = "fahrenheit", week_days: int = 7, week_layout: str = "horizontal",
+ week_start_offset: int = 0) -> Image.Image:
+ """Dispatches to the right build_* -- mirrors calendar_render._build's
+ exact "month falls back to agenda when it doesn't fit" resolution, so
+ a narrow month-mode widget set to modern style still gets a sensible
+ modern view instead of erroring or silently reverting to classic."""
+ effective_view = view
+ if view == "month" and not _month_view_fits(target_w, target_h):
+ effective_view = "agenda"
+
+ if effective_view == "agenda":
+ return build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb, weather_cities, weather_units)
+ if effective_view == "today_tomorrow":
+ return build_today_tomorrow(events, browse_offset, target_w, target_h, tz, palette_rgb, weather_cities,
+ weather_units)
+ if effective_view == "week":
+ return build_week(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb, weather_cities,
+ weather_units, week_days, week_layout, week_start_offset)
+ return build_month(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb)
diff --git a/server/app/html_render.py b/server/app/html_render.py
index 7c5f9e3..2ebe189 100644
--- a/server/app/html_render.py
+++ b/server/app/html_render.py
@@ -1,9 +1,19 @@
-"""Experimental "modern" weather widget render style: Jinja2 + a
-persistent headless Chromium browser (Playwright) instead of the hand-
-drawn PIL primitives in weather_render.py -- see docs/widgets.md and the
-`html-widget-render` branch's PR description for the design rationale
-(gradients/shadows/soft shading that PIL can't easily do, at the cost of
-a real browser-process dependency).
+"""Experimental "modern" render style, offered as an opt-in alternative
+to several widget types' hand-drawn PIL primitives: Jinja2 + a
+persistent headless Chromium browser (Playwright) -- see docs/widgets.md
+for the design rationale (gradients/shadows/soft shading that PIL can't
+easily do, at the cost of a real browser-process dependency). Everything
+in this module is shared infrastructure (the persistent browser, ordered
+dithering) plus one `build_*` function per widget type that has a
+modern-style builder -- battery/text/tasks/static image/whiteboard live
+here directly (mirroring how those widget types are themselves "inlined"
+in their own widget.py rather than getting a dedicated render module);
+calendar's (agenda mode only, see calendar_html_render.py) is the one
+exception, kept separate the same reason calendar_render.py itself is
+its own 800+ line file rather than joining battery/text/tasks inline.
+Not offered for the photos widget -- a real photograph isn't a
+synthesized dashboard card, and photos has its own separate palette/
+dithering concern instead (see widgets/photos.py).
Two things this module owns that nothing else in the codebase needed
before:
@@ -57,7 +67,7 @@ from jinja2 import Environment, FileSystemLoader, select_autoescape
from PIL import Image
from . import panel_style
-from .image_pipeline import DEFAULT_PALETTE_RGB
+from .image_pipeline import DEFAULT_PALETTE_RGB, hex_to_rgb
_TEMPLATE_DIR = Path(__file__).resolve().parent / "templates" / "widget_html"
_FONT_DIR = Path(__file__).resolve().parent / "fonts"
@@ -87,6 +97,17 @@ ACCENT_START = "#1c4fd6"
ACCENT_END = "#6fa8ff"
+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:
+ """A darker shade of `rgb` for a CSS gradient's second stop -- purely
+ decorative (ordered_dither commits everything to exact palette colors
+ regardless), same reasoning ACCENT_START/END's own comment gives."""
+ return _rgb_to_hex(tuple(max(0, round(c * factor)) for c in rgb))
+
+
# --- Persistent background browser -------------------------------------
_loop: asyncio.AbstractEventLoop | None = None
@@ -325,3 +346,181 @@ def render_weather_preview_png(mode: str, data, orientation: str, palette_rgb: l
img = build(mode, data, target_w, target_h, palette_rgb, units, city_label)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
return _png_bytes(quantized)
+
+
+# --- Battery "modern" style ------------------------------------------------
+
+def _battery_sizes(target_w: int, target_h: int, num_lines: int, scale: float) -> dict:
+ base = min(target_w, target_h)
+ icon_h = max(16, int(base // 3 * scale))
+ pct_size = max(14, int(base // 3 * scale))
+ line_size = max(9, int(base // 9 * scale))
+ gap = 8
+ total = icon_h + gap + pct_size + num_lines * (line_size + gap)
+ return {"icon_h": icon_h, "pct_size": pct_size, "line_size": line_size, "total": total}
+
+
+def build_battery(percent: int, lines: list[str], target_w: int, target_h: int,
+ palette_rgb: list | None = None) -> Image.Image:
+ """HTML/CSS-rendered analogue of widgets/battery.py's classic PIL
+ drawing -- same icon+percent+caption-lines shape, `lines` already
+ resolved by the caller (widgets/battery.py's _lines_for(), shared
+ with the classic path so the estimate/age formatting only lives in
+ one place). Returns an already-palette-exact RGB image (see
+ ordered_dither).
+
+ Shrinks icon/text sizes together (in 0.05 steps down to 0.3x) until
+ the whole stack actually fits the available height -- the classic
+ PIL path solves the same "icon + percent + 0-2 lines in a fixed box"
+ problem by truncating lines that don't fit; scaling down instead
+ keeps every resolved line visible, which reads better for a widget
+ that only ever has at most 2 short caption lines to begin with."""
+ avail_h = target_h - panel_style.GUTTER * 2
+ num_lines = len(lines)
+ scale = 1.0
+ sizes = _battery_sizes(target_w, target_h, num_lines, scale)
+ while sizes["total"] > avail_h and scale > 0.3:
+ scale -= 0.05
+ sizes = _battery_sizes(target_w, target_h, num_lines, scale)
+ # Extreme case (a 1x1-grid-cell-sized widget in "detailed" mode):
+ # scale bottomed out and it still doesn't fit -- drop the least
+ # important line rather than render overlapping text, same
+ # graceful-degradation idiom the classic PIL path's own
+ # `if y + small_font_size > ...: break` truncation already uses.
+ while sizes["total"] > avail_h and lines:
+ lines = lines[:-1]
+ num_lines = len(lines)
+ sizes = _battery_sizes(target_w, target_h, num_lines, scale)
+
+ fill_color = panel_style.battery_fill_color(percent, palette_rgb)
+ icon_h = sizes["icon_h"]
+ icon_w = int(icon_h * 1.8)
+ stroke = max(2, icon_h // 12)
+ nub_w = max(3, icon_w // 10)
+ template = _jinja_env.get_template("battery.html.jinja")
+ html = template.render(
+ w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=panel_style.CARD_RADIUS,
+ font_dir=str(_FONT_DIR), percent=percent, lines=lines,
+ icon_w=icon_w, icon_h=icon_h, icon_radius=icon_h // 6, stroke=stroke,
+ fill_pct=max(0, min(100, percent)), fill_radius=max(0, icon_h // 6 - stroke),
+ fill_color=_rgb_to_hex(fill_color), fill_color_dark=_darken_hex(fill_color),
+ nub_w=nub_w, nub_h=icon_h // 2, nub_radius=max(1, nub_w // 3),
+ pct_size=sizes["pct_size"], line_size=sizes["line_size"],
+ )
+ rendered = render_html_to_image(html, target_w, target_h)
+ return ordered_dither(rendered, palette_rgb)
+
+
+# --- Text "modern" style ---------------------------------------------------
+
+def build_text(cfg, target_w: int, target_h: int, palette_rgb: list | None = None) -> Image.Image:
+ """HTML/CSS-rendered analogue of widgets/text.py's classic PIL
+ drawing. Reuses widgets/text.py's own `_fit()` for the one piece of
+ logic CSS has no native equivalent for (shrink-to-fit sizing) --
+ `_fit` measures against the exact same vendored font files via PIL,
+ so the resolved size is a real fit decision, not a guess -- but lets
+ the browser do its own text wrapping/line-breaking at that size
+ (paragraphs/runs passed through directly as HTML) rather than
+ replicating `_fit`'s own word-wrapped line list; the two wrapping
+ algorithms can disagree on exact break points, an acceptable
+ approximation since this style only needs to look good and fit
+ reasonably, not be pixel-identical to classic. Returns an already-
+ palette-exact RGB image (see ordered_dither)."""
+ from PIL import ImageDraw
+
+ from . import widgets # local import: heavy-ish, and only "modern" text needs it
+
+ text_widget = widgets.text
+ bg_rgb = (hex_to_rgb(cfg.background_color) if cfg.background_color else None) or (255, 255, 255)
+ family = cfg.font_family if cfg.font_family in text_widget.FONT_FAMILIES else text_widget.DEFAULT_FONT_FAMILY
+ paragraphs = cfg.content or []
+ margin = text_widget.MARGIN
+ max_width = max(10, target_w - 2 * margin)
+ max_height = max(10, target_h - 2 * margin)
+
+ measure_img = Image.new("RGB", (1, 1))
+ draw = ImageDraw.Draw(measure_img)
+ size, _lines = text_widget._fit(draw, paragraphs, family, cfg.font_size, max_width, max_height)
+
+ files = text_widget._FONT_FILES.get(family) or text_widget._FONT_FILES[text_widget.DEFAULT_FONT_FAMILY]
+ align = cfg.align if cfg.align in ("left", "center", "right") else "left"
+ template = _jinja_env.get_template("text.html.jinja")
+ html = template.render(
+ w=target_w, h=target_h, margin=margin, bg_color=_rgb_to_hex(bg_rgb),
+ size=size, line_height=text_widget.LINE_HEIGHT_FACTOR, align=align,
+ font_regular=str(_FONT_DIR / files[(False, False)]), font_bold=str(_FONT_DIR / files[(True, False)]),
+ font_italic=str(_FONT_DIR / files[(False, True)]), font_bold_italic=str(_FONT_DIR / files[(True, True)]),
+ paragraphs=paragraphs,
+ )
+ rendered = render_html_to_image(html, target_w, target_h)
+ return ordered_dither(rendered, palette_rgb)
+
+
+# --- Tasks "modern" style ---------------------------------------------------
+
+def build_tasks(tasks: list[dict], target_w: int, target_h: int, palette_rgb: list | None = None,
+ title: str = "Tasks") -> Image.Image:
+ """HTML/CSS-rendered analogue of calendar_render._build_tasks --
+ same header+checklist shape. Reuses calendar_render's own
+ _event_colors/_fmt_task_due (the exact color-dedup/due-date-format
+ logic the classic renderer uses) so a task's color chip/due string
+ matches classic style exactly; only the drawing differs. Returns an
+ already-palette-exact RGB image (see ordered_dither)."""
+ from .calendar_render import _event_colors, _fmt_task_due
+
+ header_h = max(28, min(target_w, target_h) // 8)
+ body_size = max(11, min(target_w, target_h) // 20)
+ row_h = body_size + 14
+ box_size = max(10, body_size - 4)
+ avail_h = target_h - header_h - 16
+ max_rows = max(0, avail_h // row_h)
+
+ owners_seen: list[str] = []
+ rows = []
+ for task in tasks[:max_rows]:
+ colors = _event_colors(task, owners_seen, palette_rgb)
+ done = task.get("completed_at") is not None
+ due = None if done else (_fmt_task_due(task.get("due")) or None)
+ rows.append({
+ "colors": [_rgb_to_hex(c) for c in colors],
+ "done": done,
+ "due": due,
+ "summary": task["summary"],
+ })
+ more_count = max(0, len(tasks) - max_rows)
+
+ template = _jinja_env.get_template("tasks.html.jinja")
+ html = template.render(
+ w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=panel_style.CARD_RADIUS,
+ font_dir=str(_FONT_DIR), title=title, header_h=header_h, title_size=max(14, header_h - 12),
+ accent_start=_rgb_to_hex(panel_style.ink(palette_rgb, panel_style.THEME_TASKS)),
+ accent_end=_rgb_to_hex(panel_style.ink(palette_rgb, panel_style.THEME_TASKS)),
+ rows=rows, more_count=more_count, row_h=row_h, box_size=box_size, body_size=body_size,
+ )
+ rendered = render_html_to_image(html, target_w, target_h)
+ return ordered_dither(rendered, palette_rgb)
+
+
+# --- Static image / whiteboard "modern" style (shared) ---------------------
+
+def build_framed_image(composed: Image.Image, target_w: int, target_h: int,
+ palette_rgb: list | None = None) -> Image.Image:
+ """Wraps an already-composed image (static_image.py/whiteboard.py's
+ own compose_into() output, exactly target_w x target_h, already
+ cropped/fit per that widget's own display_mode) in a rounded-corner,
+ shadowed card -- the first visual chrome either widget type has ever
+ had (both currently draw with zero chrome of their own). Returns an
+ already-palette-exact RGB image (see ordered_dither)."""
+ import base64
+
+ buf = io.BytesIO()
+ composed.convert("RGB").save(buf, format="PNG")
+ image_b64 = base64.b64encode(buf.getvalue()).decode("ascii")
+
+ template = _jinja_env.get_template("framed_image.html.jinja")
+ html = template.render(
+ w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=panel_style.CARD_RADIUS,
+ image_b64=image_b64,
+ )
+ rendered = render_html_to_image(html, target_w, target_h)
+ return ordered_dither(rendered, palette_rgb)
diff --git a/server/app/migration.py b/server/app/migration.py
index 157ece4..096f713 100644
--- a/server/app/migration.py
+++ b/server/app/migration.py
@@ -814,6 +814,72 @@ def _migration_31(conn) -> None:
conn.execute(text("ALTER TABLE weather_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'"))
+def _migration_32(conn) -> None:
+ """Photos widget's own independent palette/dithering (models.Frame.
+ photo_palette_rgb/photo_dither_strength -- see widgets/photos.py's
+ render()). NULL/1.0 defaults reproduce the exact previous rendering
+ (same reference palette/strength as the main fields) until a frame's
+ Configuration tab sets them differently.
+
+ Guarded per-column, same reasoning as migration 30/31's own comments."""
+ existing = {c["name"] for c in inspect(conn).get_columns("frames")}
+ if "photo_palette_rgb" not in existing:
+ conn.execute(text("ALTER TABLE frames ADD COLUMN photo_palette_rgb TEXT"))
+ if "photo_dither_strength" not in existing:
+ conn.execute(text("ALTER TABLE frames ADD COLUMN photo_dither_strength REAL NOT NULL DEFAULT 1.0"))
+
+
+def _migration_33(conn) -> None:
+ """Battery widget render style (models.BatteryWidgetConfig.
+ render_style) -- same shape as migration 31's weather one. Every
+ existing battery widget defaults to "classic", no behavior change."""
+ existing = {c["name"] for c in inspect(conn).get_columns("battery_widget_configs")}
+ if "render_style" not in existing:
+ conn.execute(text("ALTER TABLE battery_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'"))
+
+
+def _migration_34(conn) -> None:
+ """Text widget render style (models.TextWidgetConfig.render_style) --
+ same shape as migration 31/33. Every existing text widget defaults to
+ "classic", no behavior change."""
+ existing = {c["name"] for c in inspect(conn).get_columns("text_widget_configs")}
+ if "render_style" not in existing:
+ conn.execute(text("ALTER TABLE text_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'"))
+
+
+def _migration_35(conn) -> None:
+ """Tasks widget render style (models.TaskWidgetConfig.render_style)
+ -- same shape as migration 31/33/34. Every existing tasks widget
+ defaults to "classic", no behavior change."""
+ existing = {c["name"] for c in inspect(conn).get_columns("task_widget_configs")}
+ if "render_style" not in existing:
+ conn.execute(text("ALTER TABLE task_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'"))
+
+
+def _migration_36(conn) -> None:
+ """Static image widget render style (models.StaticWidgetConfig.
+ render_style) -- same shape as migration 31/33/34/35."""
+ existing = {c["name"] for c in inspect(conn).get_columns("static_widget_configs")}
+ if "render_style" not in existing:
+ conn.execute(text("ALTER TABLE static_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'"))
+
+
+def _migration_37(conn) -> None:
+ """Whiteboard widget render style (models.WhiteboardWidgetConfig.
+ render_style) -- same shape as migration 36."""
+ existing = {c["name"] for c in inspect(conn).get_columns("whiteboard_widget_configs")}
+ if "render_style" not in existing:
+ conn.execute(text("ALTER TABLE whiteboard_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'"))
+
+
+def _migration_38(conn) -> None:
+ """Calendar widget render style (models.CalendarWidgetConfig.
+ render_style) -- same shape as migration 31/33/34/35/36/37."""
+ existing = {c["name"] for c in inspect(conn).get_columns("calendar_widget_configs")}
+ if "render_style" not in existing:
+ conn.execute(text("ALTER TABLE calendar_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'"))
+
+
MIGRATIONS = [
(1, _migration_1),
(2, _migration_2),
@@ -846,6 +912,13 @@ MIGRATIONS = [
(29, _migration_29),
(30, _migration_30),
(31, _migration_31),
+ (32, _migration_32),
+ (33, _migration_33),
+ (34, _migration_34),
+ (35, _migration_35),
+ (36, _migration_36),
+ (37, _migration_37),
+ (38, _migration_38),
]
diff --git a/server/app/models.py b/server/app/models.py
index fa0f8c9..c9ba298 100644
--- a/server/app/models.py
+++ b/server/app/models.py
@@ -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
diff --git a/server/app/routers/api_frames.py b/server/app/routers/api_frames.py
index 76bb782..2898622 100644
--- a/server/app/routers/api_frames.py
+++ b/server/app/routers/api_frames.py
@@ -105,6 +105,9 @@ def api_config_save(
color_boost: float | None = Form(None),
contrast_boost: float | None = Form(None),
dither_strength: float | None = Form(None),
+ photo_palette: list[str] | None = Form(None),
+ photo_palette_reset: bool | None = Form(None),
+ photo_dither_strength: float | None = Form(None),
hold_duration_ms: int | None = Form(None),
next_hold_action: str | None = Form(None),
back_hold_action: str | None = Form(None),
@@ -182,6 +185,17 @@ def api_config_save(
cfg.contrast_boost = max(0.0, min(2.0, contrast_boost))
if dither_strength is not None:
cfg.dither_strength = max(0.0, min(1.0, dither_strength))
+ if photo_palette_reset:
+ cfg.photo_palette_rgb = None
+ elif photo_palette is not None:
+ if len(photo_palette) != len(PALETTE_LABELS):
+ raise HTTPException(400, f"Expected {len(PALETTE_LABELS)} palette colors, got {len(photo_palette)}")
+ parsed = [hex_to_rgb(h) for h in photo_palette]
+ if any(rgb is None for rgb in parsed):
+ raise HTTPException(400, "Palette colors must be #rrggbb hex values")
+ cfg.photo_palette_rgb = [list(rgb) for rgb in parsed]
+ if photo_dither_strength is not None:
+ cfg.photo_dither_strength = max(0.0, min(1.0, photo_dither_strength))
if hold_duration_ms is not None:
cfg.hold_duration_ms = max(MIN_HOLD_DURATION_MS, min(MAX_HOLD_DURATION_MS, hold_duration_ms))
if next_hold_action is not None:
diff --git a/server/app/routers/api_layouts.py b/server/app/routers/api_layouts.py
index f2f32ff..b4a8241 100644
--- a/server/app/routers/api_layouts.py
+++ b/server/app/routers/api_layouts.py
@@ -56,13 +56,13 @@ LAYOUT_CONFIG_FIELDS: dict[str, tuple[str, ...]] = {
"photos": ("album_id", "order", "display_mode", "queue_target_len"),
"calendar": (
"view", "week_start", "weather_enabled", "weather_units", "weather_cities",
- "week_days", "week_layout", "week_start_offset",
+ "week_days", "week_layout", "week_start_offset", "render_style",
),
- "tasks": ("name", "show_completed"),
- "static": ("display_mode", "original_filename"),
- "text": ("content", "font_size", "font_family", "align", "background_color"),
- "whiteboard": ("user_id", "url"),
- "battery": ("mode",),
+ "tasks": ("name", "show_completed", "render_style"),
+ "static": ("display_mode", "original_filename", "render_style"),
+ "text": ("content", "font_size", "font_family", "align", "background_color", "render_style"),
+ "whiteboard": ("user_id", "url", "render_style"),
+ "battery": ("mode", "render_style"),
"weather": (
"mode", "provider", "units", "city_label", "city_latitude", "city_longitude",
"hourly_interval_hours", "daily_days", "cities", "render_style",
diff --git a/server/app/routers/api_widgets.py b/server/app/routers/api_widgets.py
index 18f459f..5924fc2 100644
--- a/server/app/routers/api_widgets.py
+++ b/server/app/routers/api_widgets.py
@@ -35,11 +35,16 @@ from ..image_pipeline import (
DEFAULT_STATIC_DISPLAY_MODE,
DISPLAY_MODES,
hex_to_rgb,
+ logical_render_size,
MAX_BORDER_THICKNESS,
MIN_BORDER_THICKNESS,
PALETTE_LABELS,
STATIC_DISPLAY_MODES,
render_preview_png,
+ compose_into,
+ _enhance,
+ _png_bytes,
+ _quantize,
)
from ..image_upload import decode_upload
from ..models import (
@@ -317,9 +322,12 @@ def api_widget_config_save(
album_id: str | None = Form(None),
order: str | None = Form(None),
display_mode: str | None = Form(None),
+ static_render_style: str | None = Form(None),
+ whiteboard_render_style: str | None = Form(None),
queue_target_len: int | None = Form(None),
# calendar
calendar_view: str | None = Form(None),
+ calendar_render_style: str | None = Form(None),
calendar_week_start: int | None = Form(None),
calendar_week_days: int | None = Form(None),
calendar_week_layout: str | None = Form(None),
@@ -329,10 +337,12 @@ def api_widget_config_save(
# tasks
tasks_name: str | None = Form(None),
tasks_show_completed: bool | None = Form(None),
+ tasks_render_style: str | None = Form(None),
# text
text_html: str | None = Form(None),
text_font_size: int | None = Form(None),
text_font_family: str | None = Form(None),
+ text_render_style: str | None = Form(None),
text_align: str | None = Form(None),
text_background_color: str | None = Form(None),
# weather
@@ -344,6 +354,7 @@ def api_widget_config_save(
weather_render_style: str | None = Form(None),
# battery
battery_mode: str | None = Form(None),
+ battery_render_style: str | None = Form(None),
):
"""Every field optional -- same partial-update, form-urlencoded
convention as the old frame-level api_config_save, now scoped to one
@@ -406,6 +417,8 @@ def api_widget_config_save(
# new unit label.
ccfg.weather_checked_at = 0.0
ccfg.weather_units = calendar_weather_units
+ if calendar_render_style is not None and calendar_render_style in ("classic", "modern"):
+ ccfg.render_style = calendar_render_style
elif widget.widget_type == "tasks":
with widget_locked(db, frame.id, widget.id) as (_, _, tcfg):
if tasks_name is not None:
@@ -416,10 +429,22 @@ def api_widget_config_save(
if tasks_show_completed is not None and tasks_show_completed != tcfg.show_completed:
tcfg.show_completed = tasks_show_completed
tcfg.checked_at = 0.0 # pick up the change promptly
+ if tasks_render_style is not None and tasks_render_style in ("classic", "modern"):
+ tcfg.render_style = tasks_render_style
elif widget.widget_type == "static":
with widget_locked(db, frame.id, widget.id) as (_, _, scfg):
if display_mode is not None:
scfg.display_mode = display_mode if display_mode in STATIC_DISPLAY_MODES else DEFAULT_STATIC_DISPLAY_MODE
+ if static_render_style is not None and static_render_style in ("classic", "modern"):
+ scfg.render_style = static_render_style
+ elif widget.widget_type == "whiteboard":
+ # user_id/url go through the dedicated /whiteboard-source endpoint
+ # (owner-only, JSON body) -- render_style is the one setting this
+ # widget type takes through the shared /config form, same as
+ # every other widget type's own render_style.
+ with widget_locked(db, frame.id, widget.id) as (_, _, wbcfg):
+ if whiteboard_render_style is not None and whiteboard_render_style in ("classic", "modern"):
+ wbcfg.render_style = whiteboard_render_style
elif widget.widget_type == "text":
with widget_locked(db, frame.id, widget.id) as (_, _, xcfg):
if text_html is not None:
@@ -440,6 +465,8 @@ def api_widget_config_save(
xcfg.background_color = (
text_background_color if hex_to_rgb(text_background_color) else "#ffffff"
)
+ if text_render_style is not None and text_render_style in ("classic", "modern"):
+ xcfg.render_style = text_render_style
elif widget.widget_type == "weather":
with widget_locked(db, frame.id, widget.id) as (_, _, wcfg):
if weather_mode is not None and weather_mode in ("current", "hourly", "daily", "multi_city"):
@@ -476,6 +503,8 @@ def api_widget_config_save(
with widget_locked(db, frame.id, widget.id) as (_, _, bcfg):
if battery_mode is not None:
bcfg.mode = battery_mode if battery_mode in ("compact", "detailed") else "detailed"
+ if battery_render_style is not None and battery_render_style in ("classic", "modern"):
+ bcfg.render_style = battery_render_style
with frame_locked(db, frame.id) as cfg:
cfg.stats_config_saves += 1
return {"status": "saved"}
@@ -836,14 +865,29 @@ def api_widget_preview_calendar(
ccfg = db.get(CalendarWidgetConfig, widget.id)
events, summary = get_or_refresh_calendar_events_for_widget(db, frame, widget)
weather_cities = get_or_refresh_weather_for_widget(db, frame, widget) if ccfg.weather_enabled else None
- png = calendar_render.render_calendar_preview_png(
- events, view=ccfg.view, browse_offset=ccfg.browse_offset, orientation=frame.orientation,
- palette_rgb=frame.palette_rgb, timezone=frame.timezone, fetch_summary=summary,
- week_start=ccfg.week_start,
- weather_cities=weather_cities, weather_units=ccfg.weather_units,
- week_days=ccfg.week_days, week_layout=ccfg.week_layout,
- week_start_offset=ccfg.week_start_offset,
- )
+
+ target_w, target_h = logical_render_size(frame.orientation)
+ if ccfg.render_style == "modern":
+ from zoneinfo import ZoneInfo
+
+ from .. import calendar_html_render
+
+ tz = ZoneInfo(frame.timezone) if frame.timezone else ZoneInfo("UTC")
+ 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,
+ )
+ quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0)
+ png = _png_bytes(quantized)
+ else:
+ png = calendar_render.render_calendar_preview_png(
+ events, view=ccfg.view, browse_offset=ccfg.browse_offset, orientation=frame.orientation,
+ palette_rgb=frame.palette_rgb, timezone=frame.timezone, fetch_summary=summary,
+ week_start=ccfg.week_start,
+ weather_cities=weather_cities, weather_units=ccfg.weather_units,
+ week_days=ccfg.week_days, week_layout=ccfg.week_layout,
+ week_start_offset=ccfg.week_start_offset,
+ )
return Response(content=png, media_type="image/png")
@@ -862,8 +906,17 @@ def api_widget_preview_tasks(
raise HTTPException(400, "No task lists included on this widget yet")
tcfg = db.get(TaskWidgetConfig, widget.id)
tasks = get_or_refresh_tasks_for_widget(db, frame, widget)
- png = calendar_render.render_tasks_preview_png(tasks, orientation=frame.orientation,
- palette_rgb=frame.palette_rgb, title=tcfg.name or "Tasks")
+ title = tcfg.name or "Tasks"
+ if tcfg.render_style == "modern":
+ 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)
+ quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0)
+ png = _png_bytes(quantized)
+ else:
+ png = calendar_render.render_tasks_preview_png(tasks, orientation=frame.orientation,
+ palette_rgb=frame.palette_rgb, title=title)
return Response(content=png, media_type="image/png")
@@ -1170,11 +1223,22 @@ def api_widget_preview_static(
if not scfg.image:
raise HTTPException(400, "No image uploaded to this widget yet")
source = Image.open(io.BytesIO(scfg.image)).convert("RGB")
- png = render_preview_png(
- source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
- display_mode=scfg.display_mode, color_boost=frame.color_boost,
- contrast_boost=frame.contrast_boost, dither_strength=frame.dither_strength,
- )
+ if scfg.render_style == "modern":
+ from .. import html_render
+
+ 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=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)
+ quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0)
+ png = _png_bytes(quantized)
+ else:
+ png = render_preview_png(
+ source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
+ display_mode=scfg.display_mode, color_boost=frame.color_boost,
+ contrast_boost=frame.contrast_boost, dither_strength=frame.dither_strength,
+ )
return Response(content=png, media_type="image/png")
@@ -1309,8 +1373,18 @@ def api_widget_preview_whiteboard(
raise HTTPException(400, "No whiteboard configured on this widget yet")
raise HTTPException(502, "Could not fetch/render the whiteboard yet -- check the URL and credentials")
source = Image.open(io.BytesIO(png_bytes)).convert("RGB")
- png = render_preview_png(
- source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
- display_mode="letterbox",
- )
+ if wcfg.render_style == "modern":
+ from .. import html_render
+
+ 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)
+ quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0)
+ png = _png_bytes(quantized)
+ else:
+ png = render_preview_png(
+ source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
+ display_mode="letterbox",
+ )
return Response(content=png, media_type="image/png")
diff --git a/server/app/routers/frame_pages.py b/server/app/routers/frame_pages.py
index ceae180..7d43c8c 100644
--- a/server/app/routers/frame_pages.py
+++ b/server/app/routers/frame_pages.py
@@ -306,6 +306,7 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
)
return templates.TemplateResponse("_widget_dialog_whiteboard.html", {
"request": request, "frame": frame, "widget": widget, "user": user,
+ "whiteboard_cfg": whiteboard_cfg,
"whiteboard_source": _whiteboard_source_info(db, whiteboard_cfg),
"viewer_has_webdav_creds": viewer_has_webdav_creds, **border_ctx, **button_ctx,
})
diff --git a/server/app/static/frame_config.js b/server/app/static/frame_config.js
index 3fb9f24..5442268 100644
--- a/server/app/static/frame_config.js
+++ b/server/app/static/frame_config.js
@@ -114,7 +114,7 @@ async function loadControl() {
document.getElementById('take-control').addEventListener('click', takeControl);
-// ---- Advanced configuration: color palette ----
+// ---- Advanced configuration: color palette(s) ----
//
// Hex field and R/G/B number fields are kept in sync live, both
// directions -- editing either updates the other plus the preview
@@ -122,9 +122,15 @@ document.getElementById('take-control').addEventListener('click', takeControl);
// the server already validates as #rrggbb); the R/G/B fields are purely
// an alternate, more precise way to arrive at the same value than
// eyeballing a color-picker swatch.
+//
+// Parameterized by classPrefix ("palette" for the main one, "photo-
+// palette" for the photos-only one added alongside it) rather than
+// duplicated wholesale -- there are exactly two real instances of this,
+// not a speculative future one, and the two would otherwise be ~90
+// near-identical lines apart.
-function paletteHexInputs() {
- return Array.from(document.querySelectorAll('.palette-hex'))
+function paletteHexInputs(classPrefix) {
+ return Array.from(document.querySelectorAll(`.${classPrefix}-hex`))
.sort((a, b) => Number(a.dataset.index) - Number(b.dataset.index));
}
@@ -140,45 +146,53 @@ function rgbFromHex(hex) {
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
-function paletteFieldsFor(index) {
+function paletteFieldsFor(classPrefix, index) {
const at = (cls) => document.querySelector(`.${cls}[data-index="${index}"]`);
- return { hex: at('palette-hex'), r: at('palette-r'), g: at('palette-g'), b: at('palette-b'), swatch: at('palette-swatch-preview') };
+ return {
+ hex: at(`${classPrefix}-hex`), r: at(`${classPrefix}-r`), g: at(`${classPrefix}-g`), b: at(`${classPrefix}-b`),
+ swatch: at(`${classPrefix}-swatch-preview`),
+ };
}
-function syncPaletteFromHex(index) {
- const f = paletteFieldsFor(index);
+function syncPaletteFromHex(classPrefix, index) {
+ const f = paletteFieldsFor(classPrefix, index);
const rgb = rgbFromHex(f.hex.value);
if (!rgb) return;
[f.r.value, f.g.value, f.b.value] = rgb;
f.swatch.style.background = f.hex.value;
}
-function syncPaletteFromRgb(index) {
- const f = paletteFieldsFor(index);
+function syncPaletteFromRgb(classPrefix, index) {
+ const f = paletteFieldsFor(classPrefix, index);
const hex = hexFromRgb(f.r.value, f.g.value, f.b.value);
f.hex.value = hex;
f.swatch.style.background = hex;
}
-const palettePickerCount = paletteHexInputs().length;
-for (let i = 0; i < palettePickerCount; i++) {
- const f = paletteFieldsFor(i);
- f.hex.addEventListener('input', () => syncPaletteFromHex(i));
- [f.r, f.g, f.b].forEach((el) => el.addEventListener('input', () => syncPaletteFromRgb(i)));
+function wirePaletteInputs(classPrefix) {
+ const count = paletteHexInputs(classPrefix).length;
+ for (let i = 0; i < count; i++) {
+ const f = paletteFieldsFor(classPrefix, i);
+ f.hex.addEventListener('input', () => syncPaletteFromHex(classPrefix, i));
+ [f.r, f.g, f.b].forEach((el) => el.addEventListener('input', () => syncPaletteFromRgb(classPrefix, i)));
+ }
}
+wirePaletteInputs('palette');
+wirePaletteInputs('photo-palette');
+
// Sliders: live numeric readout next to each, no save until the button
// below is clicked.
-['color_boost', 'contrast_boost', 'dither_strength'].forEach((id) => {
+['color_boost', 'contrast_boost', 'dither_strength', 'photo_dither_strength'].forEach((id) => {
const input = document.getElementById(id);
const readout = document.getElementById(`${id}_value`);
input.addEventListener('input', () => { readout.textContent = Number(input.value).toFixed(2); });
});
-async function savePalette(extra) {
+async function savePalette(classPrefix, paletteFormKey, extra) {
const body = new URLSearchParams(extra || {});
- for (const input of paletteHexInputs()) {
- body.append('palette', input.value);
+ for (const input of paletteHexInputs(classPrefix)) {
+ body.append(paletteFormKey, input.value);
}
try {
const resp = await fetch(`${window.FRAME_API}/config`, {
@@ -195,7 +209,7 @@ async function savePalette(extra) {
}
document.getElementById('palette-save').addEventListener('click', () => {
- savePalette({
+ savePalette('palette', 'palette', {
color_boost: document.getElementById('color_boost').value,
contrast_boost: document.getElementById('contrast_boost').value,
dither_strength: document.getElementById('dither_strength').value,
@@ -203,16 +217,16 @@ document.getElementById('palette-save').addEventListener('click', () => {
});
document.getElementById('palette-reset').addEventListener('click', () => {
- const inputs = paletteHexInputs();
+ const inputs = paletteHexInputs('palette');
window.DEFAULT_PALETTE_HEX.forEach((hex, i) => {
inputs[i].value = hex;
- syncPaletteFromHex(i);
+ syncPaletteFromHex('palette', i);
});
['color_boost', 'contrast_boost', 'dither_strength'].forEach((id) => {
document.getElementById(id).value = '1';
document.getElementById(`${id}_value`).textContent = '1.00';
});
- savePalette({ palette_reset: 'true', color_boost: '1', contrast_boost: '1', dither_strength: '1' });
+ savePalette('palette', 'palette', { palette_reset: 'true', color_boost: '1', contrast_boost: '1', dither_strength: '1' });
});
// Fills the table with a community-measured starting point (see the
@@ -220,13 +234,32 @@ document.getElementById('palette-reset').addEventListener('click', () => {
// editing the hex/RGB fields by hand; the user still clicks Save (or
// Reset) to commit or discard it.
document.getElementById('palette-load-calibrated').addEventListener('click', () => {
- const inputs = paletteHexInputs();
+ const inputs = paletteHexInputs('palette');
window.CALIBRATED_SPECTRA6_HEX.forEach((hex, i) => {
inputs[i].value = hex;
- syncPaletteFromHex(i);
+ syncPaletteFromHex('palette', i);
});
});
+// ---- Photos configuration: its own separate palette/dithering ----
+
+document.getElementById('photo-palette-save').addEventListener('click', () => {
+ savePalette('photo-palette', 'photo_palette', {
+ photo_dither_strength: document.getElementById('photo_dither_strength').value,
+ });
+});
+
+document.getElementById('photo-palette-reset').addEventListener('click', () => {
+ const inputs = paletteHexInputs('photo-palette');
+ window.DEFAULT_PALETTE_HEX.forEach((hex, i) => {
+ inputs[i].value = hex;
+ syncPaletteFromHex('photo-palette', i);
+ });
+ document.getElementById('photo_dither_strength').value = '1';
+ document.getElementById('photo_dither_strength_value').textContent = '1.00';
+ savePalette('photo-palette', 'photo_palette', { photo_palette_reset: 'true', photo_dither_strength: '1' });
+});
+
// ---- Preview: current photo vs. how it renders with saved settings ----
// Scoped to this frame's photo widget (window.PHOTO_WIDGET_PREVIEW_API,
// set by the template) rather than window.FRAME_API -- palette/color/
diff --git a/server/app/static/widget_dialog_battery.js b/server/app/static/widget_dialog_battery.js
index 42ee8cd..1fc53e2 100644
--- a/server/app/static/widget_dialog_battery.js
+++ b/server/app/static/widget_dialog_battery.js
@@ -13,6 +13,7 @@ function initBatteryDialog() {
e.preventDefault();
const body = new URLSearchParams({
battery_mode: document.getElementById('battery_mode').value,
+ battery_render_style: document.getElementById('battery_render_style').value,
});
try {
const resp = await fetch(`${window.FRAME_API}/config`, {
diff --git a/server/app/static/widget_dialog_calendar.js b/server/app/static/widget_dialog_calendar.js
index 581c3a9..36b5ae4 100644
--- a/server/app/static/widget_dialog_calendar.js
+++ b/server/app/static/widget_dialog_calendar.js
@@ -81,6 +81,7 @@ function initCalendarDialog() {
calendar_week_days: document.getElementById('calendar_week_days').value,
calendar_week_layout: document.getElementById('calendar_week_layout').value,
calendar_week_start_offset: document.getElementById('calendar_week_start_offset').value,
+ calendar_render_style: document.getElementById('calendar_render_style').value,
});
try {
const resp = await fetch(`${window.FRAME_API}/config`, {
diff --git a/server/app/static/widget_dialog_static.js b/server/app/static/widget_dialog_static.js
index 623f09c..bfd033d 100644
--- a/server/app/static/widget_dialog_static.js
+++ b/server/app/static/widget_dialog_static.js
@@ -38,6 +38,7 @@ function initStaticDialog() {
e.preventDefault();
const body = new URLSearchParams({
display_mode: document.getElementById('display_mode').value,
+ static_render_style: document.getElementById('static_render_style').value,
});
try {
const resp = await fetch(`${window.FRAME_API}/config`, {
diff --git a/server/app/static/widget_dialog_tasks.js b/server/app/static/widget_dialog_tasks.js
index 672bac0..dfa1934 100644
--- a/server/app/static/widget_dialog_tasks.js
+++ b/server/app/static/widget_dialog_tasks.js
@@ -71,6 +71,7 @@ function initTasksDialog() {
const body = new URLSearchParams({
tasks_name: document.getElementById('tasks_name').value,
tasks_show_completed: String(document.getElementById('tasks_show_completed').checked),
+ tasks_render_style: document.getElementById('tasks_render_style').value,
});
try {
const resp = await fetch(`${window.FRAME_API}/config`, {
diff --git a/server/app/static/widget_dialog_text.js b/server/app/static/widget_dialog_text.js
index d31722c..1f867bd 100644
--- a/server/app/static/widget_dialog_text.js
+++ b/server/app/static/widget_dialog_text.js
@@ -113,6 +113,7 @@ function initTextDialog() {
text_font_size: document.getElementById('text_font_size').value,
text_align: document.getElementById('text_align').value,
text_background_color: document.getElementById('text_background_color').value,
+ text_render_style: document.getElementById('text_render_style').value,
});
try {
const resp = await fetch(`${window.FRAME_API}/config`, {
diff --git a/server/app/static/widget_dialog_whiteboard.js b/server/app/static/widget_dialog_whiteboard.js
index 4ee5946..d18aff6 100644
--- a/server/app/static/widget_dialog_whiteboard.js
+++ b/server/app/static/widget_dialog_whiteboard.js
@@ -90,6 +90,25 @@ function initWhiteboardDialog() {
whiteboardClearBtn.addEventListener('click', clearWhiteboardSource);
}
+ document.getElementById('whiteboard-config-form').addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const body = new URLSearchParams({
+ whiteboard_render_style: document.getElementById('whiteboard_render_style').value,
+ });
+ try {
+ const resp = await fetch(`${window.FRAME_API}/config`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body,
+ });
+ if (!resp.ok) throw new Error(await apiError(resp));
+ showStatus(true, 'Saved.');
+ loadWhiteboardPreview(false);
+ } catch (e) {
+ showStatus(false, e.message);
+ }
+ });
+
// Shows whatever's already cached (cheap, no refetch) on open; the
// button is the one place that means "no really, go check now" --
// bypasses the fetch throttle server-side (see api_widget_preview_
diff --git a/server/app/templates/_widget_dialog_battery.html b/server/app/templates/_widget_dialog_battery.html
index 7a7f8f3..45ca5ee 100644
--- a/server/app/templates/_widget_dialog_battery.html
+++ b/server/app/templates/_widget_dialog_battery.html
@@ -11,6 +11,12 @@
+
diff --git a/server/app/templates/_widget_dialog_calendar.html b/server/app/templates/_widget_dialog_calendar.html
index bcf063b..433af60 100644
--- a/server/app/templates/_widget_dialog_calendar.html
+++ b/server/app/templates/_widget_dialog_calendar.html
@@ -40,6 +40,12 @@
0 = starts today, negative = starts in the
past, positive = starts in the future. Only used when Days to show isn't 7.
+
diff --git a/server/app/templates/_widget_dialog_static.html b/server/app/templates/_widget_dialog_static.html
index e58787a..4884da3 100644
--- a/server/app/templates/_widget_dialog_static.html
+++ b/server/app/templates/_widget_dialog_static.html
@@ -32,6 +32,12 @@
exactly without cropping (an image with a different aspect ratio
looks stretched); Shrink to fit shows the whole
image, letterboxed if needed.
+
+
+
{% include "_widget_border_fields.html" %}
{% include "_widget_button_fields.html" %}
diff --git a/server/app/templates/frame_config.html b/server/app/templates/frame_config.html
index 5ac4811..23af66e 100644
--- a/server/app/templates/frame_config.html
+++ b/server/app/templates/frame_config.html
@@ -139,11 +139,12 @@
Advanced configuration
-
Color-quantization values used when dithering photos
- for this panel -- approximations by default, since exact primaries
- aren't published. Tune them by comparing a rendered photo against
- the physical panel; different panel units can vary enough to be
- worth calibrating per frame.
+
Color-quantization values used for every widget
+ except photos (which has its own separate
+ settings below) -- approximations by default, since exact
+ primaries aren't published. Tune them by comparing a rendered
+ widget against the physical panel; different panel units can
+ vary enough to be worth calibrating per frame.
Color
Hex
R
G
B
@@ -195,6 +196,45 @@
Compare against the physical panel before keeping it.
+
+ Photos configuration
+
Palette and dithering used only by
+ the photos widget, independent of Advanced configuration above --
+ lets you tune the rest of this frame's widgets (e.g. a "modern"
+ HTML-rendered look) without changing what looks best for actual
+ photographs, or vice versa.
+
+
+
Color
Hex
R
G
B
+
+ {% set current_photo_palette = frame.photo_palette_rgb or default_palette_rgb %}
+ {% set current_photo_hex = palette_to_hex(current_photo_palette) %}
+ {% for label in palette_labels %}
+
+ {#- Fixed height (not auto) -- every stacked day-section's header
+ must be exactly this tall regardless of whether THIS particular
+ day has a weather entry, or days with/without weather misalign
+ where their event rows start (see calendar_week_horizontal's
+ identical fix/reasoning). #}
+