From 5f4f8f2ea70dfc01f905dcad08c597c5902f8c06 Mon Sep 17 00:00:00 2001
From: Thomas Faour
Date: Fri, 31 Jul 2026 10:34:28 +0000
Subject: [PATCH] Add a curated theme system for "modern" style widgets,
inspired by Tesserae
Frame.theme (7 presets in app/theme_tokens.py) drives font family,
corner radius, drop shadow, and an accent hue for every modern-style
widget's header/accent region. Rich accent colors (not just the 6 flat
panel inks) are approximated via denser Bayer stippling confined to just
that region (html_render.ordered_dither_regions), so icon/text content
elsewhere stays exactly as crisp as it is today -- verified directly
against real Chromium renders, both in unit tests and via run-server.
"classic" is a byte-identical no-visual-change default: weather's header
keeps its original fixed blue gradient, tasks/calendar keep their flat
THEME_* ink.
Themes are purely stylistic -- battery's charge-level color, calendar/
tasks' per-owner event chips, and text's own per-widget font choice are
never touched.
---
docs/widgets.md | 64 ++++++
server/app/calendar_html_render.py | 116 ++++++----
server/app/html_render.py | 137 ++++++++----
server/app/migration.py | 10 +
server/app/models.py | 5 +
server/app/routers/api_frames.py | 5 +-
server/app/routers/api_widgets.py | 13 +-
server/app/routers/frame_pages.py | 3 +-
server/app/static/frame_config.js | 18 ++
server/app/templates/frame_config.html | 17 ++
.../templates/widget_html/battery.html.jinja | 6 +-
.../widget_html/calendar_agenda.html.jinja | 8 +-
.../widget_html/calendar_month.html.jinja | 8 +-
.../calendar_today_tomorrow.html.jinja | 8 +-
.../calendar_week_horizontal.html.jinja | 8 +-
.../widget_html/framed_image.html.jinja | 2 +-
.../templates/widget_html/tasks.html.jinja | 8 +-
.../widget_html/weather_current.html.jinja | 6 +-
.../widget_html/weather_daily.html.jinja | 8 +-
server/app/theme_tokens.py | 202 ++++++++++++++++++
server/app/widgets/battery.py | 2 +-
server/app/widgets/calendar.py | 1 +
server/app/widgets/static_image.py | 2 +-
server/app/widgets/tasks.py | 2 +-
server/app/widgets/text.py | 89 ++------
server/app/widgets/weather.py | 2 +-
server/app/widgets/whiteboard.py | 3 +-
server/tests/test_html_render.py | 65 ++++++
server/tests/test_theme_tokens.py | 92 ++++++++
server/tests/test_widgets_calendar.py | 32 +++
server/tests/test_widgets_tasks.py | 30 +++
server/tests/test_widgets_weather.py | 31 +++
32 files changed, 808 insertions(+), 195 deletions(-)
create mode 100644 server/app/theme_tokens.py
create mode 100644 server/tests/test_html_render.py
create mode 100644 server/tests/test_theme_tokens.py
diff --git a/docs/widgets.md b/docs/widgets.md
index 9c48461..82e7f1d 100644
--- a/docs/widgets.md
+++ b/docs/widgets.md
@@ -212,6 +212,70 @@ Per-widget-type notes:
(`framed_image.html.jinja`, shared between the two) wrapping the
already-composed image.
+### Themes for modern-style widgets
+
+`Frame.theme` (String, default `"classic"`, one Advanced Configuration
+``) picks a curated visual preset for every modern-style widget
+on that frame -- font family, corner radius, drop shadow, and an accent
+hue for widgets with a header/accent region. Presets live in
+`app/theme_tokens.py`'s `THEMES` dict; `resolve_theme(theme_name,
+widget_kind, palette_rgb)` turns one into concrete, ready-to-render
+values (`accent_hex`/`accent_hex_dark`, resolved `font_regular`/
+`font_bold` file paths, `radius`, `shadow`, `accent_amplitude`). Inspired
+by [Tesserae](https://github.com/dmellok/tesserae)'s (AGPL-3.0) own
+three-layer CSS custom-property theme system -- this is an original
+reimplementation of that *architecture*, not a copy of its token file
+(see this repo's `CLAUDE.md` on copyleft dependencies).
+
+**A theme is purely stylistic, never functional color-coding.** Battery's
+charge-level red/yellow/green, calendar/tasks' per-owner event color
+chips, and text's user-authored inline run colors are status/identity
+signals, not style choices -- no theme may recolor them, and every
+`build_*`/`resolve_theme` call site that touches those stays on its own
+existing logic untouched. Text's own per-widget `font_family` setting
+(a user's explicit content-level choice, same carve-out reasoning) is
+similarly never overridden by a theme -- `build_text` accepts a
+`theme_name` param for signature uniformity with every other modern-
+style builder but deliberately ignores it.
+
+**Rich accent hues, not just the 6 exact panel inks.** A theme's
+`accent_hex` can be any arbitrary color (e.g. terracotta, moss, slate) --
+`html_render.ordered_dither_regions(rendered, palette_rgb,
+base_amplitude, accent_regions=[(rect, amplitude), ...])` dithers the
+whole widget at the existing safe default (`ordered_dither`'s tuned 48,
+unchanged, still icon/text-legible) and then *separately* re-dithers
+just the accent rectangle (a header bar's already-computed pixel rect)
+at a theme's higher `accent_amplitude` (~130) and pastes it back. Safe
+to do per-region for the same reason `ordered_dither` itself is safe
+per-widget: ordered (Bayer) dithering has no cross-pixel error term, so
+a region's result depends only on its own pixels. A single higher
+amplitude applied to the *whole* widget instead was tried and rejected --
+it washes out pale content (a weather icon's white cloud body nearly
+vanished in testing); confining the higher amplitude to just the accent
+rect avoids that while still letting the rect approximate a rich hue via
+denser stippling instead of flatly snapping to one nearest ink (what
+happens to a rich hue at the base amplitude).
+
+**"classic" is a deliberately no-visual-change default.** Its
+`accent_hex` is `None`, meaning "keep this widget kind's own pre-theme
+look exactly": weather's header was always a fixed blue gradient (now
+`theme_tokens._CLASSIC_WEATHER_GRADIENT`, byte-identical to the old
+module-level `ACCENT_START`/`ACCENT_END` constants this system
+replaced); tasks/calendar's header was always a flat single ink resolved
+through `panel_style.THEME` (still is, just via `resolve_theme` now).
+Widget kinds with no ink of their own (battery/text/static/whiteboard)
+fall back to black, though none of their templates currently have an
+accent-colored surface for it to visibly affect.
+
+Which widgets get the richer accent-region treatment: weather's
+`build_daily` (the header bar, when `city_label` is set), tasks, and
+calendar's four view builders (each already computed a `header_h` in
+Python for layout, reused as the accent rect). Weather's `build_current`,
+battery, and static/whiteboard's shared `build_framed_image` are
+theme-aware for font/radius/shadow only -- no header/accent region to
+dither richer, so they call plain `ordered_dither` exactly as before
+themes existed.
+
## Button actions
Each physical button (NEXT/BACK) runs the `(widget, action)` binding of
diff --git a/server/app/calendar_html_render.py b/server/app/calendar_html_render.py
index 0a33a0a..10bec93 100644
--- a/server/app/calendar_html_render.py
+++ b/server/app/calendar_html_render.py
@@ -22,7 +22,7 @@ from zoneinfo import ZoneInfo
from PIL import Image
-from . import html_render, panel_style
+from . import html_render, panel_style, theme_tokens
from .calendar_render import (
MARGIN,
WEEKDAY_NAMES,
@@ -71,8 +71,11 @@ def _day_section_data(day: date, events: list[dict], tz: ZoneInfo, palette_rgb,
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."""
+ weather_units: str = "fahrenheit", theme_name: str | None = None) -> Image.Image:
+ """HTML/CSS-rendered analogue of calendar_render._build_agenda. Has a
+ header bar -- dithered at the theme's accent_amplitude via
+ ordered_dither_regions."""
+ theme = theme_tokens.resolve_theme(theme_name, "calendar", palette_rgb)
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)
@@ -91,24 +94,30 @@ def build_agenda(events: list[dict], browse_offset: int, target_w: int, target_h
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"],
+ w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"], shadow=theme["shadow"],
+ font_regular=theme["font_regular"], font_bold=theme["font_bold"],
+ header=data["header"], title_size=title_size, header_h=header_h,
+ accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"], 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)
+ gutter = panel_style.GUTTER
+ accent_rect = (gutter, gutter, target_w - gutter, gutter + header_h)
+ return html_render.ordered_dither_regions(
+ rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])]
+ )
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:
+ weather_units: str = "fahrenheit", theme_name: str | None = None) -> Image.Image:
"""HTML/CSS-rendered analogue of calendar_render._build_today_tomorrow
- -- two day-sections stacked (see _day_section_data)."""
+ -- two day-sections stacked (see _day_section_data), each with its own
+ header bar dithered richer via ordered_dither_regions."""
+ theme = theme_tokens.resolve_theme(theme_name, "calendar", palette_rgb)
start_day = datetime.now(tz).date() + timedelta(days=browse_offset)
section_h = target_h // 2
title_size = max(13, section_h // 8)
@@ -131,26 +140,35 @@ def build_today_tomorrow(events: list[dict], browse_offset: int, target_w: int,
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,
+ w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"], shadow=theme["shadow"],
+ font_regular=theme["font_regular"], font_bold=theme["font_bold"],
+ days=days, title_size=title_size, header_h=header_h,
+ accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"], 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)
+ gutter = panel_style.GUTTER
+ accent_regions = [
+ ((gutter, gutter + i * section_h, target_w - gutter, gutter + i * section_h + header_h),
+ theme["accent_amplitude"])
+ for i in range(len(days))
+ ]
+ return html_render.ordered_dither_regions(rendered, palette_rgb, accent_regions=accent_regions)
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:
+ start_offset: int = 0, theme_name: str | None = None) -> 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."""
+ columns) layouts. Each header (per-section or per-column) dithers
+ richer via ordered_dither_regions."""
+ theme = theme_tokens.resolve_theme(theme_name, "calendar", palette_rgb)
+ gutter = panel_style.GUTTER
today = datetime.now(tz).date()
if days == 7:
days_since_start = (today.weekday() - week_start) % 7
@@ -158,7 +176,6 @@ def build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
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":
@@ -180,13 +197,19 @@ def build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
]
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,
+ w=target_w, h=target_h, gutter=gutter, radius=theme["radius"], shadow=theme["shadow"],
+ font_regular=theme["font_regular"], font_bold=theme["font_bold"],
+ days=day_sections, title_size=title_size, header_h=header_h,
+ accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"], 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)
+ accent_regions = [
+ ((gutter, gutter + i * section_h, target_w - gutter, gutter + i * section_h + header_h),
+ theme["accent_amplitude"])
+ for i in range(len(day_sections))
+ ]
+ return html_render.ordered_dither_regions(rendered, palette_rgb, accent_regions=accent_regions)
header_size = max(10, min(16, (target_w // days) // 6))
chip_size = max(9, header_size - 3)
@@ -219,20 +242,29 @@ def build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
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,
+ w=target_w, h=target_h, gutter=gutter, radius=theme["radius"], shadow=theme["shadow"],
+ font_regular=theme["font_regular"], font_bold=theme["font_bold"],
+ cols=cols, header_size=header_size, chip_size=chip_size,
+ header_h=header_h, weather_size=weather_size, unit_suffix=unit_suffix,
+ accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"],
)
rendered = html_render.render_html_to_image(html, target_w, target_h)
- return html_render.ordered_dither(rendered, palette_rgb)
+ accent_rect = (gutter, gutter, target_w - gutter, gutter + header_h)
+ return html_render.ordered_dither_regions(rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])])
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:
+ week_start: int, palette_rgb: list | None = None, theme_name: str | 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)."""
+ unreadable on a 6-color dithered e-ink panel). The per-owner event
+ dots are identity-coding (like every other calendar view's chips) and
+ are never touched by a theme; only the weekday-name row (a flat
+ accent background, no gradient in this view) dithers richer via
+ ordered_dither_regions."""
+ theme = theme_tokens.resolve_theme(theme_name, "calendar", palette_rgb)
+ gutter = panel_style.GUTTER
today = datetime.now(tz).date()
target_month = _add_months(date(today.year, today.month, 1), browse_offset)
weeks_dates = list(
@@ -243,7 +275,6 @@ def build_month(events: list[dict], browse_offset: int, target_w: int, target_h:
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 = []
@@ -260,18 +291,22 @@ def build_month(events: list[dict], browse_offset: int, target_w: int, target_h:
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,
+ w=target_w, h=target_h, gutter=gutter, radius=theme["radius"], shadow=theme["shadow"],
+ font_regular=theme["font_regular"], font_bold=theme["font_bold"],
+ day_names=day_names, weeks=weeks,
+ header_size=header_size, day_size=day_size, dot_size=dot_size, accent_start=theme["accent_hex"],
)
rendered = html_render.render_html_to_image(html, target_w, target_h)
- return html_render.ordered_dither(rendered, palette_rgb)
+ weekday_row_h = header_size + 12
+ accent_rect = (gutter, gutter, target_w - gutter, gutter + weekday_row_h)
+ return html_render.ordered_dither_regions(rendered, palette_rgb,
+ accent_regions=[(accent_rect, theme["accent_amplitude"])])
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:
+ week_start_offset: int = 0, theme_name: str | None = None) -> 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
@@ -281,11 +316,12 @@ def build(events: list[dict], view: str, browse_offset: int, target_w: int, targ
effective_view = "agenda"
if effective_view == "agenda":
- return build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb, weather_cities, weather_units)
+ return build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb, weather_cities,
+ weather_units, theme_name)
if effective_view == "today_tomorrow":
return build_today_tomorrow(events, browse_offset, target_w, target_h, tz, palette_rgb, weather_cities,
- weather_units)
+ weather_units, theme_name)
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)
+ weather_units, week_days, week_layout, week_start_offset, theme_name)
+ return build_month(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb, theme_name)
diff --git a/server/app/html_render.py b/server/app/html_render.py
index 2ebe189..0f85a6d 100644
--- a/server/app/html_render.py
+++ b/server/app/html_render.py
@@ -8,7 +8,7 @@ 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
+calendar's (all four view modes, 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
@@ -66,7 +66,7 @@ import numpy as np
from jinja2 import Environment, FileSystemLoader, select_autoescape
from PIL import Image
-from . import panel_style
+from . import panel_style, theme_tokens
from .image_pipeline import DEFAULT_PALETTE_RGB, hex_to_rgb
_TEMPLATE_DIR = Path(__file__).resolve().parent / "templates" / "widget_html"
@@ -87,16 +87,6 @@ CATEGORY_EMOJI = {
"thunderstorm": "⛈️",
}
-# ACCENT_START/END: a fixed blue gradient pair for the "modern" style's
-# card header -- deliberately not routed through panel_style.theme_color
-# (unlike every classic-rendered widget's chrome), since the whole point
-# of this style is the gradient look ordered_dither below then commits
-# to exact palette colors anyway; which literal hex this starts from
-# doesn't matter to the end result the way it would for a flat PIL fill.
-ACCENT_START = "#1c4fd6"
-ACCENT_END = "#6fa8ff"
-
-
def _rgb_to_hex(rgb: tuple[int, int, int]) -> str:
return "#%02x%02x%02x" % tuple(rgb)
@@ -104,7 +94,7 @@ def _rgb_to_hex(rgb: tuple[int, int, int]) -> str:
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."""
+ regardless of which literal hex a gradient starts from)."""
return _rgb_to_hex(tuple(max(0, round(c * factor)) for c in rgb))
@@ -247,6 +237,26 @@ def ordered_dither(img: Image.Image, palette_rgb: list | None, amplitude: float
return Image.fromarray(palette[idx].astype(np.uint8), "RGB")
+def ordered_dither_regions(rendered: Image.Image, palette_rgb: list | None, base_amplitude: float = 48.0,
+ accent_regions: list[tuple[tuple[int, int, int, int], float]] = ()) -> Image.Image:
+ """Like `ordered_dither`, but lets specific rectangles (e.g. a themed
+ header bar) dither at a higher amplitude than the rest of the widget.
+ A single higher amplitude applied to a whole widget washes out pale
+ content (a weather icon's white cloud body nearly disappeared in
+ testing); dithering the base image at the safe default and only
+ re-dithering an accent rect on top -- pasted back over the base --
+ lets a header carry a rich, arbitrary accent hue (via denser
+ stippling) without touching icon/text legibility elsewhere. Safe to
+ do per-region for the same reason `ordered_dither` is safe per-widget
+ (see its docstring): no cross-pixel error-diffusion term, so each
+ region's result depends only on its own pixels."""
+ base = ordered_dither(rendered, palette_rgb, amplitude=base_amplitude)
+ for (x0, y0, x1, y1), amplitude in accent_regions:
+ crop = rendered.crop((x0, y0, x1, y1))
+ base.paste(ordered_dither(crop, palette_rgb, amplitude=amplitude), (x0, y0))
+ return base
+
+
# --- Weather "modern" style ----------------------------------------------
def _day_label(day_date: date) -> str:
@@ -259,21 +269,25 @@ def _day_label(day_date: date) -> str:
def build_current(entry: dict | None, target_w: int, target_h: int, palette_rgb: list | None = None,
- units: str = "fahrenheit", city_label: str = "") -> Image.Image:
+ units: str = "fahrenheit", city_label: str = "", theme_name: str | None = None) -> Image.Image:
"""HTML/CSS-rendered analogue of weather_render.build_current --
same call signature, so app/widgets/weather.py can dispatch to
either interchangeably. Returns an already-palette-exact RGB image
- (see ordered_dither)."""
+ (see ordered_dither). No header/accent region here (just a centered
+ icon+temp) -- theme-aware for font/radius only, plain ordered_dither
+ (no ordered_dither_regions call)."""
img = Image.new("RGB", (target_w, target_h), (255, 255, 255))
if not entry:
return img
+ theme = theme_tokens.resolve_theme(theme_name, "weather", palette_rgb)
unit_suffix = "F" if units == "fahrenheit" else "C"
icon_size = max(28, min(target_w, target_h) // 3)
template = _jinja_env.get_template("weather_current.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), emoji=CATEGORY_EMOJI.get(entry["category"], ""),
+ w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"],
+ font_regular=theme["font_regular"], font_bold=theme["font_bold"],
+ emoji=CATEGORY_EMOJI.get(entry["category"], ""),
temp=round(entry["temp"]), unit_suffix=unit_suffix, city_label=city_label,
icon_size=icon_size, temp_size=max(24, min(target_w, target_h) // 3),
label_size=max(12, icon_size // 3),
@@ -283,15 +297,18 @@ def build_current(entry: dict | None, target_w: int, target_h: int, palette_rgb:
def build_daily(daily: dict[str, dict], target_w: int, target_h: int, palette_rgb: list | None = None,
- units: str = "fahrenheit", city_label: str = "") -> Image.Image:
+ units: str = "fahrenheit", city_label: str = "", theme_name: str | None = None) -> Image.Image:
"""HTML/CSS-rendered analogue of weather_render.build_daily -- same
call signature. Returns an already-palette-exact RGB image (see
- ordered_dither)."""
+ ordered_dither). Has a header bar -- dithered at the theme's
+ accent_amplitude via ordered_dither_regions, richer than the rest of
+ the widget (see that function's docstring for why)."""
img = Image.new("RGB", (target_w, target_h), (255, 255, 255))
days = list(daily.items())
if not days:
return img
+ theme = theme_tokens.resolve_theme(theme_name, "weather", palette_rgb)
unit_suffix = "F" if units == "fahrenheit" else "C"
header_h = max(28, min(target_w, target_h) // 8) if city_label else 0
col_w = max(1, target_w // len(days))
@@ -307,21 +324,26 @@ def build_daily(daily: dict[str, dict], target_w: int, target_h: int, palette_rg
]
template = _jinja_env.get_template("weather_daily.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), city_label=city_label, header_h=header_h,
- title_size=max(14, header_h - 12), accent_start=ACCENT_START, accent_end=ACCENT_END,
+ w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"], shadow=theme["shadow"],
+ font_regular=theme["font_regular"], font_bold=theme["font_bold"],
+ city_label=city_label, header_h=header_h,
+ title_size=max(14, header_h - 12), accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"],
days=day_entries, icon_size=icon_size, label_size=max(12, icon_size // 2),
unit_suffix=unit_suffix,
)
rendered = render_html_to_image(html, target_w, target_h)
- return ordered_dither(rendered, palette_rgb)
+ if header_h <= 0:
+ return ordered_dither(rendered, palette_rgb)
+ gutter = panel_style.GUTTER
+ accent_rect = (gutter, gutter, target_w - gutter, gutter + header_h)
+ return ordered_dither_regions(rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])])
SUPPORTED_MODES = ("current", "daily")
def build(mode: str, data, target_w: int, target_h: int, palette_rgb: list | None = None,
- units: str = "fahrenheit", city_label: str = "") -> Image.Image:
+ units: str = "fahrenheit", city_label: str = "", theme_name: str | None = None) -> Image.Image:
"""Dispatches to build_current/build_daily -- mirrors weather_render.
build()'s signature (minus interval_hours, which no modern-style mode
uses) so app/widgets/weather.py and the weather preview endpoint can
@@ -329,12 +351,13 @@ def build(mode: str, data, target_w: int, target_h: int, palette_rgb: list | Non
SUPPORTED_MODES -- callers are expected to have already fallen back to
weather_render.build() for hourly/multi_city (see weather.py)."""
if mode == "current":
- return build_current(data, target_w, target_h, palette_rgb, units, city_label)
- return build_daily(data, target_w, target_h, palette_rgb, units, city_label)
+ return build_current(data, target_w, target_h, palette_rgb, units, city_label, theme_name)
+ return build_daily(data, target_w, target_h, palette_rgb, units, city_label, theme_name)
def render_weather_preview_png(mode: str, data, orientation: str, palette_rgb: list | None,
- units: str = "fahrenheit", city_label: str = "") -> bytes:
+ units: str = "fahrenheit", city_label: str = "",
+ theme_name: str | None = None) -> bytes:
"""Modern-style analogue of weather_render.render_weather_preview_png
-- same browser-viewable-PNG convention every other widget's preview
endpoint uses. build()'s output is already palette-exact (see
@@ -343,7 +366,7 @@ def render_weather_preview_png(mode: str, data, orientation: str, palette_rgb: l
from .image_pipeline import _quantize, _png_bytes, logical_render_size
target_w, target_h = logical_render_size(orientation)
- img = build(mode, data, target_w, target_h, palette_rgb, units, city_label)
+ img = build(mode, data, target_w, target_h, palette_rgb, units, city_label, theme_name)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
return _png_bytes(quantized)
@@ -361,13 +384,16 @@ def _battery_sizes(target_w: int, target_h: int, num_lines: int, scale: float) -
def build_battery(percent: int, lines: list[str], target_w: int, target_h: int,
- palette_rgb: list | None = None) -> Image.Image:
+ palette_rgb: list | None = None, theme_name: str | 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).
+ ordered_dither). Theme-aware for font/radius only -- the charge-level
+ fill_color below is a functional status signal (not a style choice)
+ and is never touched by a theme, and there's no header/accent region
+ to dither richer via ordered_dither_regions.
Shrinks icon/text sizes together (in 0.05 steps down to 0.3x) until
the whole stack actually fits the available height -- the classic
@@ -392,6 +418,7 @@ def build_battery(percent: int, lines: list[str], target_w: int, target_h: int,
num_lines = len(lines)
sizes = _battery_sizes(target_w, target_h, num_lines, scale)
+ theme = theme_tokens.resolve_theme(theme_name, "battery", palette_rgb)
fill_color = panel_style.battery_fill_color(percent, palette_rgb)
icon_h = sizes["icon_h"]
icon_w = int(icon_h * 1.8)
@@ -399,8 +426,8 @@ def build_battery(percent: int, lines: list[str], target_w: int, target_h: int,
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,
+ w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"],
+ font_regular=theme["font_regular"], font_bold=theme["font_bold"], 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),
@@ -413,7 +440,8 @@ def build_battery(percent: int, lines: list[str], target_w: int, target_h: int,
# --- Text "modern" style ---------------------------------------------------
-def build_text(cfg, target_w: int, target_h: int, palette_rgb: list | None = None) -> Image.Image:
+def build_text(cfg, target_w: int, target_h: int, palette_rgb: list | None = None,
+ theme_name: str | 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) --
@@ -425,7 +453,15 @@ def build_text(cfg, target_w: int, target_h: int, palette_rgb: list | None = Non
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)."""
+ palette-exact RGB image (see ordered_dither).
+
+ theme_name is accepted (every modern-style build_* function takes
+ one, threaded uniformly from frame.theme) but deliberately unused --
+ the text widget's own font_family is a per-widget, user-authored
+ choice (see widgets/text.py's module docstring), same carve-out
+ reasoning as run-level colors; a frame theme overriding it would
+ silently undo an explicit user choice. text.html.jinja also has no
+ card chrome (no radius/shadow) for a theme to touch."""
from PIL import ImageDraw
from . import widgets # local import: heavy-ish, and only "modern" text needs it
@@ -459,15 +495,19 @@ def build_text(cfg, target_w: int, target_h: int, palette_rgb: list | None = Non
# --- 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:
+ title: str = "Tasks", theme_name: str | None = None) -> 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)."""
+ matches classic style exactly; only the drawing differs -- and a
+ theme's accent never touches those per-owner chip colors (identity-
+ coding, not style). Has a header bar -- dithered at the theme's
+ accent_amplitude via ordered_dither_regions. Returns an already-
+ palette-exact RGB image (see ordered_dither)."""
from .calendar_render import _event_colors, _fmt_task_due
+ theme = theme_tokens.resolve_theme(theme_name, "tasks", palette_rgb)
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
@@ -491,35 +531,40 @@ def build_tasks(tasks: list[dict], target_w: int, target_h: int, palette_rgb: li
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)),
+ w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"], shadow=theme["shadow"],
+ font_regular=theme["font_regular"], font_bold=theme["font_bold"],
+ title=title, header_h=header_h, title_size=max(14, header_h - 12),
+ accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"],
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)
+ gutter = panel_style.GUTTER
+ accent_rect = (gutter, gutter, target_w - gutter, gutter + header_h)
+ return ordered_dither_regions(rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])])
# --- 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:
+ palette_rgb: list | None = None, theme_name: str | None = None,
+ widget_kind: str = "static") -> 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)."""
+ had (both currently draw with zero chrome of their own). Theme-aware
+ for radius/shadow only -- no text/header content to accent or font.
+ Returns an already-palette-exact RGB image (see ordered_dither)."""
import base64
+ theme = theme_tokens.resolve_theme(theme_name, widget_kind, palette_rgb)
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,
+ w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"], shadow=theme["shadow"],
image_b64=image_b64,
)
rendered = render_html_to_image(html, target_w, target_h)
diff --git a/server/app/migration.py b/server/app/migration.py
index 096f713..225f67d 100644
--- a/server/app/migration.py
+++ b/server/app/migration.py
@@ -880,6 +880,15 @@ def _migration_38(conn) -> None:
conn.execute(text("ALTER TABLE calendar_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'"))
+def _migration_39(conn) -> None:
+ """Frame-level curated theme for "modern" style widgets (models.
+ Frame.theme, see theme_tokens.THEMES) -- same guarded-per-column
+ shape as every prior migration."""
+ existing = {c["name"] for c in inspect(conn).get_columns("frames")}
+ if "theme" not in existing:
+ conn.execute(text("ALTER TABLE frames ADD COLUMN theme TEXT NOT NULL DEFAULT 'classic'"))
+
+
MIGRATIONS = [
(1, _migration_1),
(2, _migration_2),
@@ -919,6 +928,7 @@ MIGRATIONS = [
(36, _migration_36),
(37, _migration_37),
(38, _migration_38),
+ (39, _migration_39),
]
diff --git a/server/app/models.py b/server/app/models.py
index c9ba298..4f319b9 100644
--- a/server/app/models.py
+++ b/server/app/models.py
@@ -186,6 +186,11 @@ class Frame(Base):
# 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)
+ # Curated visual theme for "modern" (HTML/CSS) style widgets -- see
+ # theme_tokens.THEMES. Frame-level (like palette_rgb/dither_strength
+ # above), not per-widget, since a theme is "how this frame looks."
+ # Widgets rendered in classic (PIL) style ignore this entirely.
+ theme: Mapped[str] = mapped_column(String, default="classic")
# -- calendar mode (see calendar_feed.py, calendar_render.py,
# routers/device.py's RENDERERS["calendar"]) --
diff --git a/server/app/routers/api_frames.py b/server/app/routers/api_frames.py
index 2898622..1ef1784 100644
--- a/server/app/routers/api_frames.py
+++ b/server/app/routers/api_frames.py
@@ -25,7 +25,7 @@ from fastapi.responses import Response
from sqlalchemy import select
from sqlalchemy.orm import Session
-from .. import gitea_releases, grid, quiet_hours
+from .. import gitea_releases, grid, quiet_hours, theme_tokens
from ..auth import require_frame_control, require_frame_view, require_user_api
from ..db import frame_locked, get_db
from ..global_actions import GLOBAL_ACTIONS
@@ -108,6 +108,7 @@ def api_config_save(
photo_palette: list[str] | None = Form(None),
photo_palette_reset: bool | None = Form(None),
photo_dither_strength: float | None = Form(None),
+ theme: str | None = Form(None),
hold_duration_ms: int | None = Form(None),
next_hold_action: str | None = Form(None),
back_hold_action: str | None = Form(None),
@@ -196,6 +197,8 @@ def api_config_save(
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 theme is not None:
+ cfg.theme = theme if theme in theme_tokens.THEMES else "classic"
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_widgets.py b/server/app/routers/api_widgets.py
index 5924fc2..723ed70 100644
--- a/server/app/routers/api_widgets.py
+++ b/server/app/routers/api_widgets.py
@@ -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:
diff --git a/server/app/routers/frame_pages.py b/server/app/routers/frame_pages.py
index 7d43c8c..14849cc 100644
--- a/server/app/routers/frame_pages.py
+++ b/server/app/routers/frame_pages.py
@@ -17,7 +17,7 @@ from fastapi.templating import Jinja2Templates
from sqlalchemy import select
from sqlalchemy.orm import Session
-from .. import weather
+from .. import theme_tokens, weather
from ..auth import can_view_frame, current_user
from ..calendar_render import CALENDAR_VIEW_LABELS
from ..db import get_db
@@ -94,6 +94,7 @@ def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get
palette_to_hex=palette_to_hex,
photo_widget_id=photo_widget_id,
global_action_labels=GLOBAL_ACTION_LABELS,
+ themes=theme_tokens.THEMES,
)
diff --git a/server/app/static/frame_config.js b/server/app/static/frame_config.js
index 5442268..d9ceb00 100644
--- a/server/app/static/frame_config.js
+++ b/server/app/static/frame_config.js
@@ -260,6 +260,24 @@ document.getElementById('photo-palette-reset').addEventListener('click', () => {
savePalette('photo-palette', 'photo_palette', { photo_palette_reset: 'true', photo_dither_strength: '1' });
});
+// ---- Theme ----
+
+document.getElementById('theme-save').addEventListener('click', async () => {
+ const body = new URLSearchParams({ theme: document.getElementById('theme-select').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.');
+ loadPreview();
+ } catch (e) {
+ showStatus(false, e.message);
+ }
+});
+
// ---- 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/templates/frame_config.html b/server/app/templates/frame_config.html
index 23af66e..04a83ff 100644
--- a/server/app/templates/frame_config.html
+++ b/server/app/templates/frame_config.html
@@ -196,6 +196,23 @@
Compare against the physical panel before keeping it.
+
+ Theme
+ A curated visual style for widgets using the
+ "modern" (experimental) render style -- font, corner radius,
+ shadow, and header accent. Widgets rendered in the classic
+ style are unaffected. Photos are unaffected too (see Photos
+ configuration below).
+ Theme
+
+ {% for key, t in themes.items() %}
+ {{ t.label }}
+ {% endfor %}
+
+
+ Save
+
+
Photos configuration
Palette and dithering used only by
diff --git a/server/app/templates/widget_html/battery.html.jinja b/server/app/templates/widget_html/battery.html.jinja
index 2cfca71..513ee13 100644
--- a/server/app/templates/widget_html/battery.html.jinja
+++ b/server/app/templates/widget_html/battery.html.jinja
@@ -1,8 +1,8 @@