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) %}
{#- 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). #} -
-
{{ header }}
- {% if weather_entries %} -
- {% for we in weather_entries %} -
{{ we.emoji }}{{ we.high }}°/{{ we.low }}°{{ unit_suffix }}
- {% endfor %} -
- {% 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. #} +
+
+
+
{{ header }}
+ {% if weather_entries %} +
+ {% for we in weather_entries %} +
{{ we.emoji }}{{ we.high }}°/{{ we.low }}°{{ unit_suffix }}
+ {% endfor %} +
+ {% endif %} +
{% if not rows and not more_count %} diff --git a/server/app/templates/widget_html/battery.html.jinja b/server/app/templates/widget_html/battery.html.jinja index 513ee13..339d205 100644 --- a/server/app/templates/widget_html/battery.html.jinja +++ b/server/app/templates/widget_html/battery.html.jinja @@ -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; } -
+
-
{{ percent }}%
- {% for line in lines %}
{{ line }}
{% endfor %} +
+
{{ percent }}%
+
{% for line in lines %}
{{ line }}
{% endfor %}
+
diff --git a/server/app/templates/widget_html/calendar_agenda.html.jinja b/server/app/templates/widget_html/calendar_agenda.html.jinja index 4c645f9..efac8b2 100644 --- a/server/app/templates/widget_html/calendar_agenda.html.jinja +++ b/server/app/templates/widget_html/calendar_agenda.html.jinja @@ -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; } {% import "_calendar_day_section.html.jinja" as ds %} -
- {{ 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) }} +
+ {{ 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) }}
diff --git a/server/app/templates/widget_html/calendar_month.html.jinja b/server/app/templates/widget_html/calendar_month.html.jinja index 98348eb..6c80b65 100644 --- a/server/app/templates/widget_html/calendar_month.html.jinja +++ b/server/app/templates/widget_html/calendar_month.html.jinja @@ -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; } -
+
+
{% for name in day_names %}
{{ name }}
{% endfor %}
diff --git a/server/app/templates/widget_html/calendar_today_tomorrow.html.jinja b/server/app/templates/widget_html/calendar_today_tomorrow.html.jinja index e94354a..0b5b4ca 100644 --- a/server/app/templates/widget_html/calendar_today_tomorrow.html.jinja +++ b/server/app/templates/widget_html/calendar_today_tomorrow.html.jinja @@ -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; } {% import "_calendar_day_section.html.jinja" as ds %} -
+
{% 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 %}
diff --git a/server/app/templates/widget_html/calendar_week_horizontal.html.jinja b/server/app/templates/widget_html/calendar_week_horizontal.html.jinja index 26130f2..7376dfb 100644 --- a/server/app/templates/widget_html/calendar_week_horizontal.html.jinja +++ b/server/app/templates/widget_html/calendar_week_horizontal.html.jinja @@ -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; } -
- {% for col in cols %} -
-
-
{{ col.label }}
- {% if col.weather %}
{{ col.weather.emoji }}{{ col.weather.high }}°/{{ col.weather.low }}°{{ unit_suffix }}
{% endif %} +
+
+
+ {% for col in cols %} +
+
+
{{ col.label }}
+ {% if col.weather %}
{{ col.weather.emoji }}{{ col.weather.high }}°/{{ col.weather.low }}°{{ unit_suffix }}
{% endif %} +
+
+ {% for row in col.rows %} +
{{ row.summary }}
+ {% endfor %} + {% if col.more_count %}
+{{ col.more_count }}
{% endif %} +
-
- {% for row in col.rows %} -
{{ row.summary }}
- {% endfor %} - {% if col.more_count %}
+{{ col.more_count }}
{% endif %} -
-
- {% endfor %} + {% endfor %} +
diff --git a/server/app/templates/widget_html/tasks.html.jinja b/server/app/templates/widget_html/tasks.html.jinja index 98c728a..5d1cda3 100644 --- a/server/app/templates/widget_html/tasks.html.jinja +++ b/server/app/templates/widget_html/tasks.html.jinja @@ -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; } -
-
{{ title }}
+
+
+
+
{{ title }}
+
{% if not rows %}
Nothing outstanding
diff --git a/server/app/templates/widget_html/weather_current.html.jinja b/server/app/templates/widget_html/weather_current.html.jinja index 058191d..b77cb88 100644 --- a/server/app/templates/widget_html/weather_current.html.jinja +++ b/server/app/templates/widget_html/weather_current.html.jinja @@ -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; } -
-
{{ emoji }}
-
{{ temp }}°{{ unit_suffix }}
- {% if city_label %}
{{ city_label }}
{% endif %} +
+
+ {% if city_label %}
{{ city_label }}
{% else %}
{% endif %} +
{{ emoji }}
+
+
+
{{ temp }}
°{{ unit_suffix }}
+ {% if condition %}
{{ condition }}
{% endif %} +
diff --git a/server/app/templates/widget_html/weather_daily.html.jinja b/server/app/templates/widget_html/weather_daily.html.jinja index e91d256..80aa0c2 100644 --- a/server/app/templates/widget_html/weather_daily.html.jinja +++ b/server/app/templates/widget_html/weather_daily.html.jinja @@ -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; } -
- {% if city_label %}
{{ city_label }}
{% endif %} -
+
+ {% if city_label %} +
+
{{ city_label }}
+ {% endif %} +
{% for d in days %}
-
{{ d.label }}
+
{{ d.label }}
{{ d.emoji }}
-
{{ d.high }}°/{{ d.low }}°{{ unit_suffix }}
+
{{ d.high }}°
{{ d.low }}°{{ unit_suffix }}
{% endfor %}