# Widget system A frame's panel isn't one fixed "mode" anymore -- it holds N independently placed/sized widgets (photos/calendar/whiteboard/tasks/static image/text/ weather/battery), like arranging icons on an Android home screen. A frame can hold several widgets of the same type (e.g. two photo widgets pointed at different Immich albums side by side). This replaced an earlier design where `Frame.mode` picked exactly one full-panel renderer; that column and the other per-mode `Frame` columns it left behind (`album_id`, `calendar_*`, `whiteboard_*`, etc.) were dropped in migration 41, once every phase of the rollout had shipped (see "Known gaps" below for what's still open). The device-facing contract is unchanged by any of this: `GET /frame/image`, `POST /frame/advance`, `POST /frame/back` are the same frozen paths firmware has always called (see `docs/architecture.md`) -- what changed is entirely server-side, in how those endpoints decide what to render and what a button press does. ## Data model - `Widget` (`server/app/models.py`): `id`, `frame_id`, `widget_type` (`"photos"` | `"calendar"` | `"whiteboard"` | `"tasks"` | `"static"` | `"text"` | `"weather"` | `"battery"`), `x`/`y`/`w`/`h` (grid cells), `sort_order`. Widgets never overlap (enforced server-side in `routers/api_widgets.py`, re-validated regardless of what the client already checked) -- that's what keeps compositing simple: no z-order, no blending, just N independent regions pasted onto one shared canvas. Also carries an optional per-widget border (`border_style` -- `"none"` | `"solid"` | `"dashed"` | `"dotted"` | `"fancy"`, `border_thickness`, `border_color_index`, an index into the frame's palette so a border always renders as one of the panel's exact 6 ink colors) directly on `Widget` itself rather than a per-type config table, since every widget type can have one regardless of `widget_type`. Drawn by `image_pipeline.draw_widget_border` onto each widget's own region in `routers/device.py`'s `_render_widgets`, before that region is pasted onto the shared canvas -- one central integration point instead of every `app/widgets/*.py` module needing to know about it. Set via the gear-icon dialog's shared "Border" card (`_widget_border_fields.html`, included by every `_widget_dialog_*.html` template) and `POST .../widgets/{id}/border`, its own endpoint (not folded into `api_widget_config_save`) since that endpoint's per-type dispatch is keyed on a config row via `widget_locked`, and border fields live on `Widget` itself, not any per-type config table. Also carries `font_scale` (one of `panel_style.FONT_SCALE_CHOICES` -- `1.0`/`1.25`/`1.5`, labeled Normal/Large/X-Large), a per-widget legibility control: calendar and tasks widgets pack in the most body text at the smallest default sizes, so their gear-icon dialogs get a "Text size" card (`_widget_font_scale_fields.html`) the other types don't. Same Widget-level-property-not-config-field reasoning as border, and its own `POST .../widgets/{id}/font-scale` endpoint for the same reason. `panel_style.scaled_size(value, font_scale)` is the one shared multiply-and-round point every classic (`calendar_render.py`) and modern (`html_render.py`/`calendar_html_render.py`) size calc routes through immediately after its own tier lookup/floor, so row heights and per-view row caps (already derived from the font size, not a fixed constant) automatically re-fit around the bigger text instead of overflowing their box. - Per-type 1:1 extension tables -- `PhotoWidgetConfig`, `CalendarWidgetConfig`, `WhiteboardWidgetConfig`, `TaskWidgetConfig`, `StaticWidgetConfig`, `TextWidgetConfig`, `WeatherWidgetConfig`, `BatteryWidgetConfig`, each keyed by `widget_id` with `ondelete="CASCADE"` -- rather than one wide table with every type's mostly-irrelevant columns. `TextWidgetConfig.content` is parsed rich text (paragraphs of styled runs), never raw HTML -- see `server/app/text_content.py`'s module docstring for why that parse step is the widget's actual stored-XSS sanitization boundary. `PhotoWidgetConfig` mirrors `app/photo_queue.py`'s attribute names exactly, so that module's advance/back/queue logic ports across widget instances unchanged. `PhotoWidgetConfig.locked` (migration 27) freezes `current_asset_id` against both the timer-elapsed auto-advance (`photo_queue.get_current`) and the advance/back button actions (`app/widgets/photos.py`'s `ACTIONS`) until unlocked -- toggled via a "Lock this photo" button in the widget's own dialog (`POST .../widgets/{id}/lock`), shown as a lock badge on the widget's box on the Layout tab canvas. `TaskWidgetConfig` used to be a handful of `tasks_*` columns bolted onto `CalendarWidgetConfig` (a week-view-only, single-list task list); split into its own widget type (migration 17) so a task list can be placed and sized independent of any calendar's view/footprint, then (migration 18) given the same multi-source shape a calendar widget already has. `WeatherWidgetConfig` similarly lifts `CalendarWidgetConfig`'s embedded weather strip (still present and unchanged, `weather_*` columns) out into its own placeable widget type (migration 24) -- see "Weather widget" below. `BatteryWidgetConfig` (migration 25) is the odd one out -- its actual content (`Frame.battery_percent`/`battery_as_of`) isn't in this table at all, already existing frame-level state set by `routers/device.py`'s `frame_battery` regardless of whether a battery widget is even placed; the config row only holds a display-mode setting (`"compact"` | `"detailed"`). - `FrameCalendar`/`FrameTaskList` are keyed by `widget_id` (not `frame_id`) since a frame can now have more than one independent calendar/tasks widget, each with its own included set. Identical shape and permission model (owner-added, anyone-linked-can-mute, see "Per-widget config UI" below) -- `FrameTaskList` just has no `"ics"` calendar_key variant, since a plain ICS subscription has no VTODO (task) collection. - `FrameButtonAction` (`id`, `frame_id`, `button` [`"next"`|`"back"`], `widget_id`, `action`, `sort_order`) -- see "Button actions" below. ## Placement: a grid, not freeform pixels `app/grid.py` is pure grid math, no I/O. The grid is `GRID_LONG=8` x `GRID_SHORT=5` cells, defined relative to the panel's long/short axis (not "landscape" specifically) so it stays valid across `image_pipeline.logical_render_size(orientation)`'s genuine width/height swap for portrait -- landscape orientations are 8 cols x 5 rows, portrait are 5 cols x 8 rows, same cell size either way. **Changing a frame's orientation invalidates its existing layout** (an 8x5 arrangement isn't valid on a 5x8 grid) -- the server resets to one full-panel widget on an orientation change rather than trying to remap coordinates. Each widget type has a minimum grid footprint (`grid.MIN_FOOTPRINT`): photos 1x1, calendar 3x2 (a crammed calendar is illegible regardless of size-tier scaling), whiteboard 2x2, tasks 2x2, static image 1x1, text 2x1, weather 2x2 (its hourly/daily strips need the room; current/multi_city modes would tolerate smaller, but every mode shares one footprint value), battery 1x1 (just an icon + a percent, legible even at a single cell, like photos/static -- though see `MIN_FOOTPRINT`'s own comment in `grid.py` on a mobile-width gear-icon click-target gap at that size, already pre-existing for photos/static too). Enforced both client-side (UX, in the Layout tab's drag/resize canvas -- `static/frame_layout.js`) and server-side (`routers/api_widgets.py`) -- the client is never trusted alone. ## Rendering: one shared compositor `app/widgets/` is the render/action registry -- one module per `widget_type` (`photos.py`, `calendar.py`, `whiteboard.py`, `tasks.py`, `static_image.py`, `text.py`, `weather.py`, `battery.py`), each exposing: - `render(db, frame, widget, target_w, target_h, is_normal_wake) -> Image`: an unquantized RGB image exactly `target_w x target_h`, the widget's content composed into its own region. Never raises for a foreseeable failure (an Immich hiccup, an unconfigured widget) -- falls back to a small placeholder within its own region instead, so one widget having a bad moment doesn't blank the whole panel. - `ACTIONS: dict[str, Callable]` -- named button actions this type supports (`"advance"`/`"back"` for photos and calendar, `"check_now"` for whiteboard and weather -- both throttled external fetches with a forced-refetch action). Empty for tasks, static image, text, and battery -- nothing to advance/back/force for a passive checklist, a fixed uploaded image, a fixed block of authored text, or a number the device itself pushes on every wake. - `ACTION_LABELS: dict[str, str]` -- human labels for the button- assignment UI. `routers/device.py`'s `_render_widgets` loads every `Widget` row for the frame, maps each one's grid rect to pixels (`grid.cell_to_pixels`), calls its module's `render()`, and hands the whole list of `(rect, image)` regions to `image_pipeline.render_panel` -- which pastes every region onto one shared canvas, then runs enhance/manage-overlay/quantize/dither/pack **once** over the composited result. Quantizing the whole canvas together (not each region separately before pasting) is what keeps the 6-color e-ink dithering pattern consistent across a widget boundary instead of a visible seam at the edge. Calendar widgets pick from discrete size tiers (`calendar_render.py`'s `_SIZE_TIERS`) for font size/margins/row heights based on their actual grid footprint, rather than continuously scaling constants tuned for a full ~800x480 canvas -- falls back to agenda view if a widget is too small for month view to stay legible. These tiers are pixel-size constants tuned against the 7.3" panel specifically; they aren't re-tuned or verified yet for the 13.3" panel's larger native resolution (see `docs/hardware.md`'s EE02 section) -- a widget's *grid footprint* (cell count) works the same on either panel, but its rendered legibility at that footprint's actual pixel size hasn't been checked on the bigger panel. ### "Modern" render style (experimental) Every widget type except photos has a `render_style` column (`"classic"` default | `"modern"`) that swaps its hand-drawn PIL primitives for an HTML/CSS render: a Jinja2 template (`app/templates/widget_html/`) drawn through a persistent headless-Chromium browser (`app/html_render.py`, Playwright) instead of `ImageDraw` -- gradients, shadows, and soft icon shading PIL can't easily do. Calendar's own modern-style builders (all four view modes) live in `app/calendar_html_render.py` rather than `html_render.py` itself, mirroring `calendar_render.py`'s own separation from the simpler widget types. Every modern-style builder runs its own `ordered_dither` (Bayer/ordered, not Floyd-Steinberg) before returning, committing the widget to exact palette colors *before* compositing -- safe to mix with photo/other classic-rendered widgets on the same frame without a Floyd-Steinberg seam at the boundary, because ordered dithering has no cross-pixel error term the way Floyd-Steinberg's diffusion does (see `html_render.py`'s module docstring). No `Frame`-level dithering setting was needed to make this work. Not offered for the **photos** widget -- a real photograph isn't a synthesized dashboard card, and photos has a different concern instead: its own independent palette/dithering strength (`Frame.photo_palette_rgb` / `photo_dither_strength`, a second "Photos configuration" card in Advanced Configuration, separate from the main `palette_rgb`/ `dither_strength` every other widget uses). `widgets/photos.py`'s `render()` quantizes itself against these before returning, so a frame can tune the rest of its widgets' look (e.g. a calibrated palette for modern-style dashboard widgets) independently of what actually looks best for real photographs, with no `render_panel` changes needed -- see that module's own docstring for the one small, accepted edge case (a border on a photos widget whose palette genuinely diverges from the frame's main one). Playwright/Chromium is a real, heavyweight runtime dependency imported lazily only when a widget actually uses modern style. Its browser binary is fetched by `start.sh` at container startup rather than baked into the image (see `server/Dockerfile`'s own comment) -- a single ~181MB `chrome-headless-shell` binary can't be split across Docker layers the way this project's pip/npm installs were, and confirmed-failed to push to the registry as a build-time layer; cached on the `/data` volume (`PLAYWRIGHT_BROWSERS_PATH`) so only the very first boot on a fresh volume actually downloads it. Still real-panel-unverified -- treat every "modern" style as experimental regardless of deploy status. Per-widget-type notes: - **weather**: `current`/`daily` modes only -- `hourly`/`multi_city` always render classic regardless of this setting (see the Weather widget section below). - **calendar**: all four view modes (agenda/today_tomorrow/week/month) have a modern builder -- the only widget type with full modern-style coverage from the start, rather than a partial rollout like weather's. Month view's "falls back to agenda below a size threshold" behavior (`_month_view_fits`) is honored identically in both styles. - **battery/text/tasks**: full coverage (both battery modes; text reuses its own `_fit()` shrink-to-fit sizing logic, only the drawing differs). - **static image/whiteboard**: modern style is the *first* visual chrome 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. 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 `Frame.theme` (String, default `"classic"`, one Advanced Configuration `