Redo modern-style widgets in a "bold minimal" language, not just a reskin
The first modern-style rollout translated each widget's existing classic layout into HTML/CSS -- same gradient headers, same rounded-shadowed card, prettier chrome around an unchanged composition. This actually redesigns weather (current/daily), calendar (all four views), tasks, and battery: no card/shadow anywhere, a slim accent-colored rule instead of a full gradient banner (and only that rule dithers at the richer accent amplitude now, not the header text sitting on it), and a dominant hero value (temperature/percent) instead of a centered icon+number of equal weight. Padding and type sizes scale as a clamped proportion of widget size instead of fixed pixel values. Text and static/whiteboard are left alone -- text already had zero chrome and its styling is user content, not this system's to redesign; framed_image's card was already minimal. Direction was picked from three divergent mockups reviewed with the maintainer, then verified against the real render pipeline (actual Chromium render, actual ordered dithering, actual theme system) rather than just eyeballed -- that caught a day-section/month-grid divider color (#e2e6ec) that's nowhere near this panel's 6-color palette and was dithering to invisible white; fixed with a real black hairline in the one place (month view) that still needed one.
This commit is contained in:
+83
-18
@@ -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
|
||||
|
||||
|
||||
@@ -72,9 +72,15 @@ def _day_section_data(day: date, events: list[dict], tz: ZoneInfo, palette_rgb,
|
||||
def build_agenda(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
|
||||
palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
|
||||
weather_units: str = "fahrenheit", theme_name: str | None = None) -> Image.Image:
|
||||
"""HTML/CSS-rendered analogue of calendar_render._build_agenda. Has a
|
||||
header bar -- dithered at the theme's accent_amplitude via
|
||||
ordered_dither_regions."""
|
||||
"""HTML/CSS-rendered analogue of calendar_render._build_agenda.
|
||||
|
||||
Bold-minimal: no card/border/shadow (theme["radius"]/theme["shadow"]
|
||||
are unused, same carve-out as weather's build_current/build_daily --
|
||||
see docs/widgets.md). The day header is plain ink text under a slim
|
||||
accent-colored rule instead of white text on a full gradient band --
|
||||
only that thin rule dithers at the theme's richer accent_amplitude
|
||||
now, not the header text sitting on top of it, which is a legibility
|
||||
improvement over the old design, not just a visual one."""
|
||||
theme = theme_tokens.resolve_theme(theme_name, "calendar", palette_rgb)
|
||||
day = datetime.now(tz).date() + timedelta(days=browse_offset)
|
||||
title_size = max(14, min(target_w, target_h) // 12)
|
||||
@@ -82,13 +88,14 @@ def build_agenda(events: list[dict], browse_offset: int, target_w: int, target_h
|
||||
weather_size = max(10, body_size - 2)
|
||||
row_h = body_size + 14
|
||||
unit_suffix = "F" if weather_units == "fahrenheit" else "C"
|
||||
accent_h = round(html_render._clamp(min(target_w, target_h) * 0.025, 4, 8))
|
||||
|
||||
# Single day -- no cross-section alignment concern, so header_h can
|
||||
# simply reflect whether THIS day actually has weather (unlike
|
||||
# build_today_tomorrow/build_week's vertical layout, which must
|
||||
# reserve the same header_h for every stacked section regardless).
|
||||
has_weather = bool(_weather_row(weather_cities, day, weather_units))
|
||||
header_h = title_size + 24 + ((weather_size + 12) if has_weather else 0)
|
||||
header_h = accent_h + 10 + title_size + ((weather_size + 10) if has_weather else 0)
|
||||
|
||||
owners_seen: list[str] = []
|
||||
data = _day_section_data(day, events, tz, palette_rgb, weather_cities, weather_units, owners_seen,
|
||||
@@ -96,16 +103,16 @@ def build_agenda(events: list[dict], browse_offset: int, target_w: int, target_h
|
||||
|
||||
template = html_render._jinja_env.get_template("calendar_agenda.html.jinja")
|
||||
html = template.render(
|
||||
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"], shadow=theme["shadow"],
|
||||
w=target_w, h=target_h, gutter=panel_style.GUTTER,
|
||||
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
|
||||
header=data["header"], title_size=title_size, header_h=header_h,
|
||||
accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"], weather_entries=data["weather_entries"],
|
||||
header=data["header"], title_size=title_size, header_h=header_h, accent_h=accent_h,
|
||||
accent_start=theme["accent_hex"], weather_entries=data["weather_entries"],
|
||||
weather_size=weather_size, unit_suffix=unit_suffix, rows=data["rows"],
|
||||
more_count=data["more_count"], row_h=row_h, body_size=body_size,
|
||||
)
|
||||
rendered = html_render.render_html_to_image(html, target_w, target_h)
|
||||
gutter = panel_style.GUTTER
|
||||
accent_rect = (gutter, gutter, target_w - gutter, gutter + header_h)
|
||||
accent_rect = (gutter, gutter, target_w - gutter, gutter + accent_h)
|
||||
return html_render.ordered_dither_regions(
|
||||
rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])]
|
||||
)
|
||||
@@ -115,8 +122,9 @@ def build_today_tomorrow(events: list[dict], browse_offset: int, target_w: int,
|
||||
palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
|
||||
weather_units: str = "fahrenheit", theme_name: str | None = None) -> Image.Image:
|
||||
"""HTML/CSS-rendered analogue of calendar_render._build_today_tomorrow
|
||||
-- two day-sections stacked (see _day_section_data), each with its own
|
||||
header bar dithered richer via ordered_dither_regions."""
|
||||
-- two day-sections stacked (see _day_section_data). Bold-minimal, no
|
||||
card (see build_agenda's docstring) -- each section's own slim accent
|
||||
rule dithers richer via ordered_dither_regions, not its header text."""
|
||||
theme = theme_tokens.resolve_theme(theme_name, "calendar", palette_rgb)
|
||||
start_day = datetime.now(tz).date() + timedelta(days=browse_offset)
|
||||
section_h = target_h // 2
|
||||
@@ -125,13 +133,14 @@ def build_today_tomorrow(events: list[dict], browse_offset: int, target_w: int,
|
||||
weather_size = max(9, body_size - 2)
|
||||
row_h = body_size + 12
|
||||
unit_suffix = "F" if weather_units == "fahrenheit" else "C"
|
||||
accent_h = round(html_render._clamp(min(target_w, target_h) * 0.02, 3, 6))
|
||||
|
||||
day_dates = [start_day + timedelta(days=i) for i in range(2)]
|
||||
# Uniform across both stacked sections regardless of which day(s)
|
||||
# actually have weather -- see _day_section_data's own docstring for
|
||||
# why a per-day header height misaligns where rows start.
|
||||
any_weather = any(_weather_row(weather_cities, d, weather_units) for d in day_dates)
|
||||
header_h = title_size + 16 + ((weather_size + 10) if any_weather else 0)
|
||||
header_h = accent_h + 8 + title_size + ((weather_size + 8) if any_weather else 0)
|
||||
|
||||
owners_seen: list[str] = []
|
||||
days = [
|
||||
@@ -142,16 +151,16 @@ def build_today_tomorrow(events: list[dict], browse_offset: int, target_w: int,
|
||||
|
||||
template = html_render._jinja_env.get_template("calendar_today_tomorrow.html.jinja")
|
||||
html = template.render(
|
||||
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"], shadow=theme["shadow"],
|
||||
w=target_w, h=target_h, gutter=panel_style.GUTTER,
|
||||
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
|
||||
days=days, title_size=title_size, header_h=header_h,
|
||||
accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"], weather_size=weather_size,
|
||||
days=days, title_size=title_size, header_h=header_h, accent_h=accent_h,
|
||||
accent_start=theme["accent_hex"], weather_size=weather_size,
|
||||
unit_suffix=unit_suffix, row_h=row_h, body_size=body_size,
|
||||
)
|
||||
rendered = html_render.render_html_to_image(html, target_w, target_h)
|
||||
gutter = panel_style.GUTTER
|
||||
accent_regions = [
|
||||
((gutter, gutter + i * section_h, target_w - gutter, gutter + i * section_h + header_h),
|
||||
((gutter, gutter + i * section_h, target_w - gutter, gutter + i * section_h + accent_h),
|
||||
theme["accent_amplitude"])
|
||||
for i in range(len(days))
|
||||
]
|
||||
@@ -184,12 +193,13 @@ def build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
|
||||
body_size = max(9, min(target_w, target_h) // (18 + days))
|
||||
weather_size = max(8, body_size - 2)
|
||||
row_h = body_size + 10
|
||||
accent_h = round(html_render._clamp(min(target_w, target_h) * 0.018, 3, 5))
|
||||
day_dates = [week_first_day + timedelta(days=i) for i in range(days)]
|
||||
# Uniform across all `days` stacked sections -- see
|
||||
# _day_section_data's own docstring for why a per-day header
|
||||
# height misaligns where rows start.
|
||||
any_weather = any(_weather_row(weather_cities, d, weather_units) for d in day_dates)
|
||||
header_h = title_size + 12 + ((weather_size + 8) if any_weather else 0)
|
||||
header_h = accent_h + 6 + title_size + ((weather_size + 6) if any_weather else 0)
|
||||
day_sections = [
|
||||
_day_section_data(d, events, tz, palette_rgb, weather_cities, weather_units, owners_seen,
|
||||
section_h - header_h, row_h)
|
||||
@@ -197,15 +207,15 @@ def build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
|
||||
]
|
||||
template = html_render._jinja_env.get_template("calendar_today_tomorrow.html.jinja")
|
||||
html = template.render(
|
||||
w=target_w, h=target_h, gutter=gutter, radius=theme["radius"], shadow=theme["shadow"],
|
||||
w=target_w, h=target_h, gutter=gutter,
|
||||
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
|
||||
days=day_sections, title_size=title_size, header_h=header_h,
|
||||
accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"], weather_size=weather_size,
|
||||
days=day_sections, title_size=title_size, header_h=header_h, accent_h=accent_h,
|
||||
accent_start=theme["accent_hex"], weather_size=weather_size,
|
||||
unit_suffix=unit_suffix, row_h=row_h, body_size=body_size,
|
||||
)
|
||||
rendered = html_render.render_html_to_image(html, target_w, target_h)
|
||||
accent_regions = [
|
||||
((gutter, gutter + i * section_h, target_w - gutter, gutter + i * section_h + header_h),
|
||||
((gutter, gutter + i * section_h, target_w - gutter, gutter + i * section_h + accent_h),
|
||||
theme["accent_amplitude"])
|
||||
for i in range(len(day_sections))
|
||||
]
|
||||
@@ -216,13 +226,14 @@ def build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
|
||||
weather_size = max(8, chip_size - 1)
|
||||
col_w = max(1, (target_w - panel_style.GUTTER * 2) // days)
|
||||
row_h = chip_size + 8
|
||||
accent_h = round(html_render._clamp(min(target_w, target_h) * 0.02, 3, 6))
|
||||
# Reserve weather-line room in every column's header uniformly
|
||||
# (whether or not THIS specific day has a cached forecast) -- a
|
||||
# per-column height that depends on that day's own data would
|
||||
# misalign where each column's event rows start across the week
|
||||
# grid the moment any single day lacks a forecast entry.
|
||||
header_h = header_size + 22 + (weather_size + 6 if weather_cities else 0)
|
||||
max_rows = max(0, (target_h - panel_style.GUTTER * 2 - header_h) // row_h)
|
||||
header_h = header_size + 8 + (weather_size + 4 if weather_cities else 0)
|
||||
max_rows = max(0, (target_h - panel_style.GUTTER * 2 - accent_h - 6 - header_h) // row_h)
|
||||
|
||||
cols = []
|
||||
for i in range(days):
|
||||
@@ -242,14 +253,14 @@ def build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
|
||||
|
||||
template = html_render._jinja_env.get_template("calendar_week_horizontal.html.jinja")
|
||||
html = template.render(
|
||||
w=target_w, h=target_h, gutter=gutter, radius=theme["radius"], shadow=theme["shadow"],
|
||||
w=target_w, h=target_h, gutter=gutter,
|
||||
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
|
||||
cols=cols, header_size=header_size, chip_size=chip_size,
|
||||
header_h=header_h, weather_size=weather_size, unit_suffix=unit_suffix,
|
||||
accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"],
|
||||
header_h=header_h, accent_h=accent_h, weather_size=weather_size, unit_suffix=unit_suffix,
|
||||
accent_start=theme["accent_hex"],
|
||||
)
|
||||
rendered = html_render.render_html_to_image(html, target_w, target_h)
|
||||
accent_rect = (gutter, gutter, target_w - gutter, gutter + header_h)
|
||||
accent_rect = (gutter, gutter, target_w - gutter, gutter + accent_h)
|
||||
return html_render.ordered_dither_regions(rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])])
|
||||
|
||||
|
||||
@@ -260,9 +271,15 @@ def build_month(events: list[dict], browse_offset: int, target_w: int, target_h:
|
||||
classic renderer (real text at typical month-cell size is close to
|
||||
unreadable on a 6-color dithered e-ink panel). The per-owner event
|
||||
dots are identity-coding (like every other calendar view's chips) and
|
||||
are never touched by a theme; only the weekday-name row (a flat
|
||||
accent background, no gradient in this view) dithers richer via
|
||||
ordered_dither_regions."""
|
||||
are never touched by a theme.
|
||||
|
||||
Bold-minimal: no card (see build_agenda's docstring); the old flat
|
||||
accent-colored weekday-name band is now a slim accent rule above
|
||||
plain bold weekday labels, matching every other calendar view's
|
||||
header treatment -- only that rule dithers at the theme's richer
|
||||
accent_amplitude via ordered_dither_regions. "Today" is still called
|
||||
out with a small accent-filled pill around its day number (a
|
||||
genuinely small accent surface, not a band, so it was left alone)."""
|
||||
theme = theme_tokens.resolve_theme(theme_name, "calendar", palette_rgb)
|
||||
gutter = panel_style.GUTTER
|
||||
today = datetime.now(tz).date()
|
||||
@@ -275,6 +292,7 @@ def build_month(events: list[dict], browse_offset: int, target_w: int, target_h:
|
||||
header_size = max(11, min(16, target_h // 30))
|
||||
day_size = max(10, min(15, target_w // 55))
|
||||
dot_size = max(4, day_size // 2)
|
||||
accent_h = round(html_render._clamp(min(target_w, target_h) * 0.02, 3, 6))
|
||||
|
||||
owners_seen: list[str] = []
|
||||
weeks = []
|
||||
@@ -291,14 +309,13 @@ def build_month(events: list[dict], browse_offset: int, target_w: int, target_h:
|
||||
|
||||
template = html_render._jinja_env.get_template("calendar_month.html.jinja")
|
||||
html = template.render(
|
||||
w=target_w, h=target_h, gutter=gutter, radius=theme["radius"], shadow=theme["shadow"],
|
||||
w=target_w, h=target_h, gutter=gutter,
|
||||
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
|
||||
day_names=day_names, weeks=weeks,
|
||||
day_names=day_names, weeks=weeks, accent_h=accent_h,
|
||||
header_size=header_size, day_size=day_size, dot_size=dot_size, accent_start=theme["accent_hex"],
|
||||
)
|
||||
rendered = html_render.render_html_to_image(html, target_w, target_h)
|
||||
weekday_row_h = header_size + 12
|
||||
accent_rect = (gutter, gutter, target_w - gutter, gutter + weekday_row_h)
|
||||
accent_rect = (gutter, gutter, target_w - gutter, gutter + accent_h)
|
||||
return html_render.ordered_dither_regions(rendered, palette_rgb,
|
||||
accent_regions=[(accent_rect, theme["accent_amplitude"])])
|
||||
|
||||
|
||||
+136
-41
@@ -87,6 +87,20 @@ CATEGORY_EMOJI = {
|
||||
"thunderstorm": "⛈️",
|
||||
}
|
||||
|
||||
# Spelled-out condition word for the "bold minimal" current-mode layout --
|
||||
# classic's build_current never needed one (icon + temp only), but the
|
||||
# redesigned modern layout has room for a secondary line under the temp.
|
||||
CATEGORY_LABEL = {
|
||||
"clear": "Clear",
|
||||
"partly_cloudy": "Partly cloudy",
|
||||
"cloudy": "Cloudy",
|
||||
"fog": "Fog",
|
||||
"rain": "Rain",
|
||||
"snow": "Snow",
|
||||
"thunderstorm": "Thunderstorm",
|
||||
}
|
||||
|
||||
|
||||
def _rgb_to_hex(rgb: tuple[int, int, int]) -> str:
|
||||
return "#%02x%02x%02x" % tuple(rgb)
|
||||
|
||||
@@ -98,6 +112,15 @@ def _darken_hex(rgb: tuple[int, int, int], factor: float = 0.75) -> str:
|
||||
return _rgb_to_hex(tuple(max(0, round(c * factor)) for c in rgb))
|
||||
|
||||
|
||||
def _clamp(value: float, lo: float, hi: float) -> float:
|
||||
"""Keeps a size/spacing value proportional to widget dimensions
|
||||
(`value` is always some fraction of target_w/target_h) while still
|
||||
guaranteeing a floor (stays legible on a 1-2 grid-cell widget) and a
|
||||
ceiling (stops padding/type from just growing forever on a
|
||||
near-full-panel widget -- see build_current's docstring)."""
|
||||
return max(lo, min(hi, value))
|
||||
|
||||
|
||||
# --- Persistent background browser -------------------------------------
|
||||
|
||||
_loop: asyncio.AbstractEventLoop | None = None
|
||||
@@ -268,29 +291,65 @@ def _day_label(day_date: date) -> str:
|
||||
return day_date.strftime("%a")
|
||||
|
||||
|
||||
def _short_city(city_label: str) -> str:
|
||||
"""geocode_city (see docs/widgets.md's Weather widget section) hands
|
||||
back a full "City, Region, Country" string -- fine for classic's
|
||||
build_current (just drawn as one line, however wide) but wrong for
|
||||
the bold-minimal layout's small top-row label, where a 1-2 grid-
|
||||
cell widget has no room for the whole thing. Every phone-homescreen
|
||||
weather widget this style is drawing from shows just the city, so
|
||||
that's what this keeps -- CSS `text-overflow: ellipsis` is still in
|
||||
the template as a safety net for a custom single-segment label
|
||||
that's itself too long, not as the primary truncation strategy."""
|
||||
return city_label.split(",")[0].strip()
|
||||
|
||||
|
||||
def build_current(entry: dict | None, target_w: int, target_h: int, palette_rgb: list | None = None,
|
||||
units: str = "fahrenheit", city_label: str = "", theme_name: str | None = None) -> Image.Image:
|
||||
"""HTML/CSS-rendered analogue of weather_render.build_current --
|
||||
same call signature, so app/widgets/weather.py can dispatch to
|
||||
either interchangeably. Returns an already-palette-exact RGB image
|
||||
(see ordered_dither). No header/accent region here (just a centered
|
||||
icon+temp) -- theme-aware for font/radius only, plain ordered_dither
|
||||
(no ordered_dither_regions call)."""
|
||||
(see ordered_dither).
|
||||
|
||||
"Bold minimal" layout: the temperature itself is the graphic --
|
||||
city label + icon in a top row, the temp (dominant) and spelled-out
|
||||
condition anchored to the bottom, no card/border/shadow at all. This
|
||||
is a deliberate departure from every other modern-style widget's
|
||||
card-on-white-canvas chrome (see docs/widgets.md) -- there's nothing
|
||||
for a "card" to visually separate from here, so `theme["radius"]`/
|
||||
`theme["shadow"]` have no effect on this template; still theme-aware
|
||||
for font_family only, same as before. No header/accent region either
|
||||
(see ordered_dither_regions' docstring) -- a themed accent has
|
||||
nothing to attach to in a chrome-free layout.
|
||||
|
||||
Every size below is a fraction of `base` (the widget's shorter side),
|
||||
clamped to a floor/ceiling rather than fixed -- so a 1-grid-cell
|
||||
widget doesn't get comically oversized padding relative to its
|
||||
content, and a near-full-panel widget doesn't get comically large
|
||||
padding relative to *its* content either. Floors/ceilings are tuned
|
||||
by eye against real widget sizes, not derived from anything."""
|
||||
img = Image.new("RGB", (target_w, target_h), (255, 255, 255))
|
||||
if not entry:
|
||||
return img
|
||||
|
||||
theme = theme_tokens.resolve_theme(theme_name, "weather", palette_rgb)
|
||||
unit_suffix = "F" if units == "fahrenheit" else "C"
|
||||
icon_size = max(28, min(target_w, target_h) // 3)
|
||||
base = min(target_w, target_h)
|
||||
pad = _clamp(base * 0.09, 10, 26)
|
||||
icon_size = _clamp(base * 0.20, 22, 60)
|
||||
temp_size = _clamp(base * 0.46, 30, 150)
|
||||
deg_size = _clamp(temp_size * 0.28, 12, 40)
|
||||
cond_size = _clamp(base * 0.075, 11, 20)
|
||||
city_size = _clamp(base * 0.06, 10, 15)
|
||||
template = _jinja_env.get_template("weather_current.html.jinja")
|
||||
html = template.render(
|
||||
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"],
|
||||
w=target_w, h=target_h, pad=round(pad),
|
||||
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
|
||||
emoji=CATEGORY_EMOJI.get(entry["category"], ""),
|
||||
temp=round(entry["temp"]), unit_suffix=unit_suffix, city_label=city_label,
|
||||
icon_size=icon_size, temp_size=max(24, min(target_w, target_h) // 3),
|
||||
label_size=max(12, icon_size // 3),
|
||||
condition=CATEGORY_LABEL.get(entry["category"], ""),
|
||||
temp=round(entry["temp"]), unit_suffix=unit_suffix, city_label=_short_city(city_label),
|
||||
icon_size=round(icon_size), temp_size=round(temp_size), deg_size=round(deg_size),
|
||||
cond_size=round(cond_size), city_size=round(city_size),
|
||||
)
|
||||
rendered = render_html_to_image(html, target_w, target_h)
|
||||
return ordered_dither(rendered, palette_rgb)
|
||||
@@ -300,9 +359,20 @@ def build_daily(daily: dict[str, dict], target_w: int, target_h: int, palette_rg
|
||||
units: str = "fahrenheit", city_label: str = "", theme_name: str | None = None) -> Image.Image:
|
||||
"""HTML/CSS-rendered analogue of weather_render.build_daily -- same
|
||||
call signature. Returns an already-palette-exact RGB image (see
|
||||
ordered_dither). Has a header bar -- dithered at the theme's
|
||||
accent_amplitude via ordered_dither_regions, richer than the rest of
|
||||
the widget (see that function's docstring for why)."""
|
||||
ordered_dither).
|
||||
|
||||
"Bold minimal" layout, matching build_current: no card/border/
|
||||
shadow, a row of day columns each carrying its own high (dominant)
|
||||
/ low (muted) temp the same way build_current makes the current
|
||||
temp dominant. The old full-width gradient banner is gone --
|
||||
city_label, when set, is a slim accent-colored rule (not a block)
|
||||
with the city name understated beneath it, so there's still
|
||||
somewhere for a theme's accent hue to show up (dithered richer via
|
||||
ordered_dither_regions, same mechanism as before) without dragging
|
||||
back the "card with a colored header" chrome this redesign is
|
||||
moving away from. `theme["radius"]`/`theme["shadow"]` are unused
|
||||
here for the same reason as build_current -- no card for them to
|
||||
apply to."""
|
||||
img = Image.new("RGB", (target_w, target_h), (255, 255, 255))
|
||||
days = list(daily.items())
|
||||
if not days:
|
||||
@@ -310,9 +380,16 @@ def build_daily(daily: dict[str, dict], target_w: int, target_h: int, palette_rg
|
||||
|
||||
theme = theme_tokens.resolve_theme(theme_name, "weather", palette_rgb)
|
||||
unit_suffix = "F" if units == "fahrenheit" else "C"
|
||||
header_h = max(28, min(target_w, target_h) // 8) if city_label else 0
|
||||
col_w = max(1, target_w // len(days))
|
||||
icon_size = max(16, min(col_w // 2, 36))
|
||||
base = min(target_w, target_h)
|
||||
pad = round(_clamp(base * 0.08, 10, 22))
|
||||
col_w = max(1, (target_w - pad * 2) // len(days))
|
||||
col_gap = round(_clamp(col_w * 0.12, 4, 16))
|
||||
icon_size = round(_clamp(col_w * 0.30, 16, 32))
|
||||
day_label_size = round(_clamp(col_w * 0.15, 10, 14))
|
||||
high_size = round(_clamp(col_w * 0.32, 16, 32))
|
||||
low_size = round(max(9, high_size * 0.55))
|
||||
city_size = round(_clamp(base * 0.055, 10, 14))
|
||||
accent_h = round(_clamp(base * 0.025, 4, 8))
|
||||
day_entries = [
|
||||
{
|
||||
"label": _day_label(date.fromisoformat(day_str)),
|
||||
@@ -324,18 +401,17 @@ def build_daily(daily: dict[str, dict], target_w: int, target_h: int, palette_rg
|
||||
]
|
||||
template = _jinja_env.get_template("weather_daily.html.jinja")
|
||||
html = template.render(
|
||||
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"], shadow=theme["shadow"],
|
||||
w=target_w, h=target_h, pad=pad, col_gap=col_gap,
|
||||
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
|
||||
city_label=city_label, header_h=header_h,
|
||||
title_size=max(14, header_h - 12), accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"],
|
||||
days=day_entries, icon_size=icon_size, label_size=max(12, icon_size // 2),
|
||||
unit_suffix=unit_suffix,
|
||||
city_label=_short_city(city_label), city_size=city_size, accent_h=accent_h,
|
||||
accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"],
|
||||
days=day_entries, icon_size=icon_size, day_label_size=day_label_size,
|
||||
high_size=high_size, low_size=low_size, unit_suffix=unit_suffix,
|
||||
)
|
||||
rendered = render_html_to_image(html, target_w, target_h)
|
||||
if header_h <= 0:
|
||||
if not city_label:
|
||||
return ordered_dither(rendered, palette_rgb)
|
||||
gutter = panel_style.GUTTER
|
||||
accent_rect = (gutter, gutter, target_w - gutter, gutter + header_h)
|
||||
accent_rect = (pad, pad, target_w - pad, pad + accent_h)
|
||||
return ordered_dither_regions(rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])])
|
||||
|
||||
|
||||
@@ -375,12 +451,12 @@ def render_weather_preview_png(mode: str, data, orientation: str, palette_rgb: l
|
||||
|
||||
def _battery_sizes(target_w: int, target_h: int, num_lines: int, scale: float) -> dict:
|
||||
base = min(target_w, target_h)
|
||||
icon_h = max(16, int(base // 3 * scale))
|
||||
pct_size = max(14, int(base // 3 * scale))
|
||||
line_size = max(9, int(base // 9 * scale))
|
||||
gap = 8
|
||||
icon_h = max(14, int(base * 0.15 * scale))
|
||||
pct_size = max(20, int(base * 0.42 * scale))
|
||||
line_size = max(9, int(base * 0.085 * scale))
|
||||
gap = max(4, int(base * 0.035 * scale))
|
||||
total = icon_h + gap + pct_size + num_lines * (line_size + gap)
|
||||
return {"icon_h": icon_h, "pct_size": pct_size, "line_size": line_size, "total": total}
|
||||
return {"icon_h": icon_h, "pct_size": pct_size, "line_size": line_size, "gap": gap, "total": total}
|
||||
|
||||
|
||||
def build_battery(percent: int, lines: list[str], target_w: int, target_h: int,
|
||||
@@ -390,18 +466,26 @@ def build_battery(percent: int, lines: list[str], target_w: int, target_h: int,
|
||||
resolved by the caller (widgets/battery.py's _lines_for(), shared
|
||||
with the classic path so the estimate/age formatting only lives in
|
||||
one place). Returns an already-palette-exact RGB image (see
|
||||
ordered_dither). Theme-aware for font/radius only -- the charge-level
|
||||
ordered_dither). Theme-aware for font only -- the charge-level
|
||||
fill_color below is a functional status signal (not a style choice)
|
||||
and is never touched by a theme, and there's no header/accent region
|
||||
to dither richer via ordered_dither_regions.
|
||||
|
||||
Bold-minimal: no card (theme["radius"] unused, same carve-out as
|
||||
weather's build_current -- see docs/widgets.md); the percent is the
|
||||
hero value anchored toward the bottom, same treatment build_current
|
||||
gives the temperature, with the icon small and secondary above it
|
||||
instead of both competing at the same size like the old centered
|
||||
layout did.
|
||||
|
||||
Shrinks icon/text sizes together (in 0.05 steps down to 0.3x) until
|
||||
the whole stack actually fits the available height -- the classic
|
||||
PIL path solves the same "icon + percent + 0-2 lines in a fixed box"
|
||||
problem by truncating lines that don't fit; scaling down instead
|
||||
keeps every resolved line visible, which reads better for a widget
|
||||
that only ever has at most 2 short caption lines to begin with."""
|
||||
avail_h = target_h - panel_style.GUTTER * 2
|
||||
pad = round(_clamp(min(target_w, target_h) * 0.09, 10, 26))
|
||||
avail_h = target_h - pad * 2
|
||||
num_lines = len(lines)
|
||||
scale = 1.0
|
||||
sizes = _battery_sizes(target_w, target_h, num_lines, scale)
|
||||
@@ -426,13 +510,13 @@ def build_battery(percent: int, lines: list[str], target_w: int, target_h: int,
|
||||
nub_w = max(3, icon_w // 10)
|
||||
template = _jinja_env.get_template("battery.html.jinja")
|
||||
html = template.render(
|
||||
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"],
|
||||
w=target_w, h=target_h, pad=pad,
|
||||
font_regular=theme["font_regular"], font_bold=theme["font_bold"], percent=percent, lines=lines,
|
||||
icon_w=icon_w, icon_h=icon_h, icon_radius=icon_h // 6, stroke=stroke,
|
||||
fill_pct=max(0, min(100, percent)), fill_radius=max(0, icon_h // 6 - stroke),
|
||||
fill_color=_rgb_to_hex(fill_color), fill_color_dark=_darken_hex(fill_color),
|
||||
nub_w=nub_w, nub_h=icon_h // 2, nub_radius=max(1, nub_w // 3),
|
||||
pct_size=sizes["pct_size"], line_size=sizes["line_size"],
|
||||
pct_size=sizes["pct_size"], line_size=sizes["line_size"], line_gap=sizes["gap"],
|
||||
)
|
||||
rendered = render_html_to_image(html, target_w, target_h)
|
||||
return ordered_dither(rendered, palette_rgb)
|
||||
@@ -502,17 +586,28 @@ def build_tasks(tasks: list[dict], target_w: int, target_h: int, palette_rgb: li
|
||||
logic the classic renderer uses) so a task's color chip/due string
|
||||
matches classic style exactly; only the drawing differs -- and a
|
||||
theme's accent never touches those per-owner chip colors (identity-
|
||||
coding, not style). Has a header bar -- dithered at the theme's
|
||||
accent_amplitude via ordered_dither_regions. Returns an already-
|
||||
palette-exact RGB image (see ordered_dither)."""
|
||||
coding, not style) or the done-checkbox fill (a completion state
|
||||
signal, not a style choice -- it happens to reuse the accent color,
|
||||
but that's incidental, same as before this redesign).
|
||||
|
||||
Bold-minimal: no card (theme["shadow"]/["radius"] unused, same
|
||||
carve-out as calendar's redesigned views -- see docs/widgets.md).
|
||||
The old gradient header banner is now a slim accent rule + plain
|
||||
bold title, matching every calendar view's day-header language --
|
||||
only the rule dithers at the theme's richer accent_amplitude, not
|
||||
the title text sitting on it. Returns an already-palette-exact RGB
|
||||
image (see ordered_dither)."""
|
||||
from .calendar_render import _event_colors, _fmt_task_due
|
||||
|
||||
theme = theme_tokens.resolve_theme(theme_name, "tasks", palette_rgb)
|
||||
header_h = max(28, min(target_w, target_h) // 8)
|
||||
body_size = max(11, min(target_w, target_h) // 20)
|
||||
base = min(target_w, target_h)
|
||||
accent_h = round(_clamp(base * 0.025, 3, 6))
|
||||
title_size = max(14, base // 12)
|
||||
body_size = max(11, base // 20)
|
||||
row_h = body_size + 14
|
||||
box_size = max(10, body_size - 4)
|
||||
avail_h = target_h - header_h - 16
|
||||
header_h = accent_h + 6 + title_size
|
||||
avail_h = target_h - panel_style.GUTTER * 2 - header_h - 8
|
||||
max_rows = max(0, avail_h // row_h)
|
||||
|
||||
owners_seen: list[str] = []
|
||||
@@ -531,15 +626,15 @@ def build_tasks(tasks: list[dict], target_w: int, target_h: int, palette_rgb: li
|
||||
|
||||
template = _jinja_env.get_template("tasks.html.jinja")
|
||||
html = template.render(
|
||||
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"], shadow=theme["shadow"],
|
||||
w=target_w, h=target_h, gutter=panel_style.GUTTER,
|
||||
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
|
||||
title=title, header_h=header_h, title_size=max(14, header_h - 12),
|
||||
accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"],
|
||||
title=title, header_h=header_h, accent_h=accent_h, title_size=title_size,
|
||||
accent_start=theme["accent_hex"],
|
||||
rows=rows, more_count=more_count, row_h=row_h, box_size=box_size, body_size=body_size,
|
||||
)
|
||||
rendered = render_html_to_image(html, target_w, target_h)
|
||||
gutter = panel_style.GUTTER
|
||||
accent_rect = (gutter, gutter, target_w - gutter, gutter + header_h)
|
||||
accent_rect = (gutter, gutter, target_w - gutter, gutter + accent_h)
|
||||
return ordered_dither_regions(rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])])
|
||||
|
||||
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
{% macro day_section(header, weather_entries, weather_size, unit_suffix, rows, more_count, row_h, body_size, title_size, accent_start, accent_end, header_h) %}
|
||||
{% macro day_section(header, weather_entries, weather_size, unit_suffix, rows, more_count, row_h, body_size, title_size, accent_start, header_h, accent_h) %}
|
||||
<div class="day-section">
|
||||
{#- Fixed height (not auto) -- every stacked day-section's header
|
||||
must be exactly this tall regardless of whether THIS particular
|
||||
day has a weather entry, or days with/without weather misalign
|
||||
where their event rows start (see calendar_week_horizontal's
|
||||
identical fix/reasoning). #}
|
||||
<div class="day-header" style="background: linear-gradient(135deg, {{ accent_start }} 0%, {{ accent_end }} 100%); font-size: {{ title_size }}px; height: {{ header_h }}px;">
|
||||
<div class="day-header-title">{{ header }}</div>
|
||||
{% if weather_entries %}
|
||||
<div class="day-weather-row" style="font-size: {{ weather_size }}px;">
|
||||
{% for we in weather_entries %}
|
||||
<div class="day-weather-entry"><span class="day-weather-icon" style="font-size: {{ weather_size * 1.3 }}px;">{{ we.emoji }}</span><span>{{ we.high }}°/{{ we.low }}°{{ unit_suffix }}</span></div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
identical fix/reasoning). Bold-minimal: a slim accent-colored
|
||||
rule (not a full gradient band) carries the theme identity --
|
||||
the header text itself is plain ink, dithered at the base
|
||||
amplitude like everything else, not the richer accent amplitude
|
||||
the old white-on-gradient text needed to stay legible. #}
|
||||
<div class="day-header" style="height: {{ header_h }}px;">
|
||||
<div class="accent-rule" style="height: {{ accent_h }}px; background: {{ accent_start }};"></div>
|
||||
<div class="day-header-row">
|
||||
<div class="day-header-title" style="font-size: {{ title_size }}px;">{{ header }}</div>
|
||||
{% if weather_entries %}
|
||||
<div class="day-weather-row" style="font-size: {{ weather_size }}px;">
|
||||
{% for we in weather_entries %}
|
||||
<div class="day-weather-entry"><span class="day-weather-icon" style="font-size: {{ weather_size * 1.3 }}px;">{{ we.emoji }}</span><span>{{ we.high }}°/{{ we.low }}°{{ unit_suffix }}</span></div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="day-rows">
|
||||
{% if not rows and not more_count %}
|
||||
|
||||
@@ -4,18 +4,9 @@
|
||||
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
|
||||
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
|
||||
.card {
|
||||
width: {{ w - gutter * 2 }}px;
|
||||
height: {{ h - gutter * 2 }}px;
|
||||
margin: {{ gutter }}px;
|
||||
border-radius: {{ radius }}px;
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
.wrap {
|
||||
width: {{ w }}px; height: {{ h }}px; padding: {{ pad }}px;
|
||||
display: flex; flex-direction: column; justify-content: space-between;
|
||||
}
|
||||
.icon-wrap { display: flex; align-items: center; }
|
||||
.icon-body {
|
||||
@@ -24,7 +15,6 @@
|
||||
border: {{ stroke }}px solid #000000;
|
||||
border-radius: {{ icon_radius }}px;
|
||||
padding: {{ stroke }}px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.icon-fill {
|
||||
width: {{ fill_pct }}%;
|
||||
@@ -38,16 +28,19 @@
|
||||
background: #000000;
|
||||
border-radius: 0 {{ nub_radius }}px {{ nub_radius }}px 0;
|
||||
}
|
||||
.pct { font-weight: 700; font-size: {{ pct_size }}px; line-height: 1; color: {{ fill_color }}; }
|
||||
.line { font-weight: 400; font-size: {{ line_size }}px; line-height: 1; color: #5b6674; }
|
||||
.pct { font-weight: 700; font-size: {{ pct_size }}px; line-height: 0.85; color: {{ fill_color }}; letter-spacing: -0.02em; }
|
||||
.lines { margin-top: {{ line_gap }}px; }
|
||||
.line { font-weight: 400; font-size: {{ line_size }}px; line-height: 1.3; color: #5b6674; }
|
||||
</style></head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="wrap">
|
||||
<div class="icon-wrap">
|
||||
<div class="icon-body"><div class="icon-fill"></div></div>
|
||||
<div class="icon-nub"></div>
|
||||
</div>
|
||||
<div class="pct">{{ percent }}%</div>
|
||||
{% for line in lines %}<div class="line">{{ line }}</div>{% endfor %}
|
||||
<div>
|
||||
<div class="pct">{{ percent }}%</div>
|
||||
<div class="lines">{% for line in lines %}<div class="line">{{ line }}</div>{% endfor %}</div>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>
|
||||
|
||||
@@ -4,33 +4,32 @@
|
||||
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
|
||||
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
|
||||
.card {
|
||||
.wrap {
|
||||
width: {{ w - gutter * 2 }}px;
|
||||
height: {{ h - gutter * 2 }}px;
|
||||
margin: {{ gutter }}px;
|
||||
border-radius: {{ radius }}px;
|
||||
overflow: hidden;
|
||||
{% if shadow %}box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2);{% endif %}
|
||||
background: #ffffff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.day-section { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }
|
||||
.day-header { flex: 0 0 auto; color: #ffffff; font-weight: 700; line-height: 1.2; padding: 10px 16px; overflow: hidden; }
|
||||
.day-weather-row { display: flex; gap: 14px; margin-top: 6px; }
|
||||
.day-weather-entry { display: flex; align-items: center; gap: 4px; color: rgba(255,255,255,0.9); }
|
||||
.day-section { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; overflow: hidden; }
|
||||
.day-header { flex: 0 0 auto; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.accent-rule { width: 100%; border-radius: 100px; }
|
||||
.day-header-row { flex: 1 1 auto; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding-top: 6px; min-height: 0; }
|
||||
.day-header-title { font-weight: 700; color: #17233b; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.day-weather-row { display: flex; gap: 14px; flex: 0 0 auto; }
|
||||
.day-weather-entry { display: flex; align-items: center; gap: 4px; color: #5b6674; }
|
||||
.day-weather-icon { line-height: 1; }
|
||||
.day-rows { flex: 1 1 auto; padding: 8px 14px; overflow: hidden; }
|
||||
.day-rows { flex: 1 1 auto; padding: 8px 0; overflow: hidden; }
|
||||
.day-row { display: flex; align-items: center; gap: 8px; }
|
||||
.day-chip { display: flex; height: 12px; width: 10px; border-radius: 3px; overflow: hidden; flex: 0 0 auto; }
|
||||
.day-chip span { flex: 1 1 0; }
|
||||
.day-time { color: #5b6674; flex: 0 0 auto; white-space: nowrap; }
|
||||
.day-time { color: #5b6674; flex: 0 0 auto; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.day-summary { color: #17233b; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.day-empty, .day-more { color: #5b6674; padding-top: 4px; }
|
||||
</style></head>
|
||||
<body>
|
||||
{% import "_calendar_day_section.html.jinja" as ds %}
|
||||
<div class="card">
|
||||
{{ ds.day_section(header, weather_entries, weather_size, unit_suffix, rows, more_count, row_h, body_size, title_size, accent_start, accent_end, header_h) }}
|
||||
<div class="wrap">
|
||||
{{ ds.day_section(header, weather_entries, weather_size, unit_suffix, rows, more_count, row_h, body_size, title_size, accent_start, header_h, accent_h) }}
|
||||
</div>
|
||||
</body></html>
|
||||
|
||||
@@ -4,22 +4,27 @@
|
||||
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
|
||||
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
|
||||
.card {
|
||||
.wrap {
|
||||
width: {{ w - gutter * 2 }}px;
|
||||
height: {{ h - gutter * 2 }}px;
|
||||
margin: {{ gutter }}px;
|
||||
border-radius: {{ radius }}px;
|
||||
overflow: hidden;
|
||||
{% if shadow %}box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2);{% endif %}
|
||||
background: #ffffff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.weekday-row { display: flex; flex: 0 0 auto; background: {{ accent_start }}; }
|
||||
.weekday-cell { flex: 1 1 0; color: #ffffff; font-weight: 700; font-size: {{ header_size }}px; text-align: center; padding: 4px 0; }
|
||||
.weeks { flex: 1 1 auto; display: flex; flex-direction: column; }
|
||||
.accent-rule { flex: 0 0 auto; width: 100%; height: {{ accent_h }}px; border-radius: 100px; background: {{ accent_start }}; }
|
||||
.weekday-row { display: flex; flex: 0 0 auto; margin-top: 6px; }
|
||||
.weekday-cell { flex: 1 1 0; color: #17233b; font-weight: 700; font-size: {{ header_size }}px; text-align: center; padding: 4px 0; }
|
||||
.weeks { flex: 1 1 auto; display: flex; flex-direction: column; margin-top: 2px; }
|
||||
.week-row { flex: 1 1 0; display: flex; }
|
||||
.day-cell { flex: 1 1 0; border: 1px solid #e2e6ec; padding: 4px; min-width: 0; overflow: hidden; }
|
||||
{#- A real palette color (pure black), not a pale gray -- this panel's
|
||||
6-color palette has no gray to dither toward, so #e2e6ec-style
|
||||
"hairlines" don't survive at all (confirmed by sampling actual
|
||||
rendered pixels: every one came back pure white). Horizontal
|
||||
rules only, between week rows -- enough for the grid to scan
|
||||
top-to-bottom without boxing every single day cell, which read
|
||||
more like the old structured-dashboard mockup than bold-minimal. #}
|
||||
.week-row + .week-row { border-top: 1px solid #000000; }
|
||||
.day-cell { flex: 1 1 0; padding: 4px; min-width: 0; overflow: hidden; }
|
||||
{#- Bold everywhere, including out-of-month -- de-emphasis is via
|
||||
smaller size only, not weight or a gray color. Regular-weight and
|
||||
gray text are both individually fragile under Bayer ordered
|
||||
@@ -40,7 +45,8 @@
|
||||
.dot-more { font-size: {{ day_size * 0.8 }}px; color: #5b6674; }
|
||||
</style></head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="wrap">
|
||||
<div class="accent-rule"></div>
|
||||
<div class="weekday-row">
|
||||
{% for name in day_names %}<div class="weekday-cell">{{ name }}</div>{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -4,36 +4,39 @@
|
||||
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
|
||||
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
|
||||
.card {
|
||||
.wrap {
|
||||
width: {{ w - gutter * 2 }}px;
|
||||
height: {{ h - gutter * 2 }}px;
|
||||
margin: {{ gutter }}px;
|
||||
border-radius: {{ radius }}px;
|
||||
overflow: hidden;
|
||||
{% if shadow %}box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2);{% endif %}
|
||||
background: #ffffff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.day-section { display: flex; flex-direction: column; flex: 1 1 0; min-height: 0; overflow: hidden; }
|
||||
.day-section + .day-section { border-top: 1px solid #e2e6ec; }
|
||||
.day-header { flex: 0 0 auto; color: #ffffff; font-weight: 700; line-height: 1.2; padding: 8px 16px; overflow: hidden; }
|
||||
.day-weather-row { display: flex; gap: 14px; margin-top: 4px; }
|
||||
.day-weather-entry { display: flex; align-items: center; gap: 4px; color: rgba(255,255,255,0.9); }
|
||||
{#- No divider line -- #e2e6ec was invisible on this panel's 6-color
|
||||
palette anyway (no gray to dither toward, confirmed by sampling
|
||||
rendered pixels), and the accent rule + margin at the top of the
|
||||
next section already reads as a clear boundary without one. #}
|
||||
.day-section + .day-section { margin-top: 8px; }
|
||||
.day-header { flex: 0 0 auto; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.accent-rule { width: 100%; border-radius: 100px; }
|
||||
.day-header-row { flex: 1 1 auto; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding-top: 4px; min-height: 0; }
|
||||
.day-header-title { font-weight: 700; color: #17233b; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.day-weather-row { display: flex; gap: 12px; flex: 0 0 auto; }
|
||||
.day-weather-entry { display: flex; align-items: center; gap: 4px; color: #5b6674; }
|
||||
.day-weather-icon { line-height: 1; }
|
||||
.day-rows { flex: 1 1 auto; padding: 6px 14px; overflow: hidden; }
|
||||
.day-rows { flex: 1 1 auto; padding: 4px 0; overflow: hidden; }
|
||||
.day-row { display: flex; align-items: center; gap: 8px; }
|
||||
.day-chip { display: flex; height: 12px; width: 10px; border-radius: 3px; overflow: hidden; flex: 0 0 auto; }
|
||||
.day-chip span { flex: 1 1 0; }
|
||||
.day-time { color: #5b6674; flex: 0 0 auto; white-space: nowrap; }
|
||||
.day-time { color: #5b6674; flex: 0 0 auto; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.day-summary { color: #17233b; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.day-empty, .day-more { color: #5b6674; padding-top: 2px; }
|
||||
</style></head>
|
||||
<body>
|
||||
{% import "_calendar_day_section.html.jinja" as ds %}
|
||||
<div class="card">
|
||||
<div class="wrap">
|
||||
{% for day in days %}
|
||||
{{ ds.day_section(day.header, day.weather_entries, weather_size, unit_suffix, day.rows, day.more_count, row_h, body_size, title_size, accent_start, accent_end, header_h) }}
|
||||
{{ ds.day_section(day.header, day.weather_entries, weather_size, unit_suffix, day.rows, day.more_count, row_h, body_size, title_size, accent_start, header_h, accent_h) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</body></html>
|
||||
|
||||
@@ -4,32 +4,36 @@
|
||||
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
|
||||
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
|
||||
.card {
|
||||
.wrap {
|
||||
width: {{ w - gutter * 2 }}px;
|
||||
height: {{ h - gutter * 2 }}px;
|
||||
margin: {{ gutter }}px;
|
||||
border-radius: {{ radius }}px;
|
||||
overflow: hidden;
|
||||
{% if shadow %}box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2);{% endif %}
|
||||
background: #ffffff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.accent-rule { flex: 0 0 auto; width: 100%; height: {{ accent_h }}px; border-radius: 100px; background: {{ accent_start }}; }
|
||||
.cols { flex: 1 1 auto; display: flex; margin-top: 6px; min-height: 0; }
|
||||
.col { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; }
|
||||
.col + .col { border-left: 1px solid #e2e6ec; }
|
||||
{#- No divider line -- #e2e6ec was invisible on this panel's 6-color
|
||||
palette anyway (no gray to dither toward, confirmed by sampling
|
||||
rendered pixels); the shared accent rule above already frames the
|
||||
whole week as one unit, and each column's own label anchors it. #}
|
||||
.col + .col { margin-left: 4px; }
|
||||
.col-header {
|
||||
/* Fixed height (not auto) -- every column must be exactly this tall
|
||||
regardless of whether THIS particular day has a weather entry, or
|
||||
columns with/without weather misalign their event rows to
|
||||
different starting Y positions across the week grid. */
|
||||
{#- Fixed height (not auto) -- every column must be exactly this tall
|
||||
regardless of whether THIS particular day has a weather entry, or
|
||||
columns with/without weather misalign their event rows to
|
||||
different starting Y positions across the week grid. #}
|
||||
height: {{ header_h }}px;
|
||||
flex: 0 0 auto;
|
||||
background: linear-gradient(135deg, {{ accent_start }} 0%, {{ accent_end }} 100%);
|
||||
color: #ffffff; font-weight: 700; font-size: {{ header_size }}px;
|
||||
padding: 6px 6px;
|
||||
padding: 2px 6px 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.col-header .label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.col-weather { display: flex; align-items: center; gap: 3px; color: rgba(255,255,255,0.9); font-size: {{ weather_size }}px; margin-top: 2px; }
|
||||
.col-header .label {
|
||||
font-weight: 700; color: #17233b; font-size: {{ header_size }}px;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.col-weather { display: flex; align-items: center; gap: 3px; color: #5b6674; font-size: {{ weather_size }}px; margin-top: 2px; }
|
||||
.col-rows { flex: 1 1 auto; padding: 4px; overflow: hidden; }
|
||||
.col-row { display: flex; align-items: center; gap: 4px; min-width: 0; }
|
||||
.col-chip { width: 7px; height: 7px; border-radius: 2px; flex: 0 0 auto; }
|
||||
@@ -41,20 +45,23 @@
|
||||
.col-more { font-size: {{ chip_size }}px; color: #5b6674; }
|
||||
</style></head>
|
||||
<body>
|
||||
<div class="card">
|
||||
{% for col in cols %}
|
||||
<div class="col">
|
||||
<div class="col-header">
|
||||
<div class="label">{{ col.label }}</div>
|
||||
{% if col.weather %}<div class="col-weather"><span>{{ col.weather.emoji }}</span><span>{{ col.weather.high }}°/{{ col.weather.low }}°{{ unit_suffix }}</span></div>{% endif %}
|
||||
<div class="wrap">
|
||||
<div class="accent-rule"></div>
|
||||
<div class="cols">
|
||||
{% for col in cols %}
|
||||
<div class="col">
|
||||
<div class="col-header">
|
||||
<div class="label">{{ col.label }}</div>
|
||||
{% if col.weather %}<div class="col-weather"><span>{{ col.weather.emoji }}</span><span>{{ col.weather.high }}°/{{ col.weather.low }}°{{ unit_suffix }}</span></div>{% endif %}
|
||||
</div>
|
||||
<div class="col-rows">
|
||||
{% for row in col.rows %}
|
||||
<div class="col-row"><div class="col-chip" style="background:{{ row.color }};"></div><div class="col-summary">{{ row.summary }}</div></div>
|
||||
{% endfor %}
|
||||
{% if col.more_count %}<div class="col-more">+{{ col.more_count }}</div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-rows">
|
||||
{% for row in col.rows %}
|
||||
<div class="col-row"><div class="col-chip" style="background:{{ row.color }};"></div><div class="col-summary">{{ row.summary }}</div></div>
|
||||
{% endfor %}
|
||||
{% if col.more_count %}<div class="col-more">+{{ col.more_count }}</div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</body></html>
|
||||
|
||||
@@ -4,27 +4,21 @@
|
||||
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
|
||||
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
|
||||
.card {
|
||||
.wrap {
|
||||
width: {{ w - gutter * 2 }}px;
|
||||
height: {{ h - gutter * 2 }}px;
|
||||
margin: {{ gutter }}px;
|
||||
border-radius: {{ radius }}px;
|
||||
overflow: hidden;
|
||||
{% if shadow %}box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2);{% endif %}
|
||||
background: #ffffff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.header {
|
||||
height: {{ header_h }}px;
|
||||
flex: 0 0 auto;
|
||||
background: linear-gradient(135deg, {{ accent_start }} 0%, {{ accent_end }} 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
.header { flex: 0 0 auto; height: {{ header_h }}px; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.accent-rule { width: 100%; height: {{ accent_h }}px; border-radius: 100px; background: {{ accent_start }}; }
|
||||
.title {
|
||||
flex: 1 1 auto; display: flex; align-items: center;
|
||||
color: #17233b; font-weight: 700; font-size: {{ title_size }}px; line-height: 1;
|
||||
padding-top: 4px;
|
||||
}
|
||||
.header .title { color: #ffffff; font-weight: 700; font-size: {{ title_size }}px; line-height: 1; }
|
||||
.rows { flex: 1 1 auto; padding: 8px 14px; overflow: hidden; }
|
||||
.rows { flex: 1 1 auto; padding-top: 4px; overflow: hidden; }
|
||||
.row { display: flex; align-items: center; gap: 8px; height: {{ row_h }}px; }
|
||||
.chip { display: flex; height: 12px; width: 10px; border-radius: 3px; overflow: hidden; flex: 0 0 auto; }
|
||||
.chip span { flex: 1 1 0; }
|
||||
@@ -33,7 +27,7 @@
|
||||
border: 2px solid #17233b;
|
||||
}
|
||||
.box.done { border-color: {{ accent_start }}; background: {{ accent_start }}; }
|
||||
.due { font-size: {{ body_size }}px; color: #5b6674; flex: 0 0 auto; white-space: nowrap; }
|
||||
.due { font-size: {{ body_size }}px; color: #5b6674; flex: 0 0 auto; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.summary {
|
||||
font-size: {{ body_size }}px; color: #17233b; line-height: 1.2;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
@@ -42,8 +36,11 @@
|
||||
.more { font-size: {{ body_size }}px; color: #5b6674; padding-top: 2px; }
|
||||
</style></head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="header"><div class="title">{{ title }}</div></div>
|
||||
<div class="wrap">
|
||||
<div class="header">
|
||||
<div class="accent-rule"></div>
|
||||
<div class="title">{{ title }}</div>
|
||||
</div>
|
||||
<div class="rows">
|
||||
{% if not rows %}
|
||||
<div class="empty">Nothing outstanding</div>
|
||||
|
||||
@@ -4,27 +4,34 @@
|
||||
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
|
||||
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
|
||||
.card {
|
||||
width: {{ w - gutter * 2 }}px;
|
||||
height: {{ h - gutter * 2 }}px;
|
||||
margin: {{ gutter }}px;
|
||||
border-radius: {{ radius }}px;
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
.wrap {
|
||||
width: {{ w }}px; height: {{ h }}px; padding: {{ pad }}px;
|
||||
display: flex; flex-direction: column; justify-content: space-between;
|
||||
}
|
||||
.icon { font-size: {{ icon_size }}px; line-height: 1; filter: drop-shadow(0 3px 4px rgba(0, 0, 0, 0.18)); }
|
||||
.temp { font-weight: 700; font-size: {{ temp_size }}px; color: #17233b; }
|
||||
.city { font-weight: 400; font-size: {{ label_size }}px; color: #5b6674; }
|
||||
.top { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
|
||||
.city {
|
||||
flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
font-weight: 700; font-size: {{ city_size }}px; line-height: 1.2;
|
||||
letter-spacing: 0.12em; text-transform: uppercase; color: #5b6674;
|
||||
}
|
||||
.icon { flex: 0 0 auto; font-size: {{ icon_size }}px; line-height: 1; }
|
||||
.temp-row { display: flex; align-items: flex-start; }
|
||||
.temp {
|
||||
font-weight: 700; font-size: {{ temp_size }}px; line-height: 0.85;
|
||||
color: #17233b; letter-spacing: -0.03em;
|
||||
}
|
||||
.deg { font-weight: 400; font-size: {{ deg_size }}px; line-height: 1.3; color: #5b6674; }
|
||||
.cond { font-weight: 400; font-size: {{ cond_size }}px; color: #5b6674; margin-top: {{ (pad * 0.25) | round(0, 'floor') }}px; }
|
||||
</style></head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="icon">{{ emoji }}</div>
|
||||
<div class="temp">{{ temp }}°{{ unit_suffix }}</div>
|
||||
{% if city_label %}<div class="city">{{ city_label }}</div>{% endif %}
|
||||
<div class="wrap">
|
||||
<div class="top">
|
||||
{% if city_label %}<div class="city">{{ city_label }}</div>{% else %}<div></div>{% endif %}
|
||||
<div class="icon">{{ emoji }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="temp-row"><div class="temp">{{ temp }}</div><div class="deg">°{{ unit_suffix }}</div></div>
|
||||
{% if condition %}<div class="cond">{{ condition }}</div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</body></html>
|
||||
|
||||
@@ -4,46 +4,40 @@
|
||||
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
|
||||
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
|
||||
.card {
|
||||
width: {{ w - gutter * 2 }}px;
|
||||
height: {{ h - gutter * 2 }}px;
|
||||
margin: {{ gutter }}px;
|
||||
border-radius: {{ radius }}px;
|
||||
overflow: hidden;
|
||||
{% if shadow %}box-shadow: 0 4px 14px rgba(0, 20, 60, 0.25);{% endif %}
|
||||
background: #ffffff;
|
||||
.wrap { width: {{ w }}px; height: {{ h }}px; padding: {{ pad }}px; display: flex; flex-direction: column; }
|
||||
.accent-bar {
|
||||
flex: 0 0 auto; height: {{ accent_h }}px; width: 100%;
|
||||
border-radius: {{ (accent_h / 2) | round(0, 'floor') }}px;
|
||||
background: linear-gradient(90deg, {{ accent_start }} 0%, {{ accent_end }} 100%);
|
||||
}
|
||||
.header {
|
||||
height: {{ header_h }}px;
|
||||
background: linear-gradient(135deg, {{ accent_start }} 0%, {{ accent_end }} 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
.city {
|
||||
flex: 0 0 auto; margin-top: 6px; font-weight: 700; font-size: {{ city_size }}px;
|
||||
letter-spacing: 0.12em; text-transform: uppercase; color: #5b6674;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%;
|
||||
}
|
||||
.header .title { color: #ffffff; font-weight: 700; font-size: {{ title_size }}px; }
|
||||
.body { display: flex; height: calc(100% - {{ header_h }}px); padding: 12px 8px; }
|
||||
.col {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
.days { flex: 1 1 auto; display: flex; align-items: center; gap: {{ col_gap }}px; }
|
||||
.col { flex: 1 1 0; display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 0; }
|
||||
.day-label {
|
||||
font-weight: 700; font-size: {{ day_label_size }}px; letter-spacing: 0.06em;
|
||||
text-transform: uppercase; color: #5b6674;
|
||||
}
|
||||
.day { font-weight: 600; font-size: {{ label_size }}px; color: #17233b; }
|
||||
.icon { font-size: {{ icon_size }}px; line-height: 1; }
|
||||
.temps { font-weight: 700; font-size: {{ label_size }}px; color: #17233b; }
|
||||
.temps .low { color: #6b7788; font-weight: 400; }
|
||||
.temps { display: flex; align-items: baseline; gap: 3px; }
|
||||
.high { font-weight: 700; font-size: {{ high_size }}px; line-height: 1; color: #17233b; }
|
||||
.low { font-weight: 400; font-size: {{ low_size }}px; line-height: 1; color: #5b6674; }
|
||||
</style></head>
|
||||
<body>
|
||||
<div class="card">
|
||||
{% if city_label %}<div class="header"><div class="title">{{ city_label }}</div></div>{% endif %}
|
||||
<div class="body">
|
||||
<div class="wrap">
|
||||
{% if city_label %}
|
||||
<div class="accent-bar"></div>
|
||||
<div class="city">{{ city_label }}</div>
|
||||
{% endif %}
|
||||
<div class="days">
|
||||
{% for d in days %}
|
||||
<div class="col">
|
||||
<div class="day">{{ d.label }}</div>
|
||||
<div class="day-label">{{ d.label }}</div>
|
||||
<div class="icon">{{ d.emoji }}</div>
|
||||
<div class="temps">{{ d.high }}°<span class="low">/{{ d.low }}°{{ unit_suffix }}</span></div>
|
||||
<div class="temps"><div class="high">{{ d.high }}°</div><div class="low">{{ d.low }}°{{ unit_suffix }}</div></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user