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
+83 -18
View File
@@ -210,7 +210,70 @@ Per-widget-type notes:
either widget type has ever had (classic draws the image with zero
frame/card at all) -- a rounded-corner, shadowed card
(`framed_image.html.jinja`, shared between the two) wrapping the
already-composed image.
already-composed image. Left alone by the "bold minimal" pass below --
it never had the reskinned-classic problem the other widgets did.
### "Bold minimal": a real redesign, not just a reskin
The initial modern-style rollout (above) mostly translated each widget's
*existing* classic layout into HTML/CSS -- same gradient header banner,
same rounded-shadowed white card, prettier chrome around an unchanged
composition. A second pass reworked weather (`current`/`daily`),
calendar (all four views), tasks, and battery into an actual different
visual language, picked from several divergent directions rendered
through the real pipeline and reviewed with the maintainer (not chosen
unilaterally -- see the "Reverted e-ink quantization attempt"-style
caution about visual changes needing more than one look). Text and
static/whiteboard were deliberately left as they were (see their notes
just above) -- text already had zero chrome and its styling is
user-authored content, not this system's to redesign; the framed-image
card was already minimal.
What changed, as a consistent language across every redesigned widget:
- **No card.** No rounded-corner white box, no drop shadow, no outer
border -- content sits directly on the shared white canvas. `theme
["radius"]`/`theme["shadow"]` are now unused by every redesigned
widget's builder (still resolved, for signature uniformity with
`resolve_theme`, but nothing reads them) -- a theme's radius/shadow
fields now only affect the *un*-redesigned modern widgets (static
image/whiteboard's `framed_image.html.jinja`).
- **A slim accent rule instead of a gradient banner.** Every widget that
used to have a colored header bar with white text on it (weather's
`build_daily`, tasks, calendar's four views) now has a thin (~4-8px)
accent-colored rounded rule, with the header text as plain ink below
it instead of white text on top of it -- only that thin rule dithers
at the theme's richer `accent_amplitude` via `ordered_dither_regions`
now, not the header text sitting on it, which reads as a legibility
improvement, not just a visual one (see "Rich accent hues" below).
- **A dominant hero value, not a centered icon+number of equal weight.**
Weather's `build_current` and battery's icon+percent used to be drawn
at roughly the same size, centered as a unit; both now put the numeric
value (temperature / battery percent) at a clearly dominant size, with
the icon small and secondary above it -- closer to a phone home-screen
widget than a dashboard tile.
- **Padding/type sizes as a proportion of widget size, clamped to a
floor/ceiling, not a fixed pixel value.** So a 1-2 grid-cell widget
doesn't get comically large padding relative to its content, and a
near-full-panel widget doesn't get comically small padding either --
see `html_render._clamp` and every redesigned `build_*`'s own
`pad`/size calculations (`base = min(target_w, target_h)`, then a
fraction of `base` clamped to tuned floor/ceiling values).
**A hairline color this palette can't actually render.** Auditing the
month view's grid during this pass turned up a real, pre-existing bug
carried forward unnoticed since the very first modern-style rollout:
`.day-cell`/`.day-section`/`.col` divider borders used a pale gray
(`#e2e6ec`) -- but `DEFAULT_PALETTE_RGB` has no gray in it at all (black/
white/yellow/red/blue/green only), so a color that close to white always
nearest-matches to pure white regardless of Bayer bias, at any amplitude
-- confirmed by sampling actual rendered pixels, not just eyeballing a
screenshot. The month grid's week-row dividers now use real solid black
(`RULE`-equivalent, matching how the *classic* PIL renderer always drew
them -- see `calendar_render.RULE`); the day-section/week-column dividers
were simply dropped instead, since the accent rule + spacing at the
start of the next section/column already read as a clear boundary
without a line at all once you could actually render one.
### Themes for modern-style widgets
@@ -227,16 +290,17 @@ 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).
**What a theme actually changes, in practice**: the header/accent color
is the most visible change, but `font_family` applies to *every* text
element in the widget, not just the header title -- day labels,
temperatures, task rows, event times, day numbers all switch fonts too
(e.g. "Moss" is serif, "Ochre" a slab serif), often more noticeable than
the accent color on text-heavy widgets. `radius`/`shadow` change the
card's corners and whether it has a drop shadow at all (e.g. "Slate" is
square-cornered with no shadow). Battery and text/static/whiteboard have
no header, so for those a theme only shows up as font/radius/shadow
differences, not an accent color.
**What a theme actually changes, in practice**: the accent color (now a
slim rule rather than a full header band -- see "Bold minimal" above) is
still the most visible change on widgets that have one, but `font_family`
applies to *every* text element in the widget, not just the header title
-- day labels, temperatures, task rows, event times, day numbers all
switch fonts too (e.g. "Moss" is serif, "Ochre" a slab serif), often
more noticeable than the accent color on text-heavy widgets. `radius`/
`shadow` only affect static image/whiteboard's card now (every other
modern-style widget dropped its card in the "bold minimal" pass); text
never used them (no card from the start) and weather/battery/tasks/
calendar no longer have a card for them to apply to either.
**A theme is purely stylistic, never functional color-coding.** Battery's
charge-level red/yellow/green, calendar/tasks' per-owner event color
@@ -279,13 +343,14 @@ 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.
`build_daily` (the slim rule, when `city_label` is set), tasks, and
calendar's four view builders -- each computes its own small accent-rule
pixel rect (a fixed-height band, not the old full header_h) and passes
just that to `ordered_dither_regions`. Weather's `build_current` and
battery have no accent surface at all (no header of any kind -- see
"Bold minimal" above) and static/whiteboard's shared `build_framed_image`
is unchanged from the original rollout; all three call plain
`ordered_dither` with no accent region.
## Button actions
+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>