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
+50 -33
View File
@@ -72,9 +72,15 @@ 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", 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."""
"""HTML/CSS-rendered analogue of calendar_render._build_agenda.
Bold-minimal: no card/border/shadow (theme["radius"]/theme["shadow"]
are unused, same carve-out as weather's build_current/build_daily --
see docs/widgets.md). The day header is plain ink text under a slim
accent-colored rule instead of white text on a full gradient band --
only that thin rule dithers at the theme's richer accent_amplitude
now, not the header text sitting on top of it, which is a legibility
improvement over the old design, not just a visual one."""
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)
@@ -82,13 +88,14 @@ def build_agenda(events: list[dict], browse_offset: int, target_w: int, target_h
weather_size = max(10, body_size - 2)
row_h = body_size + 14
unit_suffix = "F" if weather_units == "fahrenheit" else "C"
accent_h = round(html_render._clamp(min(target_w, target_h) * 0.025, 4, 8))
# 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)
header_h = accent_h + 10 + title_size + ((weather_size + 10) if has_weather else 0)
owners_seen: list[str] = []
data = _day_section_data(day, events, tz, palette_rgb, weather_cities, weather_units, owners_seen,
@@ -96,16 +103,16 @@ def build_agenda(events: list[dict], browse_offset: int, target_w: int, target_h
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=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"],
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"],
header=data["header"], title_size=title_size, header_h=header_h, accent_h=accent_h,
accent_start=theme["accent_hex"], 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)
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 html_render.ordered_dither_regions(
rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])]
)
@@ -115,8 +122,9 @@ def build_today_tomorrow(events: list[dict], browse_offset: int, target_w: int,
palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
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), each with its own
header bar dithered richer via ordered_dither_regions."""
-- two day-sections stacked (see _day_section_data). Bold-minimal, no
card (see build_agenda's docstring) -- each section's own slim accent
rule dithers richer via ordered_dither_regions, not its header text."""
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
@@ -125,13 +133,14 @@ def build_today_tomorrow(events: list[dict], browse_offset: int, target_w: int,
weather_size = max(9, body_size - 2)
row_h = body_size + 12
unit_suffix = "F" if weather_units == "fahrenheit" else "C"
accent_h = round(html_render._clamp(min(target_w, target_h) * 0.02, 3, 6))
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)
header_h = accent_h + 8 + title_size + ((weather_size + 8) if any_weather else 0)
owners_seen: list[str] = []
days = [
@@ -142,16 +151,16 @@ def build_today_tomorrow(events: list[dict], browse_offset: int, target_w: int,
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=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"],
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,
days=days, title_size=title_size, header_h=header_h, accent_h=accent_h,
accent_start=theme["accent_hex"], 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)
gutter = panel_style.GUTTER
accent_regions = [
((gutter, gutter + i * section_h, target_w - gutter, gutter + i * section_h + header_h),
((gutter, gutter + i * section_h, target_w - gutter, gutter + i * section_h + accent_h),
theme["accent_amplitude"])
for i in range(len(days))
]
@@ -184,12 +193,13 @@ def build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
body_size = max(9, min(target_w, target_h) // (18 + days))
weather_size = max(8, body_size - 2)
row_h = body_size + 10
accent_h = round(html_render._clamp(min(target_w, target_h) * 0.018, 3, 5))
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)
header_h = accent_h + 6 + title_size + ((weather_size + 6) 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)
@@ -197,15 +207,15 @@ 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=gutter, radius=theme["radius"], shadow=theme["shadow"],
w=target_w, h=target_h, gutter=gutter,
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,
days=day_sections, title_size=title_size, header_h=header_h, accent_h=accent_h,
accent_start=theme["accent_hex"], 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)
accent_regions = [
((gutter, gutter + i * section_h, target_w - gutter, gutter + i * section_h + header_h),
((gutter, gutter + i * section_h, target_w - gutter, gutter + i * section_h + accent_h),
theme["accent_amplitude"])
for i in range(len(day_sections))
]
@@ -216,13 +226,14 @@ def build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
weather_size = max(8, chip_size - 1)
col_w = max(1, (target_w - panel_style.GUTTER * 2) // days)
row_h = chip_size + 8
accent_h = round(html_render._clamp(min(target_w, target_h) * 0.02, 3, 6))
# 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)
header_h = header_size + 8 + (weather_size + 4 if weather_cities else 0)
max_rows = max(0, (target_h - panel_style.GUTTER * 2 - accent_h - 6 - header_h) // row_h)
cols = []
for i in range(days):
@@ -242,14 +253,14 @@ 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=gutter, radius=theme["radius"], shadow=theme["shadow"],
w=target_w, h=target_h, gutter=gutter,
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"],
header_h=header_h, accent_h=accent_h, weather_size=weather_size, unit_suffix=unit_suffix,
accent_start=theme["accent_hex"],
)
rendered = html_render.render_html_to_image(html, target_w, target_h)
accent_rect = (gutter, gutter, target_w - gutter, gutter + header_h)
accent_rect = (gutter, gutter, target_w - gutter, gutter + accent_h)
return html_render.ordered_dither_regions(rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])])
@@ -260,9 +271,15 @@ def build_month(events: list[dict], browse_offset: int, target_w: int, target_h:
classic renderer (real text at typical month-cell size is close to
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."""
are never touched by a theme.
Bold-minimal: no card (see build_agenda's docstring); the old flat
accent-colored weekday-name band is now a slim accent rule above
plain bold weekday labels, matching every other calendar view's
header treatment -- only that rule dithers at the theme's richer
accent_amplitude via ordered_dither_regions. "Today" is still called
out with a small accent-filled pill around its day number (a
genuinely small accent surface, not a band, so it was left alone)."""
theme = theme_tokens.resolve_theme(theme_name, "calendar", palette_rgb)
gutter = panel_style.GUTTER
today = datetime.now(tz).date()
@@ -275,6 +292,7 @@ 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_h = round(html_render._clamp(min(target_w, target_h) * 0.02, 3, 6))
owners_seen: list[str] = []
weeks = []
@@ -291,14 +309,13 @@ 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=gutter, radius=theme["radius"], shadow=theme["shadow"],
w=target_w, h=target_h, gutter=gutter,
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
day_names=day_names, weeks=weeks,
day_names=day_names, weeks=weeks, accent_h=accent_h,
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)
weekday_row_h = header_size + 12
accent_rect = (gutter, gutter, target_w - gutter, gutter + weekday_row_h)
accent_rect = (gutter, gutter, target_w - gutter, gutter + accent_h)
return html_render.ordered_dither_regions(rendered, palette_rgb,
accent_regions=[(accent_rect, theme["accent_amplitude"])])
+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"])])
@@ -1,19 +1,26 @@
{% macro day_section(header, weather_entries, weather_size, unit_suffix, rows, more_count, row_h, body_size, title_size, accent_start, accent_end, header_h) %}
{% macro day_section(header, weather_entries, weather_size, unit_suffix, rows, more_count, row_h, body_size, title_size, accent_start, header_h, accent_h) %}
<div class="day-section">
{#- 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). #}
<div class="day-header" style="background: linear-gradient(135deg, {{ accent_start }} 0%, {{ accent_end }} 100%); font-size: {{ title_size }}px; height: {{ header_h }}px;">
<div class="day-header-title">{{ header }}</div>
{% if weather_entries %}
<div class="day-weather-row" style="font-size: {{ weather_size }}px;">
{% for we in weather_entries %}
<div class="day-weather-entry"><span class="day-weather-icon" style="font-size: {{ weather_size * 1.3 }}px;">{{ we.emoji }}</span><span>{{ we.high }}&deg;/{{ we.low }}&deg;{{ unit_suffix }}</span></div>
{% endfor %}
</div>
{% endif %}
identical fix/reasoning). Bold-minimal: a slim accent-colored
rule (not a full gradient band) carries the theme identity --
the header text itself is plain ink, dithered at the base
amplitude like everything else, not the richer accent amplitude
the old white-on-gradient text needed to stay legible. #}
<div class="day-header" style="height: {{ header_h }}px;">
<div class="accent-rule" style="height: {{ accent_h }}px; background: {{ accent_start }};"></div>
<div class="day-header-row">
<div class="day-header-title" style="font-size: {{ title_size }}px;">{{ header }}</div>
{% if weather_entries %}
<div class="day-weather-row" style="font-size: {{ weather_size }}px;">
{% for we in weather_entries %}
<div class="day-weather-entry"><span class="day-weather-icon" style="font-size: {{ weather_size * 1.3 }}px;">{{ we.emoji }}</span><span>{{ we.high }}&deg;/{{ we.low }}&deg;{{ unit_suffix }}</span></div>
{% endfor %}
</div>
{% endif %}
</div>
</div>
<div class="day-rows">
{% if not rows and not more_count %}
@@ -4,18 +4,9 @@
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
.card {
width: {{ w - gutter * 2 }}px;
height: {{ h - gutter * 2 }}px;
margin: {{ gutter }}px;
border-radius: {{ radius }}px;
overflow: hidden;
background: #ffffff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
.wrap {
width: {{ w }}px; height: {{ h }}px; padding: {{ pad }}px;
display: flex; flex-direction: column; justify-content: space-between;
}
.icon-wrap { display: flex; align-items: center; }
.icon-body {
@@ -24,7 +15,6 @@
border: {{ stroke }}px solid #000000;
border-radius: {{ icon_radius }}px;
padding: {{ stroke }}px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
}
.icon-fill {
width: {{ fill_pct }}%;
@@ -38,16 +28,19 @@
background: #000000;
border-radius: 0 {{ nub_radius }}px {{ nub_radius }}px 0;
}
.pct { font-weight: 700; font-size: {{ pct_size }}px; line-height: 1; color: {{ fill_color }}; }
.line { font-weight: 400; font-size: {{ line_size }}px; line-height: 1; color: #5b6674; }
.pct { font-weight: 700; font-size: {{ pct_size }}px; line-height: 0.85; color: {{ fill_color }}; letter-spacing: -0.02em; }
.lines { margin-top: {{ line_gap }}px; }
.line { font-weight: 400; font-size: {{ line_size }}px; line-height: 1.3; color: #5b6674; }
</style></head>
<body>
<div class="card">
<div class="wrap">
<div class="icon-wrap">
<div class="icon-body"><div class="icon-fill"></div></div>
<div class="icon-nub"></div>
</div>
<div class="pct">{{ percent }}%</div>
{% for line in lines %}<div class="line">{{ line }}</div>{% endfor %}
<div>
<div class="pct">{{ percent }}%</div>
<div class="lines">{% for line in lines %}<div class="line">{{ line }}</div>{% endfor %}</div>
</div>
</div>
</body></html>
@@ -4,33 +4,32 @@
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
.card {
.wrap {
width: {{ w - gutter * 2 }}px;
height: {{ h - gutter * 2 }}px;
margin: {{ gutter }}px;
border-radius: {{ radius }}px;
overflow: hidden;
{% if shadow %}box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2);{% endif %}
background: #ffffff;
display: flex;
flex-direction: column;
}
.day-section { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }
.day-header { flex: 0 0 auto; color: #ffffff; font-weight: 700; line-height: 1.2; padding: 10px 16px; overflow: hidden; }
.day-weather-row { display: flex; gap: 14px; margin-top: 6px; }
.day-weather-entry { display: flex; align-items: center; gap: 4px; color: rgba(255,255,255,0.9); }
.day-section { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; overflow: hidden; }
.day-header { flex: 0 0 auto; display: flex; flex-direction: column; overflow: hidden; }
.accent-rule { width: 100%; border-radius: 100px; }
.day-header-row { flex: 1 1 auto; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding-top: 6px; min-height: 0; }
.day-header-title { font-weight: 700; color: #17233b; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.day-weather-row { display: flex; gap: 14px; flex: 0 0 auto; }
.day-weather-entry { display: flex; align-items: center; gap: 4px; color: #5b6674; }
.day-weather-icon { line-height: 1; }
.day-rows { flex: 1 1 auto; padding: 8px 14px; overflow: hidden; }
.day-rows { flex: 1 1 auto; padding: 8px 0; overflow: hidden; }
.day-row { display: flex; align-items: center; gap: 8px; }
.day-chip { display: flex; height: 12px; width: 10px; border-radius: 3px; overflow: hidden; flex: 0 0 auto; }
.day-chip span { flex: 1 1 0; }
.day-time { color: #5b6674; flex: 0 0 auto; white-space: nowrap; }
.day-time { color: #5b6674; flex: 0 0 auto; white-space: nowrap; font-variant-numeric: tabular-nums; }
.day-summary { color: #17233b; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.day-empty, .day-more { color: #5b6674; padding-top: 4px; }
</style></head>
<body>
{% import "_calendar_day_section.html.jinja" as ds %}
<div class="card">
{{ ds.day_section(header, weather_entries, weather_size, unit_suffix, rows, more_count, row_h, body_size, title_size, accent_start, accent_end, header_h) }}
<div class="wrap">
{{ ds.day_section(header, weather_entries, weather_size, unit_suffix, rows, more_count, row_h, body_size, title_size, accent_start, header_h, accent_h) }}
</div>
</body></html>
@@ -4,22 +4,27 @@
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
.card {
.wrap {
width: {{ w - gutter * 2 }}px;
height: {{ h - gutter * 2 }}px;
margin: {{ gutter }}px;
border-radius: {{ radius }}px;
overflow: hidden;
{% if shadow %}box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2);{% endif %}
background: #ffffff;
display: flex;
flex-direction: column;
}
.weekday-row { display: flex; flex: 0 0 auto; background: {{ accent_start }}; }
.weekday-cell { flex: 1 1 0; color: #ffffff; font-weight: 700; font-size: {{ header_size }}px; text-align: center; padding: 4px 0; }
.weeks { flex: 1 1 auto; display: flex; flex-direction: column; }
.accent-rule { flex: 0 0 auto; width: 100%; height: {{ accent_h }}px; border-radius: 100px; background: {{ accent_start }}; }
.weekday-row { display: flex; flex: 0 0 auto; margin-top: 6px; }
.weekday-cell { flex: 1 1 0; color: #17233b; font-weight: 700; font-size: {{ header_size }}px; text-align: center; padding: 4px 0; }
.weeks { flex: 1 1 auto; display: flex; flex-direction: column; margin-top: 2px; }
.week-row { flex: 1 1 0; display: flex; }
.day-cell { flex: 1 1 0; border: 1px solid #e2e6ec; padding: 4px; min-width: 0; overflow: hidden; }
{#- A real palette color (pure black), not a pale gray -- this panel's
6-color palette has no gray to dither toward, so #e2e6ec-style
"hairlines" don't survive at all (confirmed by sampling actual
rendered pixels: every one came back pure white). Horizontal
rules only, between week rows -- enough for the grid to scan
top-to-bottom without boxing every single day cell, which read
more like the old structured-dashboard mockup than bold-minimal. #}
.week-row + .week-row { border-top: 1px solid #000000; }
.day-cell { flex: 1 1 0; padding: 4px; min-width: 0; overflow: hidden; }
{#- Bold everywhere, including out-of-month -- de-emphasis is via
smaller size only, not weight or a gray color. Regular-weight and
gray text are both individually fragile under Bayer ordered
@@ -40,7 +45,8 @@
.dot-more { font-size: {{ day_size * 0.8 }}px; color: #5b6674; }
</style></head>
<body>
<div class="card">
<div class="wrap">
<div class="accent-rule"></div>
<div class="weekday-row">
{% for name in day_names %}<div class="weekday-cell">{{ name }}</div>{% endfor %}
</div>
@@ -4,36 +4,39 @@
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
.card {
.wrap {
width: {{ w - gutter * 2 }}px;
height: {{ h - gutter * 2 }}px;
margin: {{ gutter }}px;
border-radius: {{ radius }}px;
overflow: hidden;
{% if shadow %}box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2);{% endif %}
background: #ffffff;
display: flex;
flex-direction: column;
}
.day-section { display: flex; flex-direction: column; flex: 1 1 0; min-height: 0; overflow: hidden; }
.day-section + .day-section { border-top: 1px solid #e2e6ec; }
.day-header { flex: 0 0 auto; color: #ffffff; font-weight: 700; line-height: 1.2; padding: 8px 16px; overflow: hidden; }
.day-weather-row { display: flex; gap: 14px; margin-top: 4px; }
.day-weather-entry { display: flex; align-items: center; gap: 4px; color: rgba(255,255,255,0.9); }
{#- No divider line -- #e2e6ec was invisible on this panel's 6-color
palette anyway (no gray to dither toward, confirmed by sampling
rendered pixels), and the accent rule + margin at the top of the
next section already reads as a clear boundary without one. #}
.day-section + .day-section { margin-top: 8px; }
.day-header { flex: 0 0 auto; display: flex; flex-direction: column; overflow: hidden; }
.accent-rule { width: 100%; border-radius: 100px; }
.day-header-row { flex: 1 1 auto; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding-top: 4px; min-height: 0; }
.day-header-title { font-weight: 700; color: #17233b; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.day-weather-row { display: flex; gap: 12px; flex: 0 0 auto; }
.day-weather-entry { display: flex; align-items: center; gap: 4px; color: #5b6674; }
.day-weather-icon { line-height: 1; }
.day-rows { flex: 1 1 auto; padding: 6px 14px; overflow: hidden; }
.day-rows { flex: 1 1 auto; padding: 4px 0; overflow: hidden; }
.day-row { display: flex; align-items: center; gap: 8px; }
.day-chip { display: flex; height: 12px; width: 10px; border-radius: 3px; overflow: hidden; flex: 0 0 auto; }
.day-chip span { flex: 1 1 0; }
.day-time { color: #5b6674; flex: 0 0 auto; white-space: nowrap; }
.day-time { color: #5b6674; flex: 0 0 auto; white-space: nowrap; font-variant-numeric: tabular-nums; }
.day-summary { color: #17233b; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.day-empty, .day-more { color: #5b6674; padding-top: 2px; }
</style></head>
<body>
{% import "_calendar_day_section.html.jinja" as ds %}
<div class="card">
<div class="wrap">
{% for day in days %}
{{ ds.day_section(day.header, day.weather_entries, weather_size, unit_suffix, day.rows, day.more_count, row_h, body_size, title_size, accent_start, accent_end, header_h) }}
{{ ds.day_section(day.header, day.weather_entries, weather_size, unit_suffix, day.rows, day.more_count, row_h, body_size, title_size, accent_start, header_h, accent_h) }}
{% endfor %}
</div>
</body></html>
@@ -4,32 +4,36 @@
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
.card {
.wrap {
width: {{ w - gutter * 2 }}px;
height: {{ h - gutter * 2 }}px;
margin: {{ gutter }}px;
border-radius: {{ radius }}px;
overflow: hidden;
{% if shadow %}box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2);{% endif %}
background: #ffffff;
display: flex;
flex-direction: column;
}
.accent-rule { flex: 0 0 auto; width: 100%; height: {{ accent_h }}px; border-radius: 100px; background: {{ accent_start }}; }
.cols { flex: 1 1 auto; display: flex; margin-top: 6px; min-height: 0; }
.col { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; }
.col + .col { border-left: 1px solid #e2e6ec; }
{#- No divider line -- #e2e6ec was invisible on this panel's 6-color
palette anyway (no gray to dither toward, confirmed by sampling
rendered pixels); the shared accent rule above already frames the
whole week as one unit, and each column's own label anchors it. #}
.col + .col { margin-left: 4px; }
.col-header {
/* Fixed height (not auto) -- every column must be exactly this tall
regardless of whether THIS particular day has a weather entry, or
columns with/without weather misalign their event rows to
different starting Y positions across the week grid. */
{#- Fixed height (not auto) -- every column must be exactly this tall
regardless of whether THIS particular day has a weather entry, or
columns with/without weather misalign their event rows to
different starting Y positions across the week grid. #}
height: {{ header_h }}px;
flex: 0 0 auto;
background: linear-gradient(135deg, {{ accent_start }} 0%, {{ accent_end }} 100%);
color: #ffffff; font-weight: 700; font-size: {{ header_size }}px;
padding: 6px 6px;
padding: 2px 6px 4px;
overflow: hidden;
}
.col-header .label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.col-weather { display: flex; align-items: center; gap: 3px; color: rgba(255,255,255,0.9); font-size: {{ weather_size }}px; margin-top: 2px; }
.col-header .label {
font-weight: 700; color: #17233b; font-size: {{ header_size }}px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.col-weather { display: flex; align-items: center; gap: 3px; color: #5b6674; font-size: {{ weather_size }}px; margin-top: 2px; }
.col-rows { flex: 1 1 auto; padding: 4px; overflow: hidden; }
.col-row { display: flex; align-items: center; gap: 4px; min-width: 0; }
.col-chip { width: 7px; height: 7px; border-radius: 2px; flex: 0 0 auto; }
@@ -41,20 +45,23 @@
.col-more { font-size: {{ chip_size }}px; color: #5b6674; }
</style></head>
<body>
<div class="card">
{% for col in cols %}
<div class="col">
<div class="col-header">
<div class="label">{{ col.label }}</div>
{% if col.weather %}<div class="col-weather"><span>{{ col.weather.emoji }}</span><span>{{ col.weather.high }}&deg;/{{ col.weather.low }}&deg;{{ unit_suffix }}</span></div>{% endif %}
<div class="wrap">
<div class="accent-rule"></div>
<div class="cols">
{% for col in cols %}
<div class="col">
<div class="col-header">
<div class="label">{{ col.label }}</div>
{% if col.weather %}<div class="col-weather"><span>{{ col.weather.emoji }}</span><span>{{ col.weather.high }}&deg;/{{ col.weather.low }}&deg;{{ unit_suffix }}</span></div>{% endif %}
</div>
<div class="col-rows">
{% for row in col.rows %}
<div class="col-row"><div class="col-chip" style="background:{{ row.color }};"></div><div class="col-summary">{{ row.summary }}</div></div>
{% endfor %}
{% if col.more_count %}<div class="col-more">+{{ col.more_count }}</div>{% endif %}
</div>
</div>
<div class="col-rows">
{% for row in col.rows %}
<div class="col-row"><div class="col-chip" style="background:{{ row.color }};"></div><div class="col-summary">{{ row.summary }}</div></div>
{% endfor %}
{% if col.more_count %}<div class="col-more">+{{ col.more_count }}</div>{% endif %}
</div>
</div>
{% endfor %}
{% endfor %}
</div>
</div>
</body></html>
@@ -4,27 +4,21 @@
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
.card {
.wrap {
width: {{ w - gutter * 2 }}px;
height: {{ h - gutter * 2 }}px;
margin: {{ gutter }}px;
border-radius: {{ radius }}px;
overflow: hidden;
{% if shadow %}box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2);{% endif %}
background: #ffffff;
display: flex;
flex-direction: column;
}
.header {
height: {{ header_h }}px;
flex: 0 0 auto;
background: linear-gradient(135deg, {{ accent_start }} 0%, {{ accent_end }} 100%);
display: flex;
align-items: center;
padding: 0 16px;
.header { flex: 0 0 auto; height: {{ header_h }}px; display: flex; flex-direction: column; overflow: hidden; }
.accent-rule { width: 100%; height: {{ accent_h }}px; border-radius: 100px; background: {{ accent_start }}; }
.title {
flex: 1 1 auto; display: flex; align-items: center;
color: #17233b; font-weight: 700; font-size: {{ title_size }}px; line-height: 1;
padding-top: 4px;
}
.header .title { color: #ffffff; font-weight: 700; font-size: {{ title_size }}px; line-height: 1; }
.rows { flex: 1 1 auto; padding: 8px 14px; overflow: hidden; }
.rows { flex: 1 1 auto; padding-top: 4px; overflow: hidden; }
.row { display: flex; align-items: center; gap: 8px; height: {{ row_h }}px; }
.chip { display: flex; height: 12px; width: 10px; border-radius: 3px; overflow: hidden; flex: 0 0 auto; }
.chip span { flex: 1 1 0; }
@@ -33,7 +27,7 @@
border: 2px solid #17233b;
}
.box.done { border-color: {{ accent_start }}; background: {{ accent_start }}; }
.due { font-size: {{ body_size }}px; color: #5b6674; flex: 0 0 auto; white-space: nowrap; }
.due { font-size: {{ body_size }}px; color: #5b6674; flex: 0 0 auto; white-space: nowrap; font-variant-numeric: tabular-nums; }
.summary {
font-size: {{ body_size }}px; color: #17233b; line-height: 1.2;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
@@ -42,8 +36,11 @@
.more { font-size: {{ body_size }}px; color: #5b6674; padding-top: 2px; }
</style></head>
<body>
<div class="card">
<div class="header"><div class="title">{{ title }}</div></div>
<div class="wrap">
<div class="header">
<div class="accent-rule"></div>
<div class="title">{{ title }}</div>
</div>
<div class="rows">
{% if not rows %}
<div class="empty">Nothing outstanding</div>
@@ -4,27 +4,34 @@
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
.card {
width: {{ w - gutter * 2 }}px;
height: {{ h - gutter * 2 }}px;
margin: {{ gutter }}px;
border-radius: {{ radius }}px;
overflow: hidden;
background: #ffffff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
.wrap {
width: {{ w }}px; height: {{ h }}px; padding: {{ pad }}px;
display: flex; flex-direction: column; justify-content: space-between;
}
.icon { font-size: {{ icon_size }}px; line-height: 1; filter: drop-shadow(0 3px 4px rgba(0, 0, 0, 0.18)); }
.temp { font-weight: 700; font-size: {{ temp_size }}px; color: #17233b; }
.city { font-weight: 400; font-size: {{ label_size }}px; color: #5b6674; }
.top { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
.city {
flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-weight: 700; font-size: {{ city_size }}px; line-height: 1.2;
letter-spacing: 0.12em; text-transform: uppercase; color: #5b6674;
}
.icon { flex: 0 0 auto; font-size: {{ icon_size }}px; line-height: 1; }
.temp-row { display: flex; align-items: flex-start; }
.temp {
font-weight: 700; font-size: {{ temp_size }}px; line-height: 0.85;
color: #17233b; letter-spacing: -0.03em;
}
.deg { font-weight: 400; font-size: {{ deg_size }}px; line-height: 1.3; color: #5b6674; }
.cond { font-weight: 400; font-size: {{ cond_size }}px; color: #5b6674; margin-top: {{ (pad * 0.25) | round(0, 'floor') }}px; }
</style></head>
<body>
<div class="card">
<div class="icon">{{ emoji }}</div>
<div class="temp">{{ temp }}&deg;{{ unit_suffix }}</div>
{% if city_label %}<div class="city">{{ city_label }}</div>{% endif %}
<div class="wrap">
<div class="top">
{% if city_label %}<div class="city">{{ city_label }}</div>{% else %}<div></div>{% endif %}
<div class="icon">{{ emoji }}</div>
</div>
<div>
<div class="temp-row"><div class="temp">{{ temp }}</div><div class="deg">&deg;{{ unit_suffix }}</div></div>
{% if condition %}<div class="cond">{{ condition }}</div>{% endif %}
</div>
</div>
</body></html>
@@ -4,46 +4,40 @@
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
.card {
width: {{ w - gutter * 2 }}px;
height: {{ h - gutter * 2 }}px;
margin: {{ gutter }}px;
border-radius: {{ radius }}px;
overflow: hidden;
{% if shadow %}box-shadow: 0 4px 14px rgba(0, 20, 60, 0.25);{% endif %}
background: #ffffff;
.wrap { width: {{ w }}px; height: {{ h }}px; padding: {{ pad }}px; display: flex; flex-direction: column; }
.accent-bar {
flex: 0 0 auto; height: {{ accent_h }}px; width: 100%;
border-radius: {{ (accent_h / 2) | round(0, 'floor') }}px;
background: linear-gradient(90deg, {{ accent_start }} 0%, {{ accent_end }} 100%);
}
.header {
height: {{ header_h }}px;
background: linear-gradient(135deg, {{ accent_start }} 0%, {{ accent_end }} 100%);
display: flex;
align-items: center;
padding: 0 16px;
.city {
flex: 0 0 auto; margin-top: 6px; font-weight: 700; font-size: {{ city_size }}px;
letter-spacing: 0.12em; text-transform: uppercase; color: #5b6674;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%;
}
.header .title { color: #ffffff; font-weight: 700; font-size: {{ title_size }}px; }
.body { display: flex; height: calc(100% - {{ header_h }}px); padding: 12px 8px; }
.col {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 5px;
.days { flex: 1 1 auto; display: flex; align-items: center; gap: {{ col_gap }}px; }
.col { flex: 1 1 0; display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 0; }
.day-label {
font-weight: 700; font-size: {{ day_label_size }}px; letter-spacing: 0.06em;
text-transform: uppercase; color: #5b6674;
}
.day { font-weight: 600; font-size: {{ label_size }}px; color: #17233b; }
.icon { font-size: {{ icon_size }}px; line-height: 1; }
.temps { font-weight: 700; font-size: {{ label_size }}px; color: #17233b; }
.temps .low { color: #6b7788; font-weight: 400; }
.temps { display: flex; align-items: baseline; gap: 3px; }
.high { font-weight: 700; font-size: {{ high_size }}px; line-height: 1; color: #17233b; }
.low { font-weight: 400; font-size: {{ low_size }}px; line-height: 1; color: #5b6674; }
</style></head>
<body>
<div class="card">
{% if city_label %}<div class="header"><div class="title">{{ city_label }}</div></div>{% endif %}
<div class="body">
<div class="wrap">
{% if city_label %}
<div class="accent-bar"></div>
<div class="city">{{ city_label }}</div>
{% endif %}
<div class="days">
{% for d in days %}
<div class="col">
<div class="day">{{ d.label }}</div>
<div class="day-label">{{ d.label }}</div>
<div class="icon">{{ d.emoji }}</div>
<div class="temps">{{ d.high }}&deg;<span class="low">/{{ d.low }}&deg;{{ unit_suffix }}</span></div>
<div class="temps"><div class="high">{{ d.high }}&deg;</div><div class="low">{{ d.low }}&deg;{{ unit_suffix }}</div></div>
</div>
{% endfor %}
</div>