Redo modern-style widgets in a "bold minimal" language, not just a reskin
Build and push server image / test (push) Successful in 55s
Build and push server image / build-and-push (push) Successful in 4m40s
Build and push server image / deploy (push) Failing after 1m33s

The first modern-style rollout translated each widget's existing classic
layout into HTML/CSS -- same gradient headers, same rounded-shadowed
card, prettier chrome around an unchanged composition. This actually
redesigns weather (current/daily), calendar (all four views), tasks, and
battery: no card/shadow anywhere, a slim accent-colored rule instead of
a full gradient banner (and only that rule dithers at the richer accent
amplitude now, not the header text sitting on it), and a dominant hero
value (temperature/percent) instead of a centered icon+number of equal
weight. Padding and type sizes scale as a clamped proportion of widget
size instead of fixed pixel values. Text and static/whiteboard are left
alone -- text already had zero chrome and its styling is user content,
not this system's to redesign; framed_image's card was already minimal.

Direction was picked from three divergent mockups reviewed with the
maintainer, then verified against the real render pipeline (actual
Chromium render, actual ordered dithering, actual theme system) rather
than just eyeballed -- that caught a day-section/month-grid divider
color (#e2e6ec) that's nowhere near this panel's 6-color palette and was
dithering to invisible white; fixed with a real black hairline in the
one place (month view) that still needed one.
This commit is contained in:
2026-08-01 11:53:42 +00:00
parent 466efdb873
commit 455020cb1f
12 changed files with 443 additions and 253 deletions
+136 -41
View File
@@ -87,6 +87,20 @@ CATEGORY_EMOJI = {
"thunderstorm": "⛈️",
}
# Spelled-out condition word for the "bold minimal" current-mode layout --
# classic's build_current never needed one (icon + temp only), but the
# redesigned modern layout has room for a secondary line under the temp.
CATEGORY_LABEL = {
"clear": "Clear",
"partly_cloudy": "Partly cloudy",
"cloudy": "Cloudy",
"fog": "Fog",
"rain": "Rain",
"snow": "Snow",
"thunderstorm": "Thunderstorm",
}
def _rgb_to_hex(rgb: tuple[int, int, int]) -> str:
return "#%02x%02x%02x" % tuple(rgb)
@@ -98,6 +112,15 @@ def _darken_hex(rgb: tuple[int, int, int], factor: float = 0.75) -> str:
return _rgb_to_hex(tuple(max(0, round(c * factor)) for c in rgb))
def _clamp(value: float, lo: float, hi: float) -> float:
"""Keeps a size/spacing value proportional to widget dimensions
(`value` is always some fraction of target_w/target_h) while still
guaranteeing a floor (stays legible on a 1-2 grid-cell widget) and a
ceiling (stops padding/type from just growing forever on a
near-full-panel widget -- see build_current's docstring)."""
return max(lo, min(hi, value))
# --- Persistent background browser -------------------------------------
_loop: asyncio.AbstractEventLoop | None = None
@@ -268,29 +291,65 @@ def _day_label(day_date: date) -> str:
return day_date.strftime("%a")
def _short_city(city_label: str) -> str:
"""geocode_city (see docs/widgets.md's Weather widget section) hands
back a full "City, Region, Country" string -- fine for classic's
build_current (just drawn as one line, however wide) but wrong for
the bold-minimal layout's small top-row label, where a 1-2 grid-
cell widget has no room for the whole thing. Every phone-homescreen
weather widget this style is drawing from shows just the city, so
that's what this keeps -- CSS `text-overflow: ellipsis` is still in
the template as a safety net for a custom single-segment label
that's itself too long, not as the primary truncation strategy."""
return city_label.split(",")[0].strip()
def build_current(entry: dict | None, target_w: int, target_h: int, palette_rgb: list | None = None,
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). No header/accent region here (just a centered
icon+temp) -- theme-aware for font/radius only, plain ordered_dither
(no ordered_dither_regions call)."""
(see ordered_dither).
"Bold minimal" layout: the temperature itself is the graphic --
city label + icon in a top row, the temp (dominant) and spelled-out
condition anchored to the bottom, no card/border/shadow at all. This
is a deliberate departure from every other modern-style widget's
card-on-white-canvas chrome (see docs/widgets.md) -- there's nothing
for a "card" to visually separate from here, so `theme["radius"]`/
`theme["shadow"]` have no effect on this template; still theme-aware
for font_family only, same as before. No header/accent region either
(see ordered_dither_regions' docstring) -- a themed accent has
nothing to attach to in a chrome-free layout.
Every size below is a fraction of `base` (the widget's shorter side),
clamped to a floor/ceiling rather than fixed -- so a 1-grid-cell
widget doesn't get comically oversized padding relative to its
content, and a near-full-panel widget doesn't get comically large
padding relative to *its* content either. Floors/ceilings are tuned
by eye against real widget sizes, not derived from anything."""
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)
base = min(target_w, target_h)
pad = _clamp(base * 0.09, 10, 26)
icon_size = _clamp(base * 0.20, 22, 60)
temp_size = _clamp(base * 0.46, 30, 150)
deg_size = _clamp(temp_size * 0.28, 12, 40)
cond_size = _clamp(base * 0.075, 11, 20)
city_size = _clamp(base * 0.06, 10, 15)
template = _jinja_env.get_template("weather_current.html.jinja")
html = template.render(
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"],
w=target_w, h=target_h, pad=round(pad),
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),
condition=CATEGORY_LABEL.get(entry["category"], ""),
temp=round(entry["temp"]), unit_suffix=unit_suffix, city_label=_short_city(city_label),
icon_size=round(icon_size), temp_size=round(temp_size), deg_size=round(deg_size),
cond_size=round(cond_size), city_size=round(city_size),
)
rendered = render_html_to_image(html, target_w, target_h)
return ordered_dither(rendered, palette_rgb)
@@ -300,9 +359,20 @@ def build_daily(daily: dict[str, dict], target_w: int, target_h: int, palette_rg
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). 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)."""
ordered_dither).
"Bold minimal" layout, matching build_current: no card/border/
shadow, a row of day columns each carrying its own high (dominant)
/ low (muted) temp the same way build_current makes the current
temp dominant. The old full-width gradient banner is gone --
city_label, when set, is a slim accent-colored rule (not a block)
with the city name understated beneath it, so there's still
somewhere for a theme's accent hue to show up (dithered richer via
ordered_dither_regions, same mechanism as before) without dragging
back the "card with a colored header" chrome this redesign is
moving away from. `theme["radius"]`/`theme["shadow"]` are unused
here for the same reason as build_current -- no card for them to
apply to."""
img = Image.new("RGB", (target_w, target_h), (255, 255, 255))
days = list(daily.items())
if not days:
@@ -310,9 +380,16 @@ def build_daily(daily: dict[str, dict], target_w: int, target_h: int, palette_rg
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))
icon_size = max(16, min(col_w // 2, 36))
base = min(target_w, target_h)
pad = round(_clamp(base * 0.08, 10, 22))
col_w = max(1, (target_w - pad * 2) // len(days))
col_gap = round(_clamp(col_w * 0.12, 4, 16))
icon_size = round(_clamp(col_w * 0.30, 16, 32))
day_label_size = round(_clamp(col_w * 0.15, 10, 14))
high_size = round(_clamp(col_w * 0.32, 16, 32))
low_size = round(max(9, high_size * 0.55))
city_size = round(_clamp(base * 0.055, 10, 14))
accent_h = round(_clamp(base * 0.025, 4, 8))
day_entries = [
{
"label": _day_label(date.fromisoformat(day_str)),
@@ -324,18 +401,17 @@ 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=theme["radius"], shadow=theme["shadow"],
w=target_w, h=target_h, pad=pad, col_gap=col_gap,
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,
city_label=_short_city(city_label), city_size=city_size, accent_h=accent_h,
accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"],
days=day_entries, icon_size=icon_size, day_label_size=day_label_size,
high_size=high_size, low_size=low_size, unit_suffix=unit_suffix,
)
rendered = render_html_to_image(html, target_w, target_h)
if header_h <= 0:
if not city_label:
return ordered_dither(rendered, palette_rgb)
gutter = panel_style.GUTTER
accent_rect = (gutter, gutter, target_w - gutter, gutter + header_h)
accent_rect = (pad, pad, target_w - pad, pad + accent_h)
return ordered_dither_regions(rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])])
@@ -375,12 +451,12 @@ def render_weather_preview_png(mode: str, data, orientation: str, palette_rgb: l
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
icon_h = max(14, int(base * 0.15 * scale))
pct_size = max(20, int(base * 0.42 * scale))
line_size = max(9, int(base * 0.085 * scale))
gap = max(4, int(base * 0.035 * scale))
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}
return {"icon_h": icon_h, "pct_size": pct_size, "line_size": line_size, "gap": gap, "total": total}
def build_battery(percent: int, lines: list[str], target_w: int, target_h: int,
@@ -390,18 +466,26 @@ def build_battery(percent: int, lines: list[str], target_w: int, target_h: int,
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). Theme-aware for font/radius only -- the charge-level
ordered_dither). Theme-aware for font 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.
Bold-minimal: no card (theme["radius"] unused, same carve-out as
weather's build_current -- see docs/widgets.md); the percent is the
hero value anchored toward the bottom, same treatment build_current
gives the temperature, with the icon small and secondary above it
instead of both competing at the same size like the old centered
layout did.
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
pad = round(_clamp(min(target_w, target_h) * 0.09, 10, 26))
avail_h = target_h - pad * 2
num_lines = len(lines)
scale = 1.0
sizes = _battery_sizes(target_w, target_h, num_lines, scale)
@@ -426,13 +510,13 @@ 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=theme["radius"],
w=target_w, h=target_h, pad=pad,
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),
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"],
pct_size=sizes["pct_size"], line_size=sizes["line_size"], line_gap=sizes["gap"],
)
rendered = render_html_to_image(html, target_w, target_h)
return ordered_dither(rendered, palette_rgb)
@@ -502,17 +586,28 @@ def build_tasks(tasks: list[dict], target_w: int, target_h: int, palette_rgb: li
logic the classic renderer uses) so a task's color chip/due string
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)."""
coding, not style) or the done-checkbox fill (a completion state
signal, not a style choice -- it happens to reuse the accent color,
but that's incidental, same as before this redesign).
Bold-minimal: no card (theme["shadow"]/["radius"] unused, same
carve-out as calendar's redesigned views -- see docs/widgets.md).
The old gradient header banner is now a slim accent rule + plain
bold title, matching every calendar view's day-header language --
only the rule dithers at the theme's richer accent_amplitude, not
the title text sitting on it. 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)
base = min(target_w, target_h)
accent_h = round(_clamp(base * 0.025, 3, 6))
title_size = max(14, base // 12)
body_size = max(11, base // 20)
row_h = body_size + 14
box_size = max(10, body_size - 4)
avail_h = target_h - header_h - 16
header_h = accent_h + 6 + title_size
avail_h = target_h - panel_style.GUTTER * 2 - header_h - 8
max_rows = max(0, avail_h // row_h)
owners_seen: list[str] = []
@@ -531,15 +626,15 @@ 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=theme["radius"], shadow=theme["shadow"],
w=target_w, h=target_h, gutter=panel_style.GUTTER,
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"],
title=title, header_h=header_h, accent_h=accent_h, title_size=title_size,
accent_start=theme["accent_hex"],
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)
gutter = panel_style.GUTTER
accent_rect = (gutter, gutter, target_w - gutter, gutter + header_h)
accent_rect = (gutter, gutter, target_w - gutter, gutter + accent_h)
return ordered_dither_regions(rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])])