diff --git a/docs/widgets.md b/docs/widgets.md index 7d5808b..1754732 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -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 diff --git a/server/app/calendar_html_render.py b/server/app/calendar_html_render.py index 10bec93..502a592 100644 --- a/server/app/calendar_html_render.py +++ b/server/app/calendar_html_render.py @@ -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"])]) diff --git a/server/app/html_render.py b/server/app/html_render.py index 0f85a6d..4a13b86 100644 --- a/server/app/html_render.py +++ b/server/app/html_render.py @@ -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"])]) diff --git a/server/app/templates/widget_html/_calendar_day_section.html.jinja b/server/app/templates/widget_html/_calendar_day_section.html.jinja index ec70b55..6611724 100644 --- a/server/app/templates/widget_html/_calendar_day_section.html.jinja +++ b/server/app/templates/widget_html/_calendar_day_section.html.jinja @@ -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) %}