# Changelog All notable changes to Tesserae are recorded here. Format loosely follows [Keep a Changelog](https://keepachangelog.com/); versions are [SemVer](https://semver.org/) (pre-1.0, so minors can carry breaking changes). ## [Unreleased] ### Added - **Panel view: preview a dashboard as the e-ink panel will actually paint it.** The dashboard editor's live preview gains an HTML view / Panel view toggle. Panel view quantises and dithers the render to the target panel's colour palette, so you see the exact per-pixel output (dithering, palette reduction) before pushing, per preview group's gamut. Also adds a shared source-crop primitive (a normalized crop + rotate applied before the panel fit) that the Send and Companion image paths will build framing on. - **Companion dashboard listings now include their Phosphor icon name.** The optional `icon` field uses the same bare identifier as the web dashboard list, so companion clients can render a consistent icon and safely fall back when a dashboard has no icon. - **Icon references that resolve to no glyph are named instead of rendering a blank box.** `render_report` now always includes `icon_invalid` (mirroring `tap_invalid`): unknown slugs or weights on `icon` elements, bad `icon`-transform bind-table values, and a heuristic scan of code/html/svg markup for `ph-` classes that aren't real Phosphor icons, each with the element id and reason. The `GET /api/mcp/icons` search normalises its query to slug form (`ph-` prefix stripped, underscores as dashes), so `ph-heart` and `calendar_heart` now match instead of returning zero results. The canvas renderer falls back to bold for an unknown icon weight rather than building a class that matches no stylesheet. Agent docs (server and bridge) spell out the two markup traps: regular weight needs both classes (`ph ph-heart`), and a wrong slug fails silently. - **Render diagnostics: `render_report` gains `debug=1`, so silent render failures name themselves.** The diagnostics section reports, per render: console errors/warnings from every frame (a throwing code-element script surfaces tagged `[code-el ]`, including uncaught async errors and CSP-blocked loads inside the sandbox), uncaught page errors, failed and 4xx/5xx network requests with URLs, per-font-face load status (`loaded | pending-at-capture | failed | never-requested`, with the `@font-face` src), authored element CSS the browser silently dropped (selector + declaration + reason, via a re-parse diff), which vendored bundles each code element inlined, and the settle record that gated the screenshot (goto / compose-signal / image-wait / font-wait outcome + elapsed ms). One `render_report(debug=True)` call now names problems that previously took pixel-diffing dozens of renders. `render_preview` and `render_report` also accept `fresh=1` to bypass the last-good fallback and widget data caches (`?fresh=1` now works on `/compose/` itself), so a stale cached result can't derail an investigation. A live-Chromium regression suite pins the acceptance case (throwing script + 404 font + dropped CSS rule, one call names all three) and that identical page content yields identical font/asset outcomes across runs. - **The MCP surfaces the icon set and code-element toolkit to agents.** `list_widgets()` now returns the vendored code-element libraries (Chart.js incl. the datalabels and Sankey plugins, canvas-gauges, Day.js, qrcode, marked, chroma, SVG.js, Phosphor) and an icon descriptor, and a new `GET /api/mcp/icons?q=` searches the 1500+ Phosphor names so an agent picks real slugs instead of guessing. The `tesserae-mcp` bridge exposes the search as a `list_icons` tool. Previously these were only described in the prose instructions, so agent-built dashboards under-used icons, charts, and the toolkit. - **Companion API 0.5: send a public image URL or a webpage to your displays.** Community clients can push a public image URL, or a server-rendered screenshot of a public webpage, to explicit displays as asynchronous jobs, with the same History, resend, and photo layout-mode handling as an uploaded image. Both run a strict public-only URL policy with no client override: private, loopback, link-local, reserved, and embedded-credential destinations are refused, including redirect hops (re-validated during the fetch, and by a per-request interceptor during the webpage render). Webpage sends need a browser pool and are advertised only when one is present. - **Companion clients can inspect the exact frame waiting for a sleeping display.** A REST device with `has_pending_render` now includes an optional `pending_render` revision and authenticated preview URL. The existing device preview endpoint accepts that revision while preserving its default last-served meaning, so clients can present Current and Next without guessing from global History or racing a newer render. - **Companion display cards distinguish served and pending frames.** REST `/frame` polls now retain the last frame handed to each device, including matching `304 Not Modified` confirmations. The Companion device preview uses that served frame while `has_pending_render` reports when a newer render is waiting for the panel's next wake; MQTT and push transports continue to use the latest server render. - **Companion API 0.4 adds canonical History and all five photo layout modes.** Community clients can page through the same push History shown in the web UI, fetch retained composition previews, and idempotently resend an entry to its original display snapshot while respecting quiet hours. Successful jobs may include the exact new History event IDs, and image uploads now advertise and accept Fit, Fill, Blur, Stretch, and Center. - **The opt-in daily heartbeat now includes a bucketed count of paired companion apps.** Same `0`/`1`/`2-3`/… bucketing as the device count, derived server-side from the live companion tokens (never a client name, install id, or app version), so adoption of the community companion app is visible without the app itself ever contacting api.tesserae.ink. Sent only when online features are enabled, like the rest of the heartbeat; see the privacy page. - **REST devices work on publicly-hosted instances (opt-in).** A new Settings → Server → Network toggle, "Allow REST clients on public networks" (off by default), lets a device fetch its rendered frame over a public address using a signed, short-lived URL the server hands it after it authenticates. Off, render artifacts stay reachable only from your LAN or an authed session; on, the operator accepts that a device's signed frame URL is fetchable from the internet until it expires. Unsigned public access is always refused, and no firmware change is needed. ### Fixed - **Panel view toggle now actually switches.** Selecting Panel view appeared to do nothing: the preview iframe's `display: block` overrode the `hidden` attribute the toggle set, so the live render stayed on top of the quantised image, which itself rendered at full panel resolution outside the frame. Both are fixed (explicit `[hidden]` override on the iframe; the panel image reuses the raster preview's letterboxed `object-fit: contain` box). The HTML-view button is also renamed from "Fit view", which read as a sizing control. - **Saving a REST device no longer emits a misleading MQTT publish failure.** REST instances can retain dormant MQTT topics for a later transport switch; device settings now follow the active transport, save to disk, and return on the next status poll without attempting a broker publish. - **CalDAV discovery handles compressed responses.** A Nextcloud behind a proxy or CDN can return a gzip- or deflate-compressed body; Python's HTTP client doesn't request or decompress that, so the parser saw a binary blob and discovery failed with "wasn't valid CalDAV XML." Discovery and the feed fetch now request an uncompressed body and decode gzip/deflate if the server compresses regardless, and the diagnostic log includes the response's content type and encoding. - **CalDAV discovery tolerates a stray BOM or whitespace before the XML declaration.** Some servers (notably Nextcloud behind certain PHP / output-buffering setups) emit a newline ahead of `= 2` as patch-capable alongside overlay schema 2. ### Added - **Protocol v2 server surfaces (device-owned touch).** Devices advertising `proto: {v: 2}` (sticky, persisted) get: interaction manifests (`GET /frame/manifest?digest=`, plus a pointer on `/frame` responses) with stable region ids, tier/type classification, feedback modes, and text regions; region-id action reports on `POST /tap` validated by re-minting ids from the frame's own sidecar; a Server-Sent Events channel (`GET /stream`) carrying values / patches / sync envelopes with keepalives; and state bundles (`GET /bundle`) projecting the warmed deck cache into digest-addressed frame states with a navigation links table. v1 responses stay byte-identical for devices that don't advertise the capability. Contract: `docs/protocol-v2-touch.md`. ### Removed - **Schema-1 overlay specs** (protocol v2 cleanup, see `docs/protocol-v2-touch.md`). The `GET /frame/overlay/` endpoint and the `build_spec` document builder are gone: v1 firmware probing the endpoint gets a 404, which its contract has always defined as feature-off, so it degrades to dispatch-without-echo until the protocol-v2 interaction manifest ships. The atlas store, values document, patch documents, touch dispatch, and the overlay capability handshake are unchanged; non-touch rendering is byte-identical. ### Added - **Frame patches now actually stage under real dithering, and periodic small changes ride them too.** The patch diff moved to composition space (before per-renderer dithering): the .bin family's default error-diffusion dither made a one-tile change perturb the packed bytes of nearly the whole frame, so the wire-space diff always blew the budget and every reconcile fell back to a full frame — the schema-2 path never engaged on hardware. Composition rects now map through the same transform chain as tap targets and the blob is cut from the new artifact. On top of that, scheduled and push-triggered re-renders whose visual diff is small (a header clock tick) are delivered as patches on the current digest for schema-2 REST devices showing the same page: no full e-ink flash per clock tick, and stable digests mean a tap fired around a render can no longer be dropped as stale. Explicit repaint intents (resend, force publish) and big diffs still mint a new digest. - **Overlay capability survives restarts.** The advertised overlay schema persists in the device-facts store and re-seeds the status cache at startup, so a patch-capable panel isn't demoted to full-repaint reconciles between a server restart and its next heartbeat. ### Fixed - **Overlay values `seq` is now milliseconds.** Second-granularity seqs made two value changes inside one second dedup to a single repaint under the firmware's newest-wins rule. ### Added - **Post-action frame patches (overlay schema 2).** After a touch action fires a Home Assistant service call, the server re-renders the page headless, diffs the wire framebuffer against the frame on glass, and stages only the changed rects as a patch document (`patches` on `GET /frame/data`, `overlay_patches` on `/status`, blob via `GET /frame/patch/`). Capable firmware partial-refreshes those rects: state text catches up within a couple of seconds, with no full download, no full e-ink flash, and the digitizer live throughout. Documents are anchored to the served frame digest with a strictly increasing `seq` and are dropped the moment a newer frame lands, so a patch can never revert a pending push. Caps: 12 rects / 256 KB per document; past that the server falls back to a normal full frame. - **Overlay value slots: attribute paths, value maps, code-element support.** Slot keys accept an attribute path (`ha:light.desk:attributes.brightness`), a slot can declare `data-overlay-map='{"on":"1","off":"0"}'` to render non-numeric states with the numeric glyph atlas, and slots inside code-element sandboxes are now collected through the same mirror mechanism as their touch regions. ### Changed - **The post-HA repaint no longer runs inside the touch wake.** The synchronous re-push (full render + download + flash while touch was locked, roughly one action per 10 s) is replaced by a debounced background reconcile that re-renders whatever the device is showing: rotation step, deck page, or the last directly-pushed page (previously, devices without a rotation never repainted at all and kept 304ing until the next schedule). Patch-capable devices reconcile ~0.4 s after the last tap of a burst; everything else gets one coalesced full push ~3 s after (`app.touch_patch_debounce_s` / `app.touch_repush_debounce_s`). ### Added - **Pages own their update cadence (discussion #140).** Every dashboard gains an "Updates" setting on the Dashboards list (only when pushed / every minute / 5 min / 15 min / hourly / daily). The scheduler re-renders the page on that cadence and delivers only to panels currently showing it (resolved from deck position, rotation position including manual holds, or a device bound to exactly one page); nobody showing it means no render at all. Freshness is now a property of the page's content, not a side effect of rotation dwell: a clock page set to "every minute" stays live through a 15-minute rotation step. Delivery respects quiet hours, and unchanged renders still answer battery panels' polls with 304. Default is "only when pushed" (no behaviour change for existing pages). ### Fixed - **Deck home-return now respects quiet hours on its promote fast path.** The timer-driven return-to-home could repaint a panel inside its quiet window when a pre-warmed frame was available (the push fallback already gated itself); the return now defers until the window ends on both paths. Hand navigation and the Push button remain quiet-exempt (user-initiated). ### Added - **Deck editor: pick pages, done ("dense rail + inspector").** Decks get a dedicated create/edit surface (Decks -> New deck / Edit): a flip-order rail of page cards with thumbnails, click-to-append page library with suggested clusters, an inspector for the selected card (home toggle, return-after slider, per-page refresh override, reposition / remove), inline settings bar (name, device chips, cadence, Advanced with entry page + a live-derived navigation-graph view), and a one-line behavior summary. Navigation derives automatically from the flip order; no graph authoring needed. Fully submittable without JavaScript (membership checkboxes + numeric order fallback). - **Deck home card with idle return.** Mark a member page as home and set "return here after" (slider, 0-120 min): the deck returns to it after that long without a button press or tap, enforced server-side for server-navigated panels and shipped in the sync manifest (`home` block) so SD-cache firmware enforces it offline. Push sends the home page to the panel first; a fresh device's entry page defaults to home. - **Deck sync manifests: swipe triggers and capacity awareness.** Link tables now carry swipe triggers (authored directions mirrored from the graph; paging defaults where silent: swipe left = next, swipe right = back), and when a device's advertised SD capacity can't fit the whole deck, overflow pages are marked `cache: false` with ring-from-home priority instead of letting the card overfill mid-sync. ### Added - **Rotations re-render at every dwell boundary, including onto themselves (discussion #140).** Previously a rotation only rendered on step *transitions*, so a step's widget data froze for its whole dwell (a device poll fetches the already-rendered frame; it never triggers a render), and a single-step rotation rendered exactly once, ever. A rotation now fires whenever a new dwell window begins, even when the step is unchanged, so a one-page rotation with a 5-minute dwell simply means "keep this page fresh every 5 minutes". The min-hold flap guard still applies to step changes but not to self-fires. Alongside this, rotation fires now exclude panels sitting inside a manual button/touch hold (previously any fire could yank a paged-away panel back mid-hold; now the rejoin pass restores them only when the hold lapses). (Supersedes the `refresh_minutes` setting that briefly shipped in v0.190.0; rotations saved with it still load, the field is ignored and dropped on next save.) ### Fixed - **Devices paged away by button/touch now rejoin their rotation (discussion #140).** The scheduler only pushed on step transitions, so on a rotation dominated by a long-dwell step a panel that was manually paged away (physical button / touch) stayed on the manual page indefinitely: the manual hold (`override_until`) was recorded but nothing acted when it lapsed. A per-tick rejoin pass now pushes the rotation's current page, device-targeted, to any device whose manual hold has lapsed, then clears the hold; devices still inside their hold are untouched, and a device already on the current step just has its hold cleared. Hold length remains "rest of the day" by default, or `app.button_hold_seconds` when set. ### Added - **Decks navigate without a hand-built graph.** Where a deck page's graph is silent, sync manifests now synthesize default links (`left`/`right` to prev/next in deck order, wrapping, plus left-half/right-half tap zones on touch panels when the page has no zones or markup touch regions of its own), and server-side button handling applies the same prev/next default for deck-bound devices, so a graph-less deck navigates identically on-device and via the server (previously `right` fell through to the rotation map and read as "refreshed but didn't navigate"). Explicit graph links always win. MCP `create_deck` with a bare page set now derives the graph from the pages' `page:` tap/swipe links automatically (bridge 0.8.2 documents it). - **Push button on the Decks page.** One click warms every page for every bound device, sends the entry page to the panel(s), and seeds the nav position, so a new deck goes live immediately instead of waiting for the scheduler's warm tick. Deck cache hygiene is documented in client-protocol.md: digests are content-addressed, so only pages with volatile content (clocks, "last updated") churn the deck version. ### Fixed - **Completed firmware updates stayed labelled as queued.** The Firmware page now distinguishes retained canary/fleet rollout membership from a release that is still newer than the device. Once a device reports the imported version it reads as up to date, while pending rows name the actual imported release instead of a potentially newer online Available version. - **Deck heartbeat reports could revert a freshly pushed dashboard.** v0.187.1's report promotion promoted whatever page the panel said was on glass, so a heartbeat arriving between a push and the device's next fetch clobbered the pending frame with the older deck frame, and the new dashboard (and its touch zones) never landed. Promotions from reports and from touch reconciliation now carry a recency guard: what's on glass only wins the live slot when it isn't older than what's pending there. A touch on stale glass still dispatches against the frame the finger actually touched; it just can't revert the pending push. - **Buttons and touches were permanently deduped after a device power cycle.** The firmware's wake-event counter is RTC-backed and restarts at 0 on any power cycle (battery pull, crash, reflash), usually without re-pairing since the token survives in NVS; the server's dedup rule treated anything `<=` the persisted high-water mark as a retry, so a restarted counter had every subsequent button and touch silently swallowed. Dedup is now equality-only (a genuine retry resends the same id; a lower id is a restart or an offline-queue replay and dispatches), and the re-pair paths (`/register` on an existing id, `/discover` MAC claim) additionally clear the dedup state outright. - **Touches were dropped as stale after deck local navigation.** When firmware paints a deck page from its SD cache, the panel shows a frame the server never served via `/frame`, so the touch stale-check rejected every subsequent stroke, and a routine conditional poll could even repaint the panel backwards to the pre-nav frame. A digest that matches a deck-cached render is now reconciled instead of dropped: the frame is promoted into the live slot (ETag polling 304s, nav position recorded) and the stroke hit-tests against its composition. `deck_page_id` reports do the same promotion, and report ingestion now runs before button/touch dispatch on `/frame` and `/status` so same-wake events resolve from the page actually on glass. Digests matching nothing are still dropped as stale. ### Added - **Per-device overlay target budgets (firmware v1.9).** The overlay capability now carries the device's own tap-echo buffer size (`overlay: {schema, max_targets}`, 32 on current E1003 firmware); the server trims target lists to the advertised value, treating absence as the v1.8 baseline of 8. When a frame has more touch regions than the budget, navigation targets (`page:` / `step:` / `rotate_*`) win the echo slots ahead of miscellaneous actions, and survivors always emit in document order. Spec size is guarded against the firmware's 8 KB parse buffer. The MCP device list now reports `overlay: {max_targets}` so agents can design within the real per-panel budget. - **MCP surfaces the deck-cache and overlay capabilities.** `/api/mcp/devices` entries now carry the hardware `kind` plus firmware capability flags from live heartbeats: `overlay: true` (instant tap echo + live value slots apply on that panel) and `deck_cache: {capacity_bytes}` (radio-off deck navigation). `render_report` extracts `overlay_slots` alongside `tap_regions` (also in `?view=touch` and `?fields=`), so an agent can verify a `data-overlay-key` annotation survived the render. The tesserae-mcp bridge (0.8.1) documents the live-value-slot vocabulary and its server-enforced guardrails (8 slots, 2 font buckets, numeric charset, 47-char values, `ha:` keys only, widget-markup-only extraction) in the handshake instructions. - **Overlay value slots + glyph atlases (hybrid render mode, schema 1 slice 2).** Widgets can mark an element `data-overlay-key="ha:"` (optional `data-overlay-suffix`) and capable touch firmware repaints just that slot with live values during a wake, no full re-render. The slot's box, alignment, font size, and weight are extracted in the same Playwright pass as touch regions (sidecar v3); glyph atlases are rasterized through the same browser + Inter faces as the composition so blitted text is pixel-identical, packed 4bpp to the firmware's strip contract, and served content-addressed at `/frame/overlay/atlas/`. Live values come from `GET /frame/data?digest=` (pre-formatted strings via the ha_core plugin) and piggyback as `overlay_values` on `/status` responses for capability-advertising devices. Firmware caps honoured server-side (8 slots, 2 atlases by largest group, 32 glyphs, 47-char values); every failure path degrades the spec to rect-only. Contract updated in docs/dev/client-protocol.md. - **Overlay specs for touch boards (hybrid render mode, schema 1).** Firmware with fast partial refresh (reTerminal E1003 first) advertises `overlay: {schema: 1}` and fetches `GET /api/v1/device//frame/overlay/`: a rect-only draw list of tap-echo targets derived from the frame's touch-region sidecar, transformed server-side into wire-framebuffer pixel space (rotation, flip, scaling, underscan all applied at spec-build time, so the firmware uses coordinates verbatim). A tap inside a target inverts and partial-refreshes that rect locally in a few hundred milliseconds while the stroke still dispatches to the server as normal. Works for both live frames and deck-cached frames; capability is sticky per device. Value slots and glyph atlases are the next schema slice. Contract in docs/dev/client-protocol.md ("Overlay specs"). - **XIAO ePaper 7.5" black/white/red variant.** `bwr_3` is now a canonical packer gamut: the `esp32_bin` renderer packs tri-colour panels to the native 2-bpp layout (96000 bytes at 800x480, MSB-first, 0b00 black / 0b01 white / 0b10 red; the reserved 0b11 is never emitted), sharing the BWRY 2-bpp path. New `xiao_epaper_75_bwr` hardware SKU on the same `esp32_bw_client` protocol as the mono board, so reflashing a unit between mono and BWR firmware migrates its registration automatically (same-protocol kind heal) while keeping the device row, token, and history. - **Deck cache sync for devices (on-device SD frame cache).** Firmware with local storage can now cache a bound deck's pre-rendered frames and navigate button/touch links on-device (wake, read card, paint; no WiFi round trip). New device-facing surface: a `deck_cache` capability advertised in heartbeat bodies (current-state per beat, withdrawn the moment a card disappears), a `GET /api/v1/device//deck` manifest (page frame digests, byte sizes, TTLs, and the link graph; cold pages are warmed on demand), digest-addressed frame fetch at `GET /api/v1/device//deck/frame/` with immutable cache headers, a `deck.version` envelope on `/status` responses so firmware knows when to re-sync, and `deck_page_id` reporting on `/status` bodies and `/frame` query params so locally-navigated pages keep the server's nav position truthful. Devices that never advertise the capability see byte-identical responses everywhere. Contract documented in docs/dev/client-protocol.md ("Deck cache sync"). - **2-bit grayscale renderer + reTerminal E1001 grayscale variant.** New `esp32_gray2_bin` renderer packs compositions to 4-level grayscale at 2 bpp (96000 bytes for 800x480; MSB-first, 0b00 black, 0b11 white, linear) for UC8179-class mono panels driven in their 4-gray waveform mode, and a `seeed_reterminal_e1001_gray` hardware SKU pairs it with the grayscale firmware build. Same `esp32_bw_client` protocol as the mono SKU, so the registration variant picker offers both and an already-registered E1001 migrates automatically when its firmware re-declares the gray kind. - **Stale device kind auto-heals on re-pair (#121).** A device that first registered under a generic protocol kind (e.g. `esp32_client`) and later comes back declaring its hardware-catalog SKU (e.g. `seeed_reterminal_e1004`) is now moved to the declared kind instead of staying pinned to the one it first paired as. Applies on `/register` re-pair and on the `/discover` MAC-claim path a re-flashed device actually hits; restricted to kinds sharing the same wire protocol, so a heal can only refine which board, never move a device across protocols. Fixes devices being silently exempt from per-kind OTA rollouts because releases are keyed by the SKU kind while the instance sat under the generic one. The device's cached render is invalidated on a move so `/frame` serves 204 until the next push repaints at the new kind's geometry. - **OTA rollout UI (Settings → Firmware) (#121).** A guard-railed admin page over the same per-kind rollout state the CLI writes (never a parallel store). Per device kind it shows the current release and its verified manifest (fw_version, key_id, sha256, size, image host), and the rollout controls: import + verify a `descriptor-.json` (rejected with the verifier's reason if the signature or key doesn't check out, or the kind is unknown), set a canary from the kind's OTA-capable devices, promote to the whole kind (disabled until a canary reports `confirmed` on that version, with a confirm dialog stating how many devices will be offered it), and pause/withdraw. A fleet view lists each device's firmware version and OTA phase chip, floating `rolled_back` / `failed` to the top; devices that never advertised OTA support show "USB update only". Every action is event-logged. The page never fetches the image bytes; it shows the URL and the devices fetch it themselves. When online mode is on, each kind also shows whether a newer firmware has been published (api.tesserae.ink's per-kind update check, cached hourly) with an "available" badge, and a one-click "Import from release" that fetches the release's signed descriptor (host-allowlisted), verifies it against your trust anchor, and sets it as the release, no manual download. The check now sends the reported firmware version (`?current=`) so api.tesserae.ink can aggregate version distribution. When online mode is off the check never runs and the page discloses that turning it on pings api.tesserae.ink with the kind ids and reported firmware versions; rollout itself stays fully offline. - **OTA state reporting is ingested server-side (#121).** A device that speaks OTA reports where it is in the update lifecycle on the `ota` object of its heartbeat (`phase` / `reason` / `target_fw` / `attempt_id` / `detail`, per the contract's State reporting section). The server now records the latest report on the device's live status, shows it as a chip on the Devices card (green `confirmed`, red `failed` / `rolled_back`, amber `rejected`, neutral in-progress), and appends an event-log row on each lifecycle transition (a terminal report re-sent every heartbeat logs once). A capability-only or `idle` beat leaves the last outcome standing. The report is advisory; the device's own first-boot checks remain the acceptance gate. ### Fixed - **OTA release path crashed on the Docker image, and shipped no trusted keys (#121).** Two packaging gaps blocked the first canary. `app/ota/release.py` imported `packaging.version` at module scope, but `packaging` was never declared and is absent from the slim image, so the release CLI and the `/status` release-delivery path (which imports `is_newer`) both raised `ModuleNotFoundError`. The version comparison now uses a small internal plain-SemVer helper (`app/semver.py`) with no third-party dependency. Separately, the Dockerfile did not copy `ota/`, so `load_trusted_keys()` found an empty registry at its default `ota/keys` dir; the image now includes it. - **Resend from History never reached devices served by a per-device renderer clone (#119).** A resend replayed the stored composition as an unbound fan-out, and unbound pushes deliberately skip clone renderers (`__`, the #83 guard), so for a bound device the resend published nothing: the device's latest-frame entry kept pointing at the newer frame and its REST `/frame` poll answered 304 against the resent frame's differing ETag. The push history row already snapshots the delivery targets, so a resend now replays those exact targets (and the matching panel dims) through the fan-out. - **Canvas editor: HA entity filter, icon picker, and per-entity overrides were dead (#130).** The canvas config drawer injects a widget's options form after page load, but the canvas editor template is standalone and never loaded the icon-picker / entity-overrides modules, and the multi-select filter wiring lived only in the grid editor. So for the Home Assistant "Entities" widget the filter box did nothing, the icon picker didn't open, and the per-entity label / icon / number-format overrides never rendered. The filter is now a shared component wired in both editors, the two modules load on the canvas page, and the drawer initializes all three after injecting the form. - **Canvas preview failed behind a non-default external port (#129).** When the server was reached on a port that differs from its internal bind (a reverse proxy, k8s Service, or Docker port map sending external 4567 to the container's 8765), the canvas preview and other render routes built the internal loopback fetch from the browser's port, so the headless renderer hit `127.0.0.1:` where nothing listens and the render was refused. The `tesserae` CLI now records its real bind port in `TESSERAE_BIND_PORT` (previously set only by the Home Assistant add-on), so the renderer always fetches `/compose` on the port Flask actually binds. - **Status bar showed the wrong device's battery (#125).** Four gaps fed the same symptom, a status bar falling back to the lowest battery / signal across all devices (and the wrong temperature / humidity). (1) Per-device render detection only scanned grid cells, so a canvas dashboard pushed once per panel instead of per device; it now scans canvas elements too. (2) Even when a canvas push fanned out per device, the canvas render path dropped the target device before the widget fetch, so every panel still resolved the aggregate; the target now threads through to the fetch. (3) Editor previews (the dashboards-list hover thumbnail and the live compose iframe) carry no target device, so both grid and canvas previews showed the aggregate; a preview now defaults to the page's first bound device and shows a real one. (4) The per-device fan-out decision read the plugin registry off `current_app`, which is absent on the scheduler and rotation push threads, so scheduled refreshes silently dropped the fan-out and a grid panel kept showing the aggregate even when a manual Send was correct; the push manager now holds a direct registry accessor. ### Added - **`fetch_latest` button action.** Re-downloads and repaints the latest frame already rendered for the device without running the composer, publishing a new artefact, moving the rotation, or setting a manual override. The action bypasses a matching `If-None-Match` for its `/frame` response, so its meaning does not depend on a particular firmware clearing its cached ETag first. - **MCP: rotations, schedules, and decks.** The agent MCP surface (`/api/mcp`) and the `tesserae-mcp` bridge now expose list / create / delete for rotations and schedules, and list / create / delete / suggest for decks, so an agent that builds pages can also wire how they cycle (rotations), when they push (schedules), and how they group for instant navigation (decks). `suggest_decks` derives a ready-made deck from the `page:` tap / swipe links already on page elements, so the agent can offer a deck once it has wired inter-page navigation. - **Decks page reshaped around the canvas.** The Decks page is now a management surface, not a graph editor: you author navigation in the canvas editor (tap / swipe "go to page" on tiles), and the Decks page suggests a deck from those links, then owns the deck-level concerns, refresh cadence (deck default + per-page), devices, entry, enable, delete. A **Sync from links** button re-derives a deck's graph (and touch zones) from the current page links, and the raw JSON graph editor is demoted to an "Advanced" fold for manual tweaks. - **Per-page refresh cadence in decks.** A deck page can set its own `refresh_interval_minutes` (in the graph editor or via MCP), overriding the deck's default, so a volatile tile can re-warm every few minutes while a photo page in the same deck refreshes rarely. `0` warms the page only on first navigation; unset inherits the deck cadence. - **Decks: pre-rendered, navigable page groups.** A new Decks page (next to Rotations) groups pages into a small linked graph that Tesserae keeps pre-rendered per bound device, so a button press or touch that moves between them serves an already-rendered frame instead of rendering on the fly. Each page links to others by a physical button name or a touch zone; the scheduler re-warms a deck's pages in the background on the cadence you set, so their data stays current. Removes the on-the-fly render latency from navigation (the download and e-ink repaint still happen). Build a deck under Decks: name it, bind devices, set the refresh cadence, and define the page graph. Decks can also be **suggested automatically**: when pages link to each other via tap / swipe "go to page" actions set in the canvas editor, the Decks page offers a one-click deck for each cluster, with the graph and touch zones derived from those links. - **OTA per-kind rollout: manual promote + canary (#121).** Beyond staging a build for a single device, an operator can now set a signed build as a device kind's release and roll it out deliberately: `python -m app.ota.release set` (offered first to the canary devices you list), `promote` (to every device of the kind), `pause`, `clear`, `list`. On `/status` the server offers a device its kind's release when the device is eligible (a canary, or the release is promoted) and the release firmware is newer than the version the device reports; a per-device staged descriptor still wins. The device-side apply/verify/rollback firmware is tracked separately, so nothing reaches a device until that ships. - **OTA production signing (Cloudflare Worker) and trusted-key registry (#121).** A Cloudflare Worker (`packages/ota-signer/`) signs firmware descriptors with an Ed25519 key held only in a Worker secret and serves the images from R2, co-locating signing with storage; its output is byte identical to the Python signer. Published public keys live in `ota/keys/.pub`, and `python -m app.ota.stage` now verifies a descriptor's signature against the key matching its `key_id` before staging, refusing a mis-signed one (`--insecure-skip-verify` to override). Keys are keyed by `key_id` so they rotate without a re-flash. The staging gate is server-side; firmware embeds its own key set as the real trust anchor. - **OTA capability handshake and `/status` delivery (Phase 2, #121).** A device advertises OTA support with an `ota: {schema: N}` object in its register/status body; the server hands back a staged, signed descriptor on the always-200 `/status` response only when the device advertised a compatible schema and the descriptor targets its kind. Descriptors are staged per device with `python -m app.ota.stage` (pipeline: `sign` then `stage`), held in `data/core/ota_pending.json`, and picked up on the next heartbeat. `/frame` is untouched, so the image channel stays byte-clean for every device kind. The production key, R2 image hosting, and OTA state reporting are later slices. - **Button wake window for ESP32 devices (#123).** A per-device `button_wake_s` config field (0-60 seconds, default 0) that keeps the device awake for a moment after a button press changes the page, so scrolling several pages doesn't pay a fresh wake and Wi-Fi cycle per press. Set it under Settings → Devices. The value is delivered on the `/frame` response a button wake already fetches (and via the config block on register/status), so firmware reads it without an extra request; 0 keeps the current behaviour of sleeping immediately, and it applies only to button wakes, not scheduled refreshes or rotation. Firmware support lands separately. - **OTA update contract (Phase 1).** The signing and verification half of the over-the-air firmware update flow: an Ed25519-signed `{payload, signature}` descriptor binds the target device kind, firmware version, image URL, size, and SHA-256. `app/ota/` carries the signer (with a `python -m app.ota.sign` CLI), the reference verifier (signature, then manifest shape, then target, then image digest, each failure a stable reason code), and the wire contract at `docs/ota/contract.md`. `tests/fixtures/ota/` publishes a test-only key and four signed fixtures (valid, wrong key, truncated, digest mismatch) so device firmware can self-test the verifier before the live pipeline exists. Descriptor delivery on `/status` behind a capability flag, and image hosting, are separate follow-up slices. ### Fixed - **Calendar discovery: adding one collection no longer hides the rest (#124).** After a CalDAV discovery, adding a discovered calendar redirected to a bare page that dropped the other found collections. The add now re-renders the discovery list (the added one marked "already added", the rest still one-click addable), so a server with several calendars can be added in a few clicks. ## [0.152.0], 2026-07-19 ### Added - **OpenDisplay device kind.** A new `opendisplay` device kind for OpenDisplay BLE e-paper tags, driven by the separate `tesserae-opendisplay` bridge: Tesserae renders a full-colour PNG (via the `pi_png` renderer) and the bridge polls the frame over REST and pushes it to the tag over Bluetooth LE, where the OpenDisplay SDK dithers for the tag's panel. REST-polled, honours `sleep_interval_s`. The panel size is set per tag at registration, so one kind covers every OpenDisplay panel. - **OpenDisplay via Home Assistant.** A second OpenDisplay path for people already running Home Assistant with the OpenDisplay integration: the new `opendisplay_ha` device kind writes each rendered frame into HA's media folder and calls the `opendisplay.upload_image` action, so HA owns the Bluetooth and no separate bridge or BLE hardware runs on the Tesserae host. Set the tag's HA device id on the device; each tag targets its own, so it scales to many tags. The frame uses a stable per-device filename overwritten in place (no media-folder growth), and orphaned files are swept on startup and when a device is removed. Best when Tesserae runs as the Home Assistant add-on with a shared `/media`; requires the Home Assistant Core plugin. - **Touch interactions guide (#49).** A new docs page covers enabling touch, attaching tap / swipe / slider / hotspot / code-element actions in the editor, verifying with the touch monitor, and implementing touch on any client (the transport is device-agnostic: ESP-IDF, CircuitPython, a Pi, or any HTTP client, not just the reTerminal firmware). - **"Fade old" toggle on the touch monitor (#49).** Dims older touch marks by recency so the latest activity stands out against the history. ### Added - **Set the log level on self-hosted installs (#122).** A `--log-level` flag and a `TESSERAE_LOG_LEVEL` environment variable set the root log level (trace / debug / info / warning / error) for Docker, LXC, and bare installs, which previously had no way to change it from the hardcoded INFO. The flag wins over the env var; the Home Assistant add-on keeps using its own Log level option. - **OpenDisplay-via-HA tags report battery, signal, and firmware.** These tags never heartbeat Tesserae directly (Home Assistant owns the Bluetooth link), so a background poller pulls each tag's battery / signal-strength / temperature / firmware from its HA entities (matched by device class, via `/api/template`) and reshapes them into a normal heartbeat. It flows through the same pipeline every other device uses, so the device card's battery, signal, and firmware tiles, the battery-drain history, and the event log all populate with no tag-side reporting. Polls every 15 minutes by default (`app.opendisplay_telemetry_interval_s`). - **Pick the OpenDisplay tag from a dropdown instead of pasting an id.** Both the Add-device form's OpenDisplay tab and the device card now list the OpenDisplay devices Home Assistant knows about (queried over HA's `/api/template` endpoint, no WebSocket), so you select the tag by name and Tesserae stores the correct device id. When the tag's model string carries a resolution (e.g. `296x128`), it also fills the panel size. Falls back to manual entry when Home Assistant isn't reachable. The underlying `ha_device_picker` config field type is reusable by any HA-targeting device kind. ### Changed - **OpenDisplay is a transport choice when adding a device.** The Add-device card's transport control gains an "OpenDisplay" option alongside REST and MQTT. It detects whether Tesserae is the Home Assistant add-on: if so it offers a short form to add the tag (pushed via `opendisplay.upload_image`), otherwise it points at the standalone bridge and the REST pairing tab. The "Issue pairing code" button also aligns to the note field and stacks full-width on narrow viewports instead of overflowing the card. - **The canvas and grid editors share one touch Interaction picker (#49).** The canvas editor had its own copy of the Interaction UI (on-tap / swipe / slider / code-element actions); it now uses the same `touch_interaction.js` module the grid editor does, so the two can't drift and an editor-side fix (e.g. decoding a Home Assistant action back into the form) only has to be made once. ### Fixed - **Physical buttons and touch swipes advance rotations built in the UI (#122).** The Rotations page saves a rotation with an empty `device_ids` (meaning "drive whichever devices the step pages are bound to"), but the button / touch handler only matched rotations that named the device explicitly. So no rotation created in the UI ever advanced on a `left`/`right` press or a swipe: the device just re-served its current frame, and the press showed in History as a red "failed" row. The handler now honours the same page-binding fall-through the scheduler uses, and benign button outcomes (no-op, duplicate, unmapped, dispatched) are labelled as such in History instead of "failed". - **The "Issue pairing code" button lines up with the note field.** The button sat ~12px below the input because the field wrapper's block padding extended the flex row's baseline; the pairing-row field now has that padding zeroed so the button's bottom meets the input's. - **OpenDisplay-via-HA no longer times out on slow BLE pushes or blocks the push pipeline.** A BLE e-paper transfer can take tens of seconds; the 10s HTTP timeout fired mid-push and masked Home Assistant's own error, and because push listeners run synchronously it would also have stalled the pipeline. The upload now runs on a single background worker (BLE is serial anyway, so pushes coalesce to the newest frame) with a 120s timeout, so a push returns immediately and HA gets time to finish or report the real failure. - **OpenDisplay-via-HA surfaces the reason `upload_image` failed.** When Home Assistant returns a 500 for `opendisplay.upload_image`, the log now includes HA's response body (the integration's actual error) instead of just "HTTP Error 500", so a failed BLE push is diagnosable without digging through HA's own logs. The **HA device id** help text and docs now spell out that it's HA's internal device id (a long hex string), not the tag's own name/serial, which was an easy mismatch to make. - **Resending a frame from History now re-paints REST clients (#119).** A resend force-republishes over MQTT, but a REST/HTTP-polled client comparing the content-addressed ETag would get a 304 and skip the re-paint when the resent frame was byte-identical to what it already showed. Resend now flags the frame so `/frame` serves one 200 to force the re-fetch, then clears the flag so the next unchanged poll is 304 again (the deep-sleep battery path is unaffected). - **OpenDisplay-via-HA can write frames to the media folder.** The container drops to an unprivileged user, but Home Assistant mounts `/media` root-owned, so the first frame write hit `PermissionError` and no frame reached the tag. The entrypoint now creates and chowns the `/media/tesserae` subdirectory while still root (HA core keeps read access), and the publisher logs one actionable warning instead of a traceback per render if the folder still isn't writable. - **OpenDisplay devices no longer read as MQTT.** Both OpenDisplay kinds carried a `status_topic` in their manifest, which tagged them as MQTT devices and showed dormant MQTT topic rows. Neither uses a broker: the OpenDisplay-via-HA kind now declares a new `push` transport (delivered through Home Assistant's `opendisplay.upload_image`, badged "HA"), and the bridge kind declares `rest` (the bridge polls the frame endpoint). Existing instances are normalised on load, so a device added before this fix corrects itself without re-adding. Both device cards also gain an OpenDisplay setup note that detects whether Tesserae is the HA add-on and links to the integration or the bridge accordingly. - **Code-element touch regions built asynchronously are no longer lost (#49).** A code element that builds its tappable DOM in JS (fetched data, a chart, any delayed render) had its `data-on-tap` nodes scanned once, ~32ms after load, before they existed, so the region never reached the sidecar and every tap on it resolved to `no_target`. The sandbox collector now re-scans on DOM changes and reports the full set each time (the host rebuilds the mirror regions from it), with a short quiet settle bounded by the existing timeout, so a late-appearing annotated node still registers. - **Swipe zones are more forgiving (#49).** A swipe was hit-tested only on its start point, so a stroke that began a hair outside a small zone and moved into it registered as `no_target` even though its tail crossed the zone. Swipe hit-testing now falls back to the stroke's end point when the start didn't land on a region declaring that direction, so a swipe onto a zone fires reliably. Taps are unchanged (still strict start-point), and sliders (a press-on interaction) aren't affected. - **Home Assistant touch actions no longer fail with HTTP 400 (#49).** The touch dispatcher called HA through `call_service_with_response`, which always appends `?return_response` — and HA rejects that with 400 for any service that doesn't support returning a payload (most actuators, e.g. `light.turn_on`). A tap that resolved and dispatched correctly still came back `ha_failed`. Touch/ button HA actions now use a plain `call_service` (no `return_response`); the response variant stays for the read-style services that need it (`todo.get_items` et al). - **Touch actions show correctly in the canvas editor (#49).** Touch actions are now stored in their canonical `{action,domain,service,data}` form at write time (a model validator, so it applies to MCP writes, editor saves, and self-heals legacy dashboards on load). Previously an agent-written flat HA action (`{service,entity_id,brightness_pct}`) dispatched fine but was stored raw, and the editor's Interaction panel — which only decodes the canonical shape — showed it as blank/`Custom`. - **Touch actions written in the flat Home Assistant shape now dispatch fully (#49).** An action like `{"service":"light.turn_on","entity_id":["light.hall"],"brightness_pct":50}` validated as fine but silently dropped `brightness_pct`: only `entity_id` was folded into the call. Top-level service-data keys are now hoisted into `data` (an explicit `data` block still wins), and a comma-joined `entity_id` string normalises to the list HA expects, so the flat form dispatches the same as the nested canonical one. - **Structured swipe actions can be authored (#49).** `on_swipe` was typed as a direction→string map, so an inline HA object (`{"left":{"action":"ha",...}}`) was rejected at write time and the interaction never stored. A swipe direction now accepts the same string-or-structured forms as `on_tap`. A swipe object with no `up`/`down`/`left`/`right` key (which can't fire) is flagged in `render_report`'s `tap_invalid` with a fix hint instead of being silently dropped. ### Added - **Touch authoring ergonomics for MCP agents (#49).** Four additions from agent-session feedback: a `describe_actions` MCP tool / `GET /api/mcp/actions/describe` returns the authoritative touch-action vocabulary (element fields, string grammar, the HA object form and every input variation, slider `{value}`, provenance, how to verify) so it doesn't have to be reverse-engineered; `render_report` takes `?view=touch` or `?fields=…` to trim the response so verifying a large board doesn't blow the output cap; a bulk element endpoint (`POST /pages//elements/bulk`, all-or- nothing) builds a big primitive board in a few chunked saves instead of one giant `set_canvas`; and the touch monitor gains a dashboard picker that previews any dashboard's touch regions at the device panel size without pushing it first. Bridge published as 0.7.0 with the matching tools. - **Self-hosted CalDAV calendars + todo lists (calendar_core).** Calendar feeds can now carry basic or digest credentials, so a private server (Baikal, Radicale, Nextcloud) that gates its `.ics` export behind auth works, including a LAN-only server the panel can't reach directly (fetches run server-side). A **Discover** panel takes your CalDAV calendar-home URL and enumerates the calendars and todo lists it finds via one PROPFIND, so you add each in a click instead of hand-building `?export` URLs. calendar_core also parses `VTODO` components now (`load_todos`), which the new CalDAV Todo widget renders as a read-only checklist. Credentials are stored in the plugin's `feeds.json` (plaintext in the data dir, same posture as the Home Assistant token). ### Fixed - **Artifact GC no longer eats the live frame's touch regions (#49).** The render prune (and the History page's per-row delete) kept only digests still referenced by event rows, so once the event cap evicted (or a History clear deleted) the push row for a frame still on a panel, its touch-region sidecar and thumbnail were removed from disk. From then on every tap resolved `no_target`, interactions stopped carrying actions, and the touch monitor had no regions to overlay. The latest render for every device (artifact + composition digest) is now always kept. Recovery on an affected install: push the dashboard once and the sidecar regenerates. - **MCP element writes reject unknown fields (422).** Unknown element keys were silently ignored, so an agent writing `tap` instead of `on_tap` (or any misspelled field) got a 200 while the interaction evaporated. The element write paths (append / patch / whole-document) now return 422 naming the unknown keys and pointing at `on_tap` / `on_swipe` / `on_slide`, so the mistake surfaces at write time instead of as a dead panel. ### Added - **Touch linger on by default for the reTerminal E1003 (#49).** `touch_linger_s` now defaults to 30, so after a touch wake the firmware stays up polling the digitizer directly and follow-up taps dispatch in a few hundred ms instead of paying the ~2.7 s deep-sleep boot each time. Delivered through the `/status` response `config` block like every other device setting; set it back to 0 in the device's settings to sleep immediately. Touch input itself stays opt-in. - **Speculative pre-render of likely next frames (#49).** During a touch session the server now prewarms the compositions a follow-up gesture is most likely to ask for (the rotation steps either side of the current one) on a background thread, so the synchronous push a swipe or page tap triggers skips its Playwright capture, the dominant share of post-touch latency. Entries are consume-once with a 60 s TTL and keyed on the page's content token, so an edit mid-session or a scheduled push minutes later can never be served a stale composition. - **Touch ETag stability locked by regression tests (#49).** A touch whose action doesn't change the canvas must leave the frame ETag untouched: renders are content-addressed, the packers are deterministic, and the touch re-push path doesn't force-publish, so an unchanged canvas resolves to `no_change` and the device's next `/frame` poll 304s. On the E1003 every false-positive 200 costs a 1.3 MB download and a ~30 s panel repaint, so this chain is now pinned by tests at both the REST layer (no-op touch → 304 against the prior ETag) and the push layer (unchanged re-push keeps the digest). - **Touch monitor Clear now sticks (#49).** Clear only wiped the on-screen marks, but the monitor re-seeds from touch history on load, so the events reappeared on refresh. It now deletes this device's recorded touch events server-side (scoped to `touch` events for that device, so push and button history is untouched), so the clear survives a reload. - **Home Assistant touch actions written in the natural shape now fire (#49).** A structured HA action only dispatched when written in the exact canonical form; the shapes an agent naturally writes (no `action` key when a `service` is given, `entity_id` at the top level, a dotted `service:"light.turn_on"`, or the HA-native `target`) silently no-op'd. These now normalise to the canonical form and dispatch, and sliders accept `$value` as well as `{value}`. Execution is server-side through the ha_core connection (a POST to `/api/services/...`), not the read-only ha_service data source. - **`render_report` no longer green-lights dead touch actions (#49).** It now returns `tap_invalid`, regions whose declared action would not dispatch (box + gesture + reason), so verification reflects what will actually fire instead of echoing a stored-but-undispatchable payload. A region appearing in `tap_regions` only means it was stored; `tap_invalid == []` is the real "this dashboard is wired" signal. The panels touch-regions endpoint reports the same. ### Added - **Per-device touch monitor (#49).** A touch-capable panel (the Seeed reTerminal E1003) gains a **Touch monitor** page, linked from its device card, that draws the panel at its true aspect ratio and plots recent touches on it: taps as dots, swipes as arrows, sliders with their value, each colour-coded by outcome (fired / no target / blocked). The last render's touch regions overlay so you can see whether a tap landed inside its target, and events stream in live over the Events SSE feed. Seeds from the recent `touch` history on load, so you get the last session's misses without waiting for a fresh tap. - **Touch events on the Events page (#49).** Touch strokes now log as their own `touch` event type with a dedicated filter chip, instead of being folded into the push feed. Every touch is recorded, including the misses (`no_target`, `stale`, `blocked`), which are the diagnostically useful ones, with a scannable summary (gesture, coordinates, resolved action, region) and the full JSON payload on expand. Handy for confirming a tap landed where you expected and fired the right action. - **Touch capability flag on devices (#49).** Panels with a touch digitizer (the Seeed reTerminal E1003) now report `touch: true` through the device APIs, the MCP `list_devices` / `/devices` surface and the canvas editor's device list. Previously nothing in the device registry indicated a panel was touch-capable, so an agent inspecting the devices couldn't tell the E1003's on-tap / swipe / slider actions would actually fire on it. It's a hardware fact carried from the hardware catalog entry, distinct from the per-device `touch_enabled` firmware setting. - **Touch actions in the grid dashboard editor (#49).** The touch authoring that shipped for the canvas editor is now on grid dashboards too: each cell's **Advanced** pane gains a full Interaction editor, on-tap and per-direction swipe actions (refresh / rotate / jump to step / go to page / webhook / Home Assistant), and "Make this a slider" to map a drag to a 0-100 `{value}` for a webhook or HA call. The picker UI is a shared `touch_interaction.js` module so the grid and canvas editors stay consistent, backed by ungated `dashboards.json` + `ha-actions.json` endpoints so it works without the canvas experiment. - **Home Assistant touch actions and sliders (phase 3 of #49).** The Interaction picker gains **Home Assistant…**: choose a service and entity (fetched live from your HA instance via the shared ha_core connection) plus optional service data, and a tap on the element fires the call. It runs synchronously and re-pushes the current page, so the frame the display gets back on that same wake already shows the new state. Any element (or invisible touch region) can also become a **slider**: the stroke's end point maps to 0-100 along the chosen axis (vertical fills upward, and a plain tap sets the value at that point), and `"{value}"` placeholders in the action receive the number, e.g. `light.turn_on` with `{"brightness_pct": "{value}"}` for a one-stroke dimmer, or a webhook URL like `…/level/{value}`. Sliders work on grid cells too. Structured HA actions are honoured only from editor/MCP-authored config or a code element's named actions map, per the touch provenance gate. - **Touch actions in the canvas editor (phase 2 of #49).** Every canvas element gains an **Interaction** section: pick an on-tap action (refresh, rotate, jump to step, go to page, webhook) and per-direction swipe actions, with a hand badge on interactive elements and in the Layers list. A new **Touch region** palette entry drops an invisible hotspot you can position over anything, including a code element's rendered output. Code elements get a named **Actions** card (mirroring Sources): define actions with pickers, reference them from markup as `data-on-tap="@name"`, in static HTML or JS-built DOM alike. A toolbar **Show touch targets** toggle renders the canvas headless and overlays the extracted regions, flagging unresolved `@name` references. The MCP `render_report` now returns `tap_regions` + `tap_dangling` so agents can verify their annotations. Dispatch gains a provenance gate: side-effecting actions (webhooks, and Home Assistant actions when they land) only fire from user-authored config (editor/MCP fields, code-element actions maps), never from raw widget markup; navigation actions work from anywhere. - **Touch input protocol (server side).** Touch-capable devices can now report taps and swipes and have them drive the dashboard (#49). A stroke arrives either as `touch_*` query params on the existing `GET /frame` poll (deep-sleep clients get the action's repaint on the same wake, exactly like button wakes) or via a new `POST /api/v1/device//tap`; the device stays a thin client and the server does all gesture classification (tap vs directional swipe) and hit-testing. Touch regions are declared in markup, not drawn: any element in the composed page can carry `data-on-tap` / `data-on-swipe` attributes (the existing button-action grammar: `refresh`, `page:`, `rotate_next`, `webhook:`, …), including DOM generated inside a canvas code element. Grid cells take a per-cell `on_tap` override plus a widget-manifest `on_tap` default. Regions are extracted at render time from the exact DOM the frame captured (shadow roots included) and stored beside the render, and a stroke is only ever dispatched against the frame the finger actually touched (stale strokes degrade to a plain refresh). Touch events land in the History page alongside button presses. The reTerminal E1003 (GT911 touch panel) gains **Touch input** and **Touch linger** device settings delivered through the standard config channel; firmware support lands separately in the unified device firmware. - **Manual history controls.** The History page auto-evicts at a cap, but now you can also clear it by hand: a "Clear history" control deletes everything or everything older than 7 / 30 / 90 days, and a per-row checkbox with a floating bulk bar (like the Dashboards multi-select) deletes just the ticked entries. Orphaned render artifacts are pruned after any bulk delete. (#116) - **"Update available" badge in the header.** When a newer Tesserae release exists, the topbar shows an accent-tinted update badge (with the version) linking to Settings. The check reads `api.tesserae.ink/version/latest` for the running version, entirely in the background off the request thread (a cached, single-flight refresh with a 6h TTL), so no page render ever blocks on it. It's gated by the online-features opt-out exactly like the heartbeat, so an opted-out install makes no call and shows nothing, and the install id is scoped (a one-way derivation) before it's sent. Compares against the latest stable release; an install already ahead of stable (a source / edge build) simply shows no badge rather than a false nag. - **Choose how Home Assistant numbers are formatted.** HA widgets rounded every numeric state to 2 decimals and trimmed trailing zeros, so a column mixing `21.00` and `20.55` rendered as `21` next to `20.55`. A new widget-level **Number format** option fixes the decimal places using the same pattern vocabulary as the canvas data element (`0`, `0.0`, `0.00`, …); blank keeps the old auto behaviour. Applied across the HA family where it fits: **Sensor** and **Entities** also take a per-entity override from a per-row format box in the names/icons editor, and **History** formats its current / min / max labels. Purpose-built HA widgets (Battery, Climate, Energy, Media, Lights) keep their fixed, unit-aware precision. `ha_sensor` 0.7.0, `ha_entities` 0.6.0, `ha_history` 0.5.0. (#111) - **Daily heartbeat now reports firmware versions per device kind.** The opt-in heartbeat already sent the set of configured device kinds but not what firmware they run, so the maintainer couldn't see the firmware spread in the field. It now includes `fw_by_kind` (`{kind: [versions]}`), sourced from each device's latest status heartbeat, deduped and capped. Aggregate only, no per-device identity; documented on the privacy page. - **Bundled fonts are now usable inside the code element.** A code element can use any bundled font by family name (`font-family: "Fira Code"`, `"Press Start 2P"`, …). The sandbox has no network and a `font-src data:` CSP, so a new `/fonts/face/.css` endpoint serves a self-contained `@font-face` (woff2 embedded as a `data:` URL), and the sandbox inlines only the fonts a given element actually names. Previously only Phosphor was available in code elements; the whole `fonts_core` set (including the new programming + pixel fonts) now works there. Bridge doc-shape updated so the agent knows. Bridge 0.5.13. - **16 new fonts: a big programming + pixel set.** Core fonts (`fonts_core` 0.5.0) gains a broad monospace / coding set (Fira Code, Source Code Pro, Martian Mono, Red Hat Mono, Fragment Mono, Spline Sans Mono, Overpass Mono, on top of the existing JetBrains Mono / IBM Plex Mono / Space Mono) and a pixel / bitmap set (Press Start 2P, VT323, Silkscreen, Pixelify Sans, Handjet, Micro 5, DotGothic16, Jersey 10, Jersey 25). All Latin-subset woff2, OFL/Apache, verified to load in-browser. - **Multi-select dashboards for bulk push / delete.** The Dashboards page now has a checkbox per dashboard and a floating action bar that appears once one is selected, with "Push selected" and "Delete selected". Push fans each dashboard out to its own bound devices; delete removes them all after a confirm. New `POST /send/pages` and `POST /pages/bulk/delete` endpoints back it. - **All six Phosphor icon weights in the canvas code element.** The sandbox previously only had bold; it now has thin, light, regular, bold, fill, and duotone, each vendored as a self-contained CSS with the font embedded as a `data:` URL and inlined only when its class (`ph`, `ph-thin`, `ph-duotone`, …) appears in the code, so an element that uses one weight doesn't pay for the others. Verified all six load and render under the sandbox CSP. ### Changed - **MCP agent binds the device early and builds visibly.** The bridge instructions now open with a "START HERE" step: pick the panel with `list_devices`, `create_canvas_page` sized to it, then `bind_devices` **right away** rather than only at push time, so the artboard and Send / schedule / rotation target the real hardware from the start (previously the agent left a dashboard unbound unless asked). The same step tells the agent to add an empty `code` element and stream it in with `append_code` (the open editor re-renders each chunk) and preview early, instead of composing the whole thing silently and posting one big `set_canvas` at the end, so the build stays responsive. Bridge bumped to 0.5.14. - **The MCP agent defaults to the code element for dashboards.** Guidance now tells the agent to build a dashboard as a `code` element (HTML/CSS/JS fed by widget sources, with the vendored toolkit) for anything beyond a trivial single-widget page, falling back to bare widget/data/shape elements only when the page really is just one widget. Bridge bumped to 0.5.12. - **Hover preview on the Dashboards page.** Hovering a dashboard row shows a scaled-down preview of it. The preview is a cached PNG screenshot, rendered via the same headless path a push uses and reused, keyed by a content token so it only re-renders when the dashboard actually changes. The render happens in the background off the request thread (a single-flight queue): the first hover of a new/changed dashboard falls back to a live `/compose/` iframe while the image renders, and subsequent hovers serve the cached PNG. It lazy-loads after a short hover-intent delay, covers grid and freeform pages alike, and is disabled on touch / narrow screens. - **Bind a canvas to multiple devices.** The canvas editor's device picker is now a multi-select popover: a canvas can target any number of panels, and Send fans the one render out to each, fitted/quantised to its own dims by the server. Matching the artboard to a specific panel's resolution is now a separate, explicit per-device action (⤢), decoupled from binding so a mixed-size fleet doesn't fight over one artboard. On the MCP side, `push_to_device` already fanned out to a device list; a new `bind_devices` tool (`POST /api/mcp/pages//devices`) persists the target set on the page so agent-built dashboards can be scheduled to the same panels. Bridge 0.5.11. ### Changed - **MCP agent designs in full colour, not just the raw panel inks.** The canvas agent's guidance (bridge doc-shape + the `/devices` colour capability) told it to "design within the panel palette so colours don't quantise away", which pushed it to flatten layouts to the handful of Spectra 6 / ACeP inks and threw away what dithering buys. The panel actually dithers the full-colour composition (Floyd-Steinberg) down to its inks, so rich colours, gradients, and photos reproduce as blended approximations. Guidance now says to design in full colour and reserve exact palette hex only for fine detail (thin text / small icons) where dithering reads as speckle, and to honour the `mono` flag for genuinely grayscale panels. Bridge bumped to 0.5.10. ### Fixed - **Grid dashboard editor: reload-on-change fields no longer pop a spurious "Leave site?" dialog.** Picking a target device, a cell's widget, a layout preset, or refitting the panel saves every form then reloads the editor (the server reshapes the page). That reload left the page still flagged dirty, so the browser's unsaved-changes guard fired: choosing "stay" cancelled the reload and left the status stuck on "Saving…" (even though the change had already been saved), while "leave" let it through and looked like leaving had saved. These paths now mark the page clean once the save completes and before the reload, so they persist and refresh silently; genuinely unsaved text edits still warn on navigate and discard on leave as before. (#115) - **Render pipeline no longer starves its own worker threads.** The headless screenshot self-requests `/compose/`, which needs a free web-server thread to be served; a blocked render holds its caller's thread for up to ~105s, and every open SSE stream (the event log, the canvas editor) pins one for the life of the connection. With only 8 waitress threads, a couple of open editors plus a render or two could leave nothing free to serve the inner `/compose`, so every render timed out (`Page.goto: Timeout 15000ms`) and the task queue backed up. The default thread count is raised to 24 (overridable via `TESSERAE_THREADS`), and the dashboards hover preview now renders in the background rather than on the request thread, so a burst of hovers can't pile onto the pool. - **Heartbeat device count no longer reads "10+" on every install.** `build_payload` counted `registry.all()`, which includes the 20+ built-in device kinds and hardware SKUs (catalogue entries, not owned hardware) alongside the operator's actual device instances. So every install reported the `10+` bucket regardless of how many panels it drives. The count, transport, and kind set now key off instances only (`kind_of` set). - **Install identifier no longer rotates on a transient read hiccup.** The install-id loader treated "file exists but couldn't be read right now" (a transient I/O / permission error, e.g. the container-boot `chown` race) the same as "corrupt", and self-healed by minting a fresh UUID that **overwrote** the still-valid file, permanently changing the install's identity (which surfaced as the ID appearing to change across updates). A present-but-unreadable file is now left untouched: the boot uses an ephemeral ID and the next healthy boot recovers the real one. Genuinely corrupt files still self-heal. - **Spectra 6 panels no longer pick up a 7th colour.** The CircuitPython PNG/BMP path mapped a `spectra_6` panel to the 7-entry palette that carries orange (that palette exists for the Pi-side inky path, which reprojects to its own gamut). A CircuitPython client paints exactly what arrives, so the extra ink showed up as `DarkOrange` (`#FF8C00`) in the output on a 6-colour panel. The gamut now maps to the 6-colour Waveshare E6 palette (black/white/red/yellow/blue/green), and the unknown / unset-gamut fallback matches. (#118) - **Gallery accepts BMP uploads.** The Picture, Gallery widget filtered BMP out of both the file picker and the server-side upload check, so a pre-dithered `.bmp` couldn't be added. `.bmp` is now an allowed suffix; thumbnails and serving already handled it. (#117) - **Stable Home Assistant add-on now tracks releases again.** The `release.yml` workflow published each GitHub Release with the built-in `GITHUB_TOKEN`, and GitHub does not let that token trigger downstream workflows, so `sync-addon.yml`'s stable-channel job never fired and the add-on's stable version froze (it lagged at 0.65.0 while releases reached v0.100.0). `release.yml` now explicitly dispatches the sync via `workflow_dispatch` (the documented exception that does fire from `GITHUB_TOKEN`), so every release bumps the stable add-on. Edge was unaffected. ### Added - **PicPak listed under supported hardware.** The README's supported-hardware section gains a Community table crediting the [PicPak 4.2" BWRY](https://docs.tesserae.ink/hardware/picpak/) client, a community firmware by [@varanu5](https://github.com/varanu5). - **Canvas code elements can show remote images.** The `code` element sandbox now allows `img-src` from the web (`https:` / `http:` / `data:` / `blob:`), so a code element can paint a Spotify album cover, an Unsplash photo, or any other remote artwork it pulls from a source, the same external images ordinary widgets already render. Isolation is otherwise unchanged: this is images only (a one-way GET); `fetch` / XHR / WebSocket stay blocked by `default-src 'none'`, and the frame keeps its opaque origin (no same-origin or credentialed access to Tesserae). Bridge doc-shape updated so the agent knows remote `` works; bridge bumped to 0.5.9. - **Service plugins: a non-placeable `service` plugin kind that feeds the canvas code element.** A new manifest `kind: "service"` exposes a whole external API as a data source for a `code` / `data` element without appearing in the canvas picker (it has no placeable render). The MCP agent discovers them via `GET /api/mcp/services` (and the `list_services` bridge tool), then sources one by key exactly like a widget: `POST /widgets//data` (probe) and `GET /widgets//options` work unchanged. By convention a service probed with empty options returns a self-describing map of the scopes it offers, so the agent can explore the API before choosing what to pull. Four reference services ship: **Open-Meteo** (no key: current / hourly / daily / air quality / marine), **REST / JSON** (any public https endpoint, with a loopback/private-host SSRF guard), **Home Assistant** (reuses the ha_core connection: states / entity / history / services / config / raw), and **Spotify** (marketplace, reuses spotify_core: now-playing / queue / top / raw Web API GET). The plugin schema now allows non-placeable kinds to leave `supports.sizes` empty. Bridge bumped to 0.5.8. - **Stream a code element in live (MCP `append_code`).** A new `POST /pages//elements//append` endpoint (and `append_code` bridge tool) appends text to a code element's `html` / `css` / `js` and saves on each call. Since a save is what pushes the SSE update to an open canvas editor, an agent can stream a code element in chunk by chunk and watch it build up, rather than the whole blob appearing at the end. Returns the field's new length; bridge doc-shape documents the pattern (add the empty code element, then append). Bridge bumped to 0.5.7. - **Code elements get a rich sandbox toolkit + editor polish.** The `code` element's sandbox now has a vendored, self-hosted library toolkit, each **conditionally inlined** (loaded only when the code references it, so unused libs cost nothing): Chart.js + chartjs-plugin-datalabels, canvas-gauges, Day.js (+utc/timezone), qrcode, marked, chroma.js, SVG.js, and Phosphor icons (``, font inlined as a `data:` URL). Isolation is unchanged (opaque origin, no network); verified end to end that each renders through the real compose path. The canvas editor's code popout now **auto-formats** on open (js-beautify) so one-line / MCP-authored code is readable, with a Format button and line-wrapping; and the sidebars are now **resizable** (drag handles, persisted) and scroll when content overflows. The MCP bridge doc-shape (0.5.6) documents every sandbox library with usage, and `set_canvas_background` now tells agents the fal.ai key lives on the AI-image (fal-image) widget. ### Changed - **A device can switch frame format (png <-> bmp) without re-registering.** Re-declaring `format` on a later `POST /api/v1/device/register` or `/discover` (MAC-match) now moves an already-registered device to the matching renderer in place, instead of the declared format being silently ignored on any existing device. On a real switch the device's cached frame is invalidated, so `GET /frame` returns `204` until the next push repaints it in the new format, rather than serving the stale old-format frame (previously only clearable by deleting `data/core/renders`). A `format` that's absent, unknown, or already active is a no-op. - **CircuitPython BMP frames are 2-8x smaller (sub-byte packing).** The `circuitpython_bmp` renderer now packs the indexed frame at the smallest standard BMP bit depth that fits its palette (a bespoke writer in `app.bmp_writer`): 1 bpp for mono, 4 bpp for tri-colour / 4-grey / Spectra 6 / 7-colour ACeP, versus Pillow's fixed 8-bit. Output stays uncompressed BI_RGB and decodes on the same `adafruit_imageload` path (its unpacker is generic over bit depth); the full-colour rgb24/rgb16 passthrough still emits a 24-bit BMP. The palette compacts to the colours actually present, so a frame that touches few colours packs even smaller. ### Added - **Chart.js in code elements; MCP data-source guidance.** Chart.js (vendored, self-hosted) is preloaded inside the `code` element's sandbox as `window.Chart` (animations off), so author JS can draw charts from widget data with a `` and `new Chart(...)`. It's inlined into the network-blocked opaque-origin sandbox, so the isolation is unchanged; it renders once at compose time (verified end to end through the real render path). The MCP bridge doc-shape (0.5.5) gains a "DATA SOURCES" section spelling out the model (a source is always a widget key + options; `data` and `bind` take one, `code` takes many named ones as `ctx.data.`; probe first; shared fetch is free) and documents the Chart.js availability. - **Code elements take data from any number of widgets; canvas editor gains collapsible sidebars and a CodeMirror popout.** A `code` element now declares a list of named `sources` (`{key, options, name}`), each injected as `ctx.data.`, so one element's JS can combine weather + transit + calendar, etc. (a legacy single `source` still works, keyed by widget id). Drivable from MCP with no new endpoint (the `sources` array rides `add_element`/`set_canvas`; bridge doc-shape updated, 0.5.4). In the canvas editor, the left and right sidebars now collapse to reclaim canvas space (persisted per browser), and code is authored in a rich CodeMirror popout (vendored, self-hosted; HTML/CSS/JS panes with syntax highlighting, line numbers, bracket matching) opened from the element inspector, which also lists each source's `ctx.data..` fields. - **Canvas `code` element: HTML/CSS/JS fed by widget data.** A new canvas element kind runs author HTML + CSS + JavaScript, fed by a widget's live data primitive: the source widget's fetched data is injected as the JS global `ctx.data` (plus `ctx.options`/`ctx.w`/`ctx.h`) and the JS builds the DOM from it. It renders in a sandboxed iframe with scripts enabled but an opaque origin and CSP `default-src 'none'` — no network, no same-origin access — so the data is delivered (never fetched from inside the frame) and the loopback render context stays sealed. Runs once at render (e-ink is static). Authored in the canvas editor (source picker + HTML/CSS/JS panes with a `ctx.data` field hint) and drivable from MCP with no new endpoint: `kind` is a free field, so `add_element` / `set_canvas` carry it, and `probe_widget_data` surfaces the field shape first. Bridge doc-shape documents the kind (bumped to 0.5.3). - **AI-generated canvas backgrounds (fal.ai).** A canvas dashboard can generate a full-bleed background image from a text prompt; the data widgets composite crisply on top, so the data never passes through the image model (the background is decorative, the numbers stay exact). New `app.fal_backgrounds` service calls fal.ai, stores the result as a local render asset, and sets the canvas' existing `bg_image`. Exposed as `POST /api/mcp/pages//background` (and `set_canvas_background` in the `tesserae-mcp` bridge) and as a "Generate background" control in the canvas editor's appearance panel. Model + style presets mirror the fal-image widget; the fal key is reused from an installed fal-image widget, else `app.fal.api_key`, else `FAL_KEY`. Generation is on-demand (set-and-forget). Needs a fal.ai key; no key = the feature 400s cleanly. - **MCP faithful-render screenshots for the catalog (Screenshot Contract).** The `GET /api/mcp/widgets//render.png` faithful render now accepts explicit `w`&`h` (clamped) as an alternative to `size` (`lg` stays 1200x800), reuses the same Playwright render path, and tightens its error semantics to the contract: unknown widget 404, unknown fragment or invalid `options`/`opts` JSON 400, render unavailable 503 (was 502), never a 200 with a blank / HTML body. A small `python -m app.screenshots --out --lg --extra ` CLI drives that same endpoint to write a widget's whole catalog set (`lg.png` plus `extra-1..N.png` from a `{name, options}` preset list). All additive; no `w`/`h` = today's behaviour. - **CircuitPython clients can request an uncompressed BMP frame.** New `circuitpython_bmp` renderer emits the composition quantised to the panel's palette as an uncompressed indexed BMP, alongside the existing `circuitpython_png`. A client declares `"format": "bmp"` on `/discover` (or `/register`) to bind it. The BMP needs no `zlib.decompress` on the client: `adafruit_imageload` reads it row by row, so peak RAM is the framebuffer plus a small row buffer, where an indexed PNG's one-shot inflate can't fit a contiguous decode buffer on Pico W class boards. PNG stays the default for boards with headroom (it's a few times smaller on the wire). The `circuitpython_generic` kind now lists both renderers and pins one per instance via a `renderer_id` recorded at registration; the shared pixel pipeline lives in `app.quantizer` so both formats produce identical pixels. Spec: [client protocol](https://dmellok.github.io/tesserae/dev/client-protocol/). - **MCP: less friction authoring widgets via Studio.** Three additions to the `/api/mcp` surface: `GET /widgets//render.png` takes a `fragment` param so an agent can faithfully render a single declared fragment (not just the whole card), 400ing an unknown fragment id; `render.png` and `POST /widgets//data` take `fresh=true` to bypass caches (surfaced to widgets as `ctx["fresh"]`, and it skips the render path's last-good fallback) so a just-edited `server.py` is instantly verifiable; and `DELETE /api/mcp/pages/` removes a canvas dashboard (throwaway QA pages), exposed in the `tesserae-mcp` bridge as `delete_canvas_page`. The reload acks now carry `reloaded` (and a re-imported module count) so an agent can tell a real in-process reload from a no-op. All additive; no `fragment`/`fresh` = today's behaviour. ### Changed - **The `tesserae-mcp` bridge now lives in this repo** (`packages/tesserae-mcp`) as a self-contained sub-project with its own `pyproject`, so its tool list / doc-shape stay in lockstep with the `/api/mcp` surface it wraps and are tested in the same CI run. It still ships a thin, stdlib-plus-`mcp` wheel published to PyPI as `tesserae-mcp`, so an agent-machine install stays light. Install becomes `pip install tesserae-mcp`. ### Docs - The `tesserae-studio-mcp` bridge install now reads `pip install tesserae-studio-mcp` (it's on PyPI), matching the `tesserae-mcp` section, with a from-source fallback. - MCP client-config snippets for Codex CLI, Cursor, Windsurf, Cline, VS Code, and the OpenAI Agents SDK, alongside the existing Claude config. - Two new docs-site pages: **Build widgets with Studio** (the Tesserae Studio authoring loop, linter rules, and the new-widget restart gate) and **MCP servers: install & use** (installing and configuring both `tesserae-mcp` for dashboards and `tesserae-studio-mcp` for widgets). ### Added - **Canvas: live data bindings for shapes.** Data elements re-evaluate every render, which is why numbers stay live; shapes (rect / ellipse / icon / line / text) were static geometry. Any element can now carry `bind` entries that read a widget field each render and map it through a transform to patch the element's props, so a shape reflects data in lockstep with the data primitives on the same canvas, with no polling or agent tick, and it survives push / rotation. Six transforms: `position` (a marker that moves along a segment), `length` (a gauge that grows), `pick` (hop between discrete states by index), `color` (threshold colouring), `gradient` (a value interpolated smoothly along colour stops, quantised to the panel palette on e-ink), and `icon` (a condition code to a Phosphor glyph). Bindings resolve through the same shared fetch as data elements, so a shape bound to a widget already on the canvas costs no extra request. Authored via the document (MCP `set_canvas` / `tesserae-mcp` 0.4.0); a binding that can't resolve is skipped, leaving the element's authored props intact. - **MCP: push a widget you are authoring to a running instance.** New endpoints let an authoring client (Tesserae Studio) install a widget over the network with no shared filesystem, so authoring works against a Tesserae on another machine or in the Home Assistant App / Docker. `POST /api/mcp/widgets/install` accepts a widget tarball, validates it (kind widget, schema-valid, no bundled-id collision, tar-slip and size guarded), writes it to a persistent `authored/` dir, and reloads: an in-process registry rebuild when safe (fast, no dropped connections) or a process restart when the widget adds an admin page. `DELETE /api/mcp/widgets/`, `POST /api/mcp/reload`, `GET /api/mcp/widgets?origin=authored`, and a faithful `GET /api/mcp/widgets//render.png` round it out. Token-authed like the rest of the MCP surface (loopback or bearer), behind the `mcp` experiment. - **MCP: faithful, editable, hardware-aware dashboard building.** A batch of agent-facing improvements from real building sessions: - **Partial edits.** `update_element`, `delete_element`, and `patch_canvas` change one element or one document field without re-sending the whole canvas (a big dashboard was expensive and error-prone to edit before). - **Structured render report.** `render_report` returns a machine-readable companion to the preview PNG: per element, the resolved box, the text that rendered, overflow/clip flags, whether the data was live / sample / error, and the computed colours, plus the board's resolved background and theme. The agent can verify a render (catch clipping, confirm live data) without parsing pixels. - **Probe tells live from placeholder.** `probe_widget_data` now reports a `data_source` (live | sample | error) and a `reason`, and lists the bindable field paths with sample values, so an agent stops mistaking a demo sample for a real result and stops reverse-engineering field shapes. - **Text measurement + layout helper.** `measure_text` reports how wide text renders in a given font (so a box can fit its content and not clip); `arrange` computes aligned grid / row / column boxes so an agent lays out by intent instead of hand-computing every pixel. - **Leaner option schemas.** `get_widget_options` omits huge choice lists (HA entity pickers) by default, showing a count and a `choices` endpoint; each option carries a format hint for its type. `get_widget_choices` pages the rows. - **Device colour capability.** `list_devices` now reports each panel's `color_mode`, renderable palette (hex), and a `mono` flag, so an agent designs within what the hardware can show. - **Concurrent-edit guard.** Writes accept a `base_rev` (from `get_canvas`); if the page changed since, the write returns a conflict instead of clobbering a UI edit, and acks now carry `updated_at` / `updated_by`. Requires the `tesserae-mcp` bridge 0.3.0. - **Canvas editor: SVG primitive.** Paste raw SVG as an element; it scales to fill the box and renders in a sandboxed iframe. - **Data primitives: value formatting.** A `format` option renders datetimes and numbers (e.g. `HH:mm`, `MMM d`, `relative`, `0.0`), so calendar/time fields are presentable. Field paths also gain array indexing and a pluck syntax (`series.*.total` / `series[].total`) to feed charts from arrays of objects. - **MCP: agent affordances.** From feedback building dashboards with an agent: `set_canvas` returns a compact `{ok,id,rev,elements}` ack instead of echoing the whole document (opt back in with `?return=doc`); a `probe_widget_data` tool returns a widget's live data so the agent can see real field names before binding; an `add_element` tool appends one element per call (each a save, so an open editor updates live as the agent builds); and the catalog omits per-widget samples to stay small. ### Fixed - **Pushed widgets served their assets only after a restart.** A widget installed over the MCP push path went live for data on the in-process reload but its `client.js` / static assets kept 404ing until a full restart, so a freshly pushed widget rendered blank. The per-plugin asset routes closed over the registry captured at startup; they now read `app.config["PLUGIN_REGISTRY"]` fresh per request (matching the composer / condition routes), so a newly-pushed non-blueprint widget serves its assets immediately on reload. Widgets that declare an admin `blueprint()` still take one restart (Flask registers blueprints once at startup). - **MCP `render.png` returned a login screenshot on password-protected instances.** The single-widget render endpoint screenshots `/_test/render` over loopback with no session; that path was not in the auth gate's loopback-exempt list, so a password-protected instance redirected the render to `/login` and captured the login page instead of the widget. `/_test/render` is now loopback-exempt (loopback only, same trust boundary as `/compose`), so the render is faithful with or without a password. - **Grid layout editor: smooth edge dragging.** Dragging a cell edge no longer rebuilds the whole schematic board on every pointer move; it repositions just the affected cells (coalesced per animation frame), so the drag tracks at full frame rate. The live preview follows the drag in place instead of sitting still until release, and settles authoritatively when the drag ends. - **Weather widgets: a written-out location now resolves.** A location set as plain text (e.g. by an MCP agent, or `"South Morang"` / `"Paris, FR"` / `"-37.65,145.09"`) is geocoded server-side instead of being ignored, so the preview and the pushed frame both show weather for that place and echo its name. Preview and push share one resolver, so they can no longer disagree. A location that genuinely can't be resolved now surfaces an error for the place you asked for rather than silently falling back to the sample city. - **Canvas: one fetch per widget.** Several data primitives bound to the same widget (temperature, humidity, wind off one weather source) now share a single data fetch per render instead of each fetching independently. - **Canvas theme background.** The editor and the pushed frame disagreed on an unset background (one showed black), and neither loaded user / community theme CSS the grid pages resolve; both now agree and fall back to the theme paper colour. - **Canvas editor: external swaps remount cleanly.** When the open document is replaced by an external save, the editor now forces a full remount so stale element nodes can't linger. - **Canvas editor: layer names.** Decoration, data, and custom-HTML/SVG elements showed as "Empty" in the Layers panel; they now carry meaningful names. - **Canvas editor: icon key format.** The icon primitive now accepts both `star` and `ph-star` (matching the format widget icon fields use). - **Canvas editor: data primitives.** A new element that binds any widget's data field (by dotted path) to a scalable text, number, or graph (line / bar / sparkline via Chart.js). Pick a source widget, configure it, choose a field from the introspected data, and style it. Renders identically in the editor and the pushed frame. - **Canvas editor: custom HTML element.** Author a "mini widget" from HTML + CSS, rendered in a sandboxed iframe (no scripts, no network). Set it in the editor or over MCP. - **Canvas editor: partially off-canvas elements.** Elements may sit past the panel edge (they clip cleanly at render), keeping a sliver on-canvas so they stay grabbable. - **Canvas editor: Shift to keep aspect ratio.** Hold Shift while dragging a corner handle to resize proportionally. - **Live canvas updates in the editor.** The freeform canvas editor now reflects external saves without a reload, so you can watch an MCP agent build a dashboard in real time. It follows changes silently while you're not editing; if you have unsaved local edits it shows a non-destructive "changed externally" reload prompt instead of clobbering them. Backed by a per-canvas Server-Sent Events stream and a content rev that distinguishes your own saves from external ones. - **MCP server (build dashboards with AI).** An optional Model Context Protocol server lets an agent (Claude Desktop / Claude Code, etc.) build freeform canvas dashboards: it lists your widgets and devices, lays out a canvas, renders a preview to check its work, and pushes to a panel. Enable it under **Settings → System → MCP** (off by default), then run the [`tesserae-mcp`](https://github.com/dmellok/tesserae-mcp) bridge on your agent's machine. The `/api/mcp` surface is reachable from loopback without a token, or with a generated token for a remote agent; writes only touch canvas dashboards, pushing is always explicit, and agent-created pages are flagged in the Dashboards list. The stdio bridge is a separate package (installed where the agent runs), so Tesserae core stays free of the `mcp` dependency. See the docs. - **Dark mode in the canvas editor.** The freeform canvas editor, a standalone page that doesn't extend the admin shell, now follows the shell's persisted light/dark choice (applied before paint, so no flash) and carries its own toolbar toggle. Its overlays (the experimental disclaimer and small-screen block-out) go dark with it. - **Canvas editor guardrails.** The freeform canvas editor gains an explicit Save button (also Cmd/Ctrl+S) and a back-to-dashboards link in its toolbar, a first-open notice that the editor is experimental with a link to file bug reports, and a small-screen block-out that explains phone editing is not supported and offers a way back. ### Changed - **Removed the usage survey** from Settings → About. The aggregate heartbeat now covers the "how is Tesserae used" question the survey stood in for. ### Fixed - **Dark-mode gaps across the admin UI.** Swept hardcoded light colours onto the shared token palette so they adapt in dark mode: the Devices card palette (previously light in both themes), Settings chips / segmented controls / hint and warning bands, and page ledes. The always-dark save bar keeps light text in both themes. - **Create-dashboard button sat low.** The button on the Dashboards create row now centres with the name field and layout picker instead of hugging the bottom edge. - **First-run consent step could be skipped.** The online-features opt-in ran after the dashboard step, whose Edit and freeform actions hand off to the editor and let someone leave the wizard before the consent screen was ever shown. It now runs before the dashboard step, which becomes the final Finish. - **Online features toggle showed a raw checkbox** beside the styled switch. The form now carries `field--switch`, which scopes the checkbox-hide CSS, so only the switch renders. ### Added - **Online features (opt-in, asked at first-run).** Tesserae can report an anonymous, aggregate install count to `api.tesserae.ink` when you add a widget from the marketplace, and show the count on the Browse cards. Each request may include your install's random ID, the widget id, and the running version; a coarse country is derived from the request IP and the IP is then discarded. No account, no personal data, no IP addresses or User-Agent strings are stored. **It is off by default**: the first-run wizard asks once, and a single switch at **Settings → System → Online features** governs it and the app + device-firmware update checks. Leaving it off means the app never contacts `api.tesserae.ink`. This replaces the separate firmware-update opt-in. Never enabled in CI / Codespaces / dev containers. Install pings surface on the `/events` timeline under the telemetry filter. - **Daily heartbeat.** With Online features on, Tesserae sends a once-a-day best-effort heartbeat to `api.tesserae.ink` carrying only low-cardinality, aggregate facts about the install (version, OS family, CPU arch, Python minor, deployment kind, transport, a bucketed device count, the set of registered device kinds, and a Home Assistant boolean). The server stores only the day, not a timestamp, and dedupes to one per install per day, so the cadence can't become an activity trace. No personal data, no exact counts, no IP stored. Also gated by **Settings → System → Online features**; the payload is documented on the privacy page. ### Fixed - **Reordered multi-select entries survive a save.** The editor's multi-select picker (Home Assistant entities / sensors) rendered its options in the plugin's default ``choices()`` order and only toggled which were checked, so a drag-reordered selection was never redrawn in the saved order. Saving any cell rewrites every cell's form, so an unrelated change like switching the dashboard theme re-submitted the entities in the default order and wiped the arrangement. The picker now renders checked options first, in the saved order, then the rest, so the order round-trips. (#94) - **Unbound dashboard no longer leaks onto a bound device's panel.** Sending a dashboard bound to no device fanned out to every renderer, including the per-device clone renderers of devices bound to other dashboards. That overwrote the bound device's latest-render entry, so the device painted the unbound dashboard on its next ``/api/v1/device//frame`` poll. Hard to catch because it only showed if the client happened to poll in the small window after the unbound push. Unbound / virtual-panel pushes now skip per-device clone renderers; only base renderers fan out (legacy single-head / retained MQTT topic still works). Bound devices only ever receive frames from pushes that target them (#83). ## [0.72.0], 2026-07-08 ### Added - **PicPak 4.2" BWRY as a community-firmware hardware entry.** New hardware SKU manifest at `hardware/community/picpak_4_2.json`, a featured hardware page at `docs/hardware/picpak.md`, and a Community vendor bucket in the compatibility matrix. Links back to the community firmware at `varanu5/picpak-tesserae-client`; credit sits on the firmware repo. The `picpak_client` device kind already ships with Tesserae; this surfaces it in Settings → Add Device and in the browsable compatibility docs. - **Tri-colour and greyscale gamuts for the CircuitPython PNG path.** New panel gamut values `bwr_3` (black/white/red tri-colour e-ink) and `gray_4` (4-level greyscale ramp) join `bwry_4` in the 2-bit family. A client can declare either over `/discover`, and the `circuitpython_png` renderer quantises to the matching indexed palette so `adafruit_imageload` mounts it directly with no on-device quantise. ### Fixed - **Calibration tone & dither block no longer disappears after save on a device whose id matches a base renderer prefix.** `_drop_clones` removed every renderer whose `device` matched the instance id; for an instance literally named `pi_bin` (or `pi_png`, `esp32`, …) that also matched the base renderer, so the combined-form save deleted the base and left the device with no renderer clones until the next restart. The device's Calibration tab then rendered no picture-quality fields. `_drop_clones` now only removes clone records (`__`), never a base renderer (#52). - **Panel gamut / calibration changes now repaint instead of serving a stale 304.** The content-checksum skip keyed only on the composition digest, so switching a device's panel (e.g. Spectra 6 to ACeP), editing a calibration profile, or changing saturation/contrast/dither left the device on the previous palette's frame until the dashboard pixels themselves changed. The skip now compares a full render signature (composition + panel geometry/gamut + resolved renderer settings), so any input that alters the packed bytes triggers a fresh render (#81). - **Status bar update chip no longer fires against an older release.** The ``tesserae_status`` widget trusted ``api.tesserae.ink``'s ``is_current`` flag, so callers running an edge / local build ahead of the latest published release surfaced a ghost "update pending" chip pointing back at the older version. Client now compares ``latest`` to ``current`` on the leading numeric triplet and only paints the chip when the latest release is strictly newer. - **Status bar rate-limits its update check.** The version-check fetch fired on every render (each dashboard push), hammering ``api.tesserae.ink`` for the same answer. The widget now caches the most recent response in ``localStorage`` for 1 hour, keyed by channel + current version, so at most one fetch per hour per browser session. The cache invalidates automatically on app upgrade because the current-version key changes. ### Changed - **Update chips lead with the new version.** The ``tesserae_status`` server-version chip previously showed the RUNNING version with a small "-> .N" hint at the newer patch. It now leads with the new version ("v0.71.5 available") and pairs both server and firmware chips with a ``ph-download-simple`` icon so the two read as one consistent "there is something to install" signal. - **Status bar honours the page gap on all four sides.** The ``tesserae_status`` widget was declared ``full_bleed=true`` so it rendered edge-to-edge on the wall-touching sides; users expected the gap slider's matting to be visible around the bar on all sides, not just the edge shared with the widgets below. Removed ``full_bleed`` from the manifest so the composer applies normal gap padding, and grew the auto-inserted bar cell's height by ``outer_pad + inner_pad`` at toggle-on time so the visible content area stays at the design's 48 px regardless of the current page gap. Existing installs with a status bar cell inserted before this fix keep the older 48 px cell; toggling the switch off and back on re-sizes to the new gap-aware height. - **Status-bar toggle in the page editor is flat, not a disclosure.** The section previously wrapped its "Enabled / Disabled" switch in a ``
`` shell so the body panel could expand and collapse. The body is a one-liner of help text plus (when enabled) a jump link, so the expand animation revealed essentially nothing and read as broken. Header row now sits above a fixed body panel; no expand/collapse state to persist. The switch itself is now a proper track+thumb toggle (was rendering as a bare checkbox before). - **Auto-insert status bar cell defaults ``check_for_updates`` to on.** Enabling the status bar is an implicit opt-in to the update-indicator chip; the fetch is rate-limited to once per hour and only paints when the latest release is strictly newer than the running build. - **"Enable firmware update lookups" toggle rendered as a proper switch.** Previously the input's label styled as a switch but the track+thumb spans were missing, so the control rendered as a bare checkbox with text. Now matches the pattern used everywhere else. ### Fixed (continued) - **Install-identifier and firmware-check sections in Settings → System no longer float with a gap above.** The ``settings-stack`` wrapping div closed BEFORE those two sections, so they inherited none of the stack's spacing rules. Closed the wrapper after the firmware-check block so they sit inside the stack. ### Removed - **"Tesserae account" reference in the install-identifier description.** There is no such thing as a Tesserae account; the copy read "Not tied to your identity, hardware, or Tesserae account". Now reads "Not tied to your identity or hardware." ## [0.71.2], 2026-07-07 ### Fixed - **Full-bleed cells now honour the gap between neighbouring cells.** v0.71.1's ``render.full_bleed`` handling skipped padding entirely, which made the status bar sit flush against the widgets below it (no visible matting even at large gap settings). Full-bleed now only drops the outer padding (edges touching the panel wall); inner padding between the cell and its neighbours still applies, so the gap slider stays visible around the bar. - **Palette / tone editor no longer disappears from the Calibration tab after a save (issue #52 follow-up).** The section is gated on ``palette_profile_slug`` resolving to a real profile; if the slug got into a state where it pointed at nothing (fresh install with no applied profile, or a stale slug from a deleted user profile), the whole palette + tone block vanished from the DOM. ``_palette_profile_slug_for`` now self-heals: on any read where the device has a supported gamut (Spectra 6 or Inky 7-colour) and the stored slug is empty or unresolvable, it backfills the family's default bundled slug and persists it. Devices whose gamut has no matching family (mono, bwry_4, rgb24, rgb16) are still passed through untouched. - **Manual re-add after device delete now wipes leftover state (issue #48 follow-up).** The discovery path already wiped orphan state on a MAC-differs signal, but the manual "Add device" form has no MAC to compare, so re-adding a device under the same id kept dashboards / history / per-device settings from the previous instance. ``devices_add`` now looks up the delete-marker for the target id and, when one exists, calls ``wipe_orphan_state`` before creating the new instance so the new device starts pristine. ### Changed - **History status label renamed "delivered" → "pushed" (discussion #62).** The chip on the History timeline reflected the server- side ``sent`` status, but "delivered" implied end-to-end receipt. The push pipeline only guarantees a successful publish (broker publish, or a REST device with a new digest ready to poll); it doesn't know whether the panel has applied the frame yet. Renamed to "pushed" so the chip matches the guarantee. Separate receipt tracking is a follow-up. - **OpenAPI spec version bumped to match the release (discussion #28).** ``schema/openapi.yaml`` had ``info.version`` stuck at 0.64.16. Rolled to the current version so generated SDKs carry the right release marker. ### Added - **Per-cell padding override (feedback from r/eink launch DMs).** Each cell now has an optional ``padding_override`` field surfaced in the page editor as a **Layout tweaks** disclosure (collapsed by default so the cell edit card stays lean). Ticking "Override page-gap padding for this cell" turns on a 0-80 px slider whose value replaces the page-level gap-derived padding on that cell, and also beats the widget's ``render.full_bleed`` manifest flag, so users can dial in per-widget breathing room without touching the page gap the other cells share. - **Content-checksum push skip (feedback from r/eink launch DMs).** When the newly-rendered composition PNG matches a bound device's last-served digest, the push pipeline no longer publishes to that device. The panel isn't asked to re-paint, which is a real battery win on the bigger e-ink panels and the primary optimisation callers were doing externally (checksumming their own data and suppressing the webhook). Skipped renderers surface as ``unchanged=True`` on the per-renderer result and log a ``no_change`` event. When every bound renderer for a push is unchanged, the whole push logs as ``no_change`` too, and the History timeline shows a distinct "no change" chip. HTTP-polled devices' next fetch still returns 304 via the existing ETag path; MQTT-only devices skip the retained re-publish entirely. ## [0.71.1], 2026-07-07 ### Fixed - **Status bar rendered on the right edge of portrait panels instead of at the top.** ``fit_cells_to_panel`` rotates the layout 90° clockwise when the design orientation doesn't match the target (landscape design ↔ portrait panel), which maps the top edge onto the right edge. The status bar cell got rotated along with everything else and painted as a vertical strip on the right. The fitter now accepts a ``top_strip_index`` hint: the flagged cell is orientation-fixed and stays at ``(0, 0, target_w, strip_h_scaled)`` in the target regardless of rotation; the remaining cells are fitted into the below-strip band with the normal rotate + scale rules. Both the composer and the "refit to current panel" path pass the hint automatically when the page has an auto-managed status bar. - **Status bar swallowed by the gap slider.** When the user set a larger matting gap, the composer's outer padding ate into the 48 px bar cell, leaving very little for the widget content. The ``render.full_bleed`` manifest flag (previously captured but not applied) now actually skips the padding subtraction, and the ``tesserae_status`` plugin declares it. The bar renders edge-to- edge regardless of the gap slider's value; other widgets are unchanged. - **Disabling the status bar left a strip of matting at the top.** The reverse-rescale on toggle-off only undid the shift + scale applied when enabling, which meant any layout edits made while the bar was on could leave a gap. Toggle-off now refits the remaining cells to the full panel via ``fit_cells_to_panel``, so the vacated band is absorbed proportionally by the widgets that are left. ## [0.71.0], 2026-07-07 ### Added - **Page-level status bar toggle.** The dashboard editor now has a "Status bar" section below the corner-radius slider. Flip the switch and Tesserae auto-inserts a full-width 48 px ``tesserae_status`` cell at the top of the layout; existing cells shift down and rescale proportionally to fit the remaining space. Flipping it back off removes the bar and reverses the rescale so the layout returns to its pre-toggle shape. Switching layout presets while the bar is on preserves it at row 0 rather than remapping it into the preset's first slot. Always horizontal at the top regardless of panel orientation. ### Fixed - **Status bar widget: icons now render on the compose canvas.** Phosphor's ``.ph-bold`` class rules live in the compose document, which the shadow root doesn't inherit, so the chip icons were rendering as blank squares. The widget now links ``/static/style/spectra-widgets.css`` into its shadow root, same pattern the weather widgets use. - **Status bar widget: text scales with the cell.** Font sizes are now driven by container-query height units (``cqh``) with clamps at each end, so a 48 px bar and a 400 px cell both render at the right weight instead of shrinking to 12 px everywhere. ### Removed - **Status bar widget: weather chip and the two custom text slots dropped.** Weather was misleading in a widget that has no live weather source of its own; the custom slots duplicated existing widgets. If you were relying on either, drop a ``weather_now`` widget or a small text widget onto the dashboard directly. ## [0.70.1], 2026-07-07 ### Fixed - **Device firmware-check lookups are now opt-in and off by default.** v0.70.0 shipped the Devices-card firmware chip and the ``tesserae_status`` firmware-updates chip wired directly to ``api.tesserae.ink/firmware//latest`` on first render. Functionally a default-on outbound call, which contradicts the "app itself sends no phone-home telemetry" claim. New setting ``settings.app.check_firmware_updates`` (default ``false``) gates both call sites; enable it from Settings -> System -> "Check for device firmware updates". When off, the Devices card still shows the current firmware from each device's heartbeat but the "update available" pill never fires, and the widget's firmware-updates chip stays hidden. Only the device kind name is ever sent; no install identifier, no device-specific fields. ## [0.70.0], 2026-07-07 ### Added - **`install_id` foundation for shared-world widgets.** Tesserae now generates a random UUID on first startup and persists it at ``data/core/install_id.json``. Widgets that declare ``needs_install_id`` in their manifest receive the raw value on ``ctx``, which is what upcoming shared-world features (dashboard pet, traveler) will key against so state survives restarts. Widgets that declare ``needs_scoped_id`` receive ``SHA-256(install_id + plugin_id)`` instead, so their outbound calls can't be correlated with other widgets on the same install. The identifier is regenerable from **Settings → System → Install identifier**; regeneration resets any per-install state that external widget services have accumulated. - **Device firmware chip on the Devices card.** Each registered device kind is looked up against ``api.tesserae.ink/firmware//latest`` the first time the Devices page renders (and on demand every 60 min after). When the device's running firmware is behind, an amber "update available" pill appears next to the version. Lookups are cached, silent-fail on network error, and the only outbound data is the kind name. - **`tesserae_status` bundled widget (fixes #66).** Dashboard status strip. Left identity (dashboard name + optional leading icon) paired with a right-hand row of ambient chips: time, weather, panel battery, Wi-Fi, broker, plus conditional app-version and firmware update indicators, plus two custom text slots. Two placements (bar, 48 px tall; block, resizable, wrapping chips) and three chip modes (icon+text, icon-only, text-only). Auto-contrasts against a freeform ``panelBg`` hex: text, icons, and rules flip between ink (#1B1A16) and paper (#FCFBF7) based on background luminance, and the update accent flips between two reds. Update chips render only when an update is pending; the signal is a positional badge dot on the icon or an accent-red text sub, never colour alone, so a 1-bit render still reads the update. The app-version update check is off by default; when enabled it fetches ``api.tesserae.ink/version/latest`` with a widget-scoped install id (see above). - **Auto-release workflow.** Pushing a ``v*`` tag now creates the matching GitHub Release from ``CHANGELOG.md`` automatically. Every release is marked ``--latest`` so ``api.tesserae.ink/version/latest`` picks it up on the next poll. Existing Releases are never overwritten. ### Fixed - **Broker-less installs no longer log push failures for renderers that never had a broker to publish to (issue #67).** ``_fan_out`` walks every registered renderer, so on a REST-only fleet with no MQTT configured, base kind renderers (esp32_bin, esp32_bw_bin, pi_png, etc.) still called ``transport.publish`` and got back ``RuntimeError("transport not connected")``. The exception marked the whole render as failed in the history view even though the actually-bound REST device published fine. ``MqttTransport.publish`` now silently no-ops when the host was empty at construction; when a host was configured but the connection dropped, it still raises so a genuine outage still surfaces. ### Changed - **Privacy docs updated.** ``docs/privacy.md`` documents the new install identifier, both places widgets can fetch from ``api.tesserae.ink``, and the exact query parameters those calls carry. The app itself still sends no phone-home telemetry. ## [0.69.18], 2026-07-06 ### Fixed - **PicPak 4.2" BWRY panels no longer paint upside-down and mirrored (issue #65, hardware validation and diagnosis by @varanu5).** The ``panel.vflip`` opt-in that shipped in v0.69.16 was declared on the ``picpak_client`` manifest and honoured by the ``esp32_bin`` renderer, but silently dropped by three separate panel-key allow-lists on the way from the manifest to the renderer (``device_loader``'s panel property, ``panel.device_panel()``, and ``push._panel_dims_for_send``). Each layer copied ``w``, ``h``, ``flip``, ``gamut`` etc. but not ``vflip``, so ``panel.vflip`` was always ``False`` when the renderer checked it and the row-reverse never fired. Three one-line additions carry the flag through end to end; a regression test now pins the flow so it can't drop again. Existing PicPak installs need to re-add the device once so the fixed manifest allow-list writes ``vflip: true`` into the instance file. ## [0.69.17], 2026-07-06 ### Fixed - **Sticky save bar no longer paints as a full-height black column on the Settings page (issue #52).** The server-tab save-bar variant is ``position: fixed`` and specifies ``bottom`` only, but was inheriting the sticky device-card variant's ``top`` too. ``top`` + ``bottom`` on a fixed element stretches it to fill the viewport height between the two insets; content sat at the bottom edge and the empty container above it read as one big black rectangle. Added ``top: auto`` on the server variant so only ``bottom`` positions it. - **Saving from the General or Calibration tab no longer jumps back to Status (issue #52).** The v0.69.14 tab-scoping fix only threaded ``?tab=`` through the calibration-side redirects; the combined-form save (General tab, panel dims, quiet hours, etc.) dropped it, so the redirect landed with just ``?opened=`` and the template's tab picker fell through to the default. Now carries an ``_active_tab`` hidden field on the combined form and echoes it back through the redirect. - **Push history rows no longer contaminate old entries with currently-linked devices (issue #52).** The device-chip renderer used to fall back to the page's live ``device_ids`` list when the event's snapshot was missing, so a device added yesterday would appear on entries pushed a week ago (before the device even existed). Snapshot-only now; pre-v0.5x rows without a snapshot show no chip rather than a wrong one. - **Per-cell widget options survive a widget-type change (issue #52 follow-up).** Switching a cell from Weather Now to Weather Forecast used to wipe every override, including the location the user had set on the previous variant. Now preserves any option whose name is also declared on the new plugin's ``cell_options`` schema, so shared knobs like ``location`` on weather_* variants (and ``feeds_filter`` on calendar_* variants) carry through. ### Changed - **Device cards on Settings → Devices sort alphabetically by name.** ``devices().all()`` returned entries in registry insertion order, so cards jumped around across renders when devices were added, renamed, or re-registered. Now stable, case-insensitive by display name with device id as tiebreaker. - **"Push" button on the Dashboards list names the fan-out.** Was labelled "Push" with the tooltip "Push this dashboard to its panel", which read as if the button targeted a single device. Now shows ``Push to N`` for multi-device dashboards, and the tooltip counts the linked devices explicitly. ### Added - **Push history has a "By dashboard" sort option (issue #52 follow-up).** ``?sort=dashboard`` groups rows sharing a target so a per-dashboard read is one scroll rather than a scan. Default is chronological, newest first. - **``--dev`` mode gains a "Seed dummy devices" affordance.** New yellow-bordered card under Add device on Settings → Devices creates a set of test instances (``dev_esp32``, ``dev_pi_bin``, ``dev_pi_png``, ``dev_picpak``, ``dev_trmnl``) so the device-card UI can be reproduced without real hardware attached. Guarded server-side by the ``DEV_MODE`` config flag; the button hides and the endpoint rejects outside of ``--dev`` mode. ## [0.69.16], 2026-07-06 ### Added - **PicPak 4.2" 4-colour BWRY panel supported out of the box (issue #61, firmware and hardware validation by @varanu5).** Ships a bundled `picpak_client` device kind that binds to the stock `esp32_bin` renderer with `gamut: "bwry_4"` (native 2 bpp packer, already present since v0.69.3) and a new `panel.vflip` option for the PicPak's bottom-to-top hardware scan direction. PicPak owners can now pair one to a stock Tesserae install; no server-plugin needed. Firmware lives at [varanu5/picpak-tesserae-client](https://github.com/varanu5/picpak-tesserae-client). ## [0.69.15], 2026-07-05 ### Fixed - **Test-pattern picker keeps its selection across saves.** Every calibration-side save (tone, palette colours, custom-image upload) returns a 302 to the Calibration tab, and the template's default first-radio would win on re-render, dropping whatever pattern the user was tuning. The picker's current pattern and colour are now persisted in localStorage keyed by device id and restored on load. ## [0.69.14], 2026-07-05 ### Fixed - **Device tabs no longer bleed across cards.** Clicking the Calibration tab on one device wrote `?tab=calibration` to the shared URL, so every other device card on the page re-rendered with Calibration active on the next reload. The template now scopes `?tab=` to the card whose id matches `?opened=`; other cards fall back to their default (Status). - **Custom-image upload keeps the device card expanded.** The upload, delete, and Send-to-panel POSTs on the Calibration tab all now thread `?opened=` through their redirects so the card doesn't collapse and lose scroll position on every action. Matches the shape the palette / tone routes have used since v0.69.9. - **Palette-profile picker previews before Apply.** Selecting a different profile in the Calibration-tab dropdown now repaints the test-pattern preview with that profile's palette and tone straight away, instead of waiting for the user to hit Apply. Backed by a new `?slug=` query on the preview endpoint (empty string means built-in default). - **BW / grayscale panels no longer show colour-only test patterns.** Mono-gamut panels used to see the palette-swatch and solid-fill entries in the picker even though both render as two blocks / one flat fill. The picker now filters those out for `gamut = "mono"`; grayscale ramp, text sample, registration grid, and custom image still show. Colour panels are unaffected. ## [0.69.13], 2026-07-05 ### Fixed - **Calendar widgets render dates in the configured app timezone (community contribution, #54 by @charmmmz).** The day / week / month calendar widgets were computing visible-date boundaries in UTC, so users in non-UTC timezones saw the wrong day for events near midnight. Now routes through the ``app_timezone()`` helper shared with the v0.69.6 history-timestamp fix, and also fixes an adjacent bug where multi-day all-day events only bucketed under their start date (iCalendar exclusive DTEND semantics are now honoured, so a Fri-to-Sun event correctly appears on all three days). - **Cloud-init yamls no longer time-bomb SD cards flashed months later (issue #35).** The server cloud-init at [`scripts/cloud-init.yaml`](scripts/cloud-init.yaml) used to clone ``main`` from ``dmellok/tesserae``, so a user who flashed an SD card three months after downloading the yaml got whatever main was on that day, not the tested release. Now pins to ``--branch v0.69.13 --depth 1``. The pi-client cloud-init at [`scripts/pi-client-cloud-init.yaml`](scripts/pi-client-cloud-init.yaml) gets a shallow clone (``--depth 1``) while the client repo doesn't publish tags yet; a comment flags the follow-up to pin properly once ``tesserae-device-pi-bin`` cuts its first release. ## [0.69.12], 2026-07-05 ### Fixed - **Multiple sticky save bars now stack instead of overlapping.** When both the outer combined-form bar and the tone-form bar are dirty on the same device card, they both stick to the viewport bottom and previously drew on top of each other. ``settings.js`` now tracks visible bars via ``MutationObserver`` on the ``hidden`` attribute and sets a cumulative ``--dx-save-bar-offset`` CSS custom property on each, so they stack vertically upwards from the viewport bottom (or downwards from the top). - **``.dx-device-card`` uses ``overflow: clip`` instead of ``hidden``.** The ``hidden`` value created a new scrollport scoped to the card, which blocked descendant ``position: sticky`` elements (the save bars) from sticking to the viewport. ``overflow: clip`` still hides overflow past the card's rounded corners but doesn't establish a scrollport. Sticky positioning on the bars now uses the viewport as intended: the bar sits at its natural position when in view, sticks to viewport bottom when scrolled below, sticks to viewport top when scrolled above. ## [0.69.11], 2026-07-05 ### Changed - **Palette profile picker hides on panels without a matching family.** Pre-v0.69.11, the picker fell through to the Spectra 6 default family for any unrecognised gamut, so a mono panel, a BWRY panel, or an RGB LCD saw Spectra 6 profiles they couldn't apply. ``_palette_family_for`` now returns empty for those gamuts and the section builder null-gates every palette endpoint (apply, save, reset, import, tone editor, per-colour palette editor), so the whole Calibration-tab palette section hides for panels that don't have bundled profiles. Spectra 6 and Inky 7-colour behaviour is unchanged. - **Sticky save bar sits in-card until it would scroll off.** ``position: sticky`` with both ``top`` and ``bottom`` insets so the bar sits at its natural in-card position when the natural position is in the viewport, and only sticks to the corresponding viewport edge when scroll would otherwise carry it off-screen. Replaces the v0.69.10 always-fixed variant, which was obscuring page content unnecessarily when the natural position was already in view. Message now reads "Unsaved changes for <device name>" instead of the generic "You have unsaved changes" so users know exactly which card is dirty. - **Palette-tone form gets its own sticky save bar.** The v0.69.10 attempt (a sticky footer inside the form with a primary teal button and a dirty flag) didn't render right and the sticky positioning didn't stick because the form itself is short. Replaced with the same ``.dx-save-bar`` shape the outer combined form uses, associated to the tone form via ``form=""`` attributes on Save + Discard. Reveals when a tone slider is touched, message reads "Unsaved tone changes for <device name>". ## [0.69.10], 2026-07-05 ### Changed - **Sticky "Save changes" bar now floats at the viewport bottom.** Previously it was ``position: sticky`` inside the device card, so a long calibration tab could scroll the bar off-screen. Now ``position: fixed`` centred at the viewport bottom, with ``env(safe-area-inset-bottom)`` inset so the iOS home-indicator doesn't sit on top of it. Matches the shape the ``--server`` variant already used on the Settings > Server section. - **Palette-tone form's Save button is much more discoverable.** The tone form was tall enough that the Save affordance sat below the fold when the user was editing sliders at the top; the button is now wrapped in a sticky footer inside the form so it stays pinned at the bottom of the visible tone-form region, and a small "Unsaved tone changes" flag fades in the moment the form goes dirty (via a ``data-dirty`` attribute now set by ``initDirtyForm``, useful for any future in-form-button dirty affordance too). Save button also promoted to the primary teal fill so it reads as the action to take. ## [0.69.9], 2026-07-05 ### Fixed - **Device card save affordance + collapse regressions from v0.68.0.** The v0.68.0 device-card reorg placed several per-endpoint forms (calibrate, palette, tone, custom-image) inside the outer combined form. HTML5 forbids nested forms: the first inner ```` close tag closes the outer form early, orphaning the picture-quality fields, the sticky save bar itself, and the Save button. Result: ``initDirtyForm`` never wired up (bar wasn't a descendant of the form it looked at), the sticky bar stayed hidden, the Save button submitted nothing, and changes to Calibration fields quietly did not persist. Every per-endpoint palette / tone / calibrate redirect also missed ``opened=``, so any Apply on those forms collapsed the device card. Rewired via the HTML5 ``form="..."`` attribute on every input in the tab panels that should submit to the combined endpoint (macros in [`_components.html`](templates/_components.html) take a new ``form`` parameter); the outer form's id ties the associations together regardless of DOM position. Per-endpoint redirects thread ``opened=`` via a shared sweep in [`palette_routes.py`](app/settings/palette_routes.py) and [`devices_routes.py`](app/settings/devices_routes.py) so the card stays open across every Save + Apply. Reverts the always-visible muted save-bar variant introduced in v0.69.6 (that was a workaround for a symptom of this bug; with the form actually wired up, hide-until-dirty is the right shape again). ## [0.69.8], 2026-07-05 ### Added - **Live-preview throbber in the widget editor.** The composer server-renders the preview page on every change, running each widget's ``fetch()`` in parallel; upstreams like Open-Meteo, iCal feeds, or Home Assistant add hundreds of ms to seconds of visible dead time. A ``ph-circle-notch`` spinner now sits over the faded iframe while the reload is in flight, fades in after ~280 ms (so a fast render with cached widget data stays flash-free), and disappears on the iframe's ``load`` event. Applies to ``reloadPreview``, the hourly hard-reset path, and the initial page open (which was previously the longest dead time thanks to ``loading="lazy"``). ## [0.69.7], 2026-07-05 ### Fixed - **mypy: narrow legacy lat/lon type in ``_app_location_dict``.** v0.69.6 added a legacy-lat/lon migration in [`app/composer.py`](app/composer.py) whose ``in (None, "")`` guard didn't survive mypy's base type check (the read value is ``Any | None``, and mypy sees ``float(None)`` as a type error). Explicit ``isinstance`` narrowing against ``int | float | str`` before the conversion. Behaviour is identical; CI mypy job now green. ## [0.69.6], 2026-07-05 Batch fix for issue #52 items 1, 2, 3, 4, 5, 6, 7. Items 8-10 (Devices to top-level nav, split registration, admin dashboard) are UX threads that need their own design pass. ### Fixed - **Multi-device dashboards now render under every bound device (item 1).** The Dashboards list used to pick a "primary" device (the first live entry in ``page.device_ids``) and only render the page under that section head; a dashboard bound to two devices only surfaced from one of them. ``_group_pages_for_index`` in [`app/page_routes.py`](app/page_routes.py) now appends the page under every live device it's bound to, so each device section head owns every dashboard pushed to it. Unbound-device fallback and half-deleted-binding tolerance stay intact. - **History timestamps render in the configured timezone (item 2).** [`app/history_routes.py`](app/history_routes.py) was using naive ``datetime.fromtimestamp`` which follows the container's TZ (UTC on Docker / MicroCloud defaults). Now routes through a new ``app_timezone()`` helper in [`app/tz_resolve.py`](app/tz_resolve.py) that reads ``settings.app.timezone``, so history rows honour the user's zone regardless of what the container thinks it is. Same helper is safe to consume from other views that render server-side clock times. - **``for_device`` no longer double-renders on instance-id collisions (item 4).** When a user's device instance id equalled a base renderer's topic prefix (say, an instance called ``pi_bin`` on a ``pi_bin`` base renderer), the settings loop rendered the picture-quality block twice with identical ``base_name`` because both the base and the clone matched. The registry now filters to clones-only when a clone is in the match set, so a single renderer's settings appear once regardless of the instance id. - **Weather widgets fall back to the app-level location (items 5 + 6).** New ``Settings → Location & time → Default location`` picker (a ``location_search`` field on the same shape the cell-level picker uses); ``_resolved_options`` in [`app/composer.py`](app/composer.py) splices it into any cell whose own ``location`` dict is empty, so weather widgets Just Work after the user picks a global location and swapping between weather_now / weather_forecast on a cell no longer triggers a re-search. Legacy flat ``latitude`` / ``longitude`` settings from before v0.69.6 get promoted into the new ``{latitude, longitude, name}`` shape on read so upgrades don't silently blank the fallback. - **Sticky "Save changes" bar is always visible on Settings tabs (item 3).** Previously hidden until the first ``input`` event, which meant users who wanted to save via a persistent button couldn't see one existed. The bar now renders in a muted state while the form is clean (dimmed background, disabled Save, hidden Discard) and lights up to the current styling on the first edit. Applies to the device card, the device-kind defaults, and the Settings > Server section. Same JS + CSS variant; templates only drop the ``hidden`` attribute and add the ``dx-save-bar--muted`` class. - **beforeunload popup fires only on real edits (item 7).** [`static/pages/editor.js`](static/pages/editor.js) used to gate the "Leave site?" prompt on ``saveBtn.disabled``, which flipped enabled on any ``input`` event including autofill / focus / browser extensions, so users saw the prompt while navigating between pages they hadn't touched. Now snapshots every form's ``FormData`` on load and after each save, and only prompts when the current values actually differ from the snapshot. ## [0.69.5], 2026-07-05 ### Fixed - **`esp32_bin` now routes `panel.gamut` through to the packer.** The renderer was hardcoding the packer's default (Spectra 6), so a device that declared a non-E6 gamut (BWRY, or any future ESP32-side gamut) got a Spectra-6 packed frame regardless. `pi_bin` already did this correctly, so BWRY on Pi worked but BWRY on ESP32 didn't end-to-end until now. See [`renderers/esp32_bin/renderer.py`](renderers/esp32_bin/renderer.py). ## [0.69.4], 2026-07-04 ### Changed - **`bwry_4` wire format switched to native 2-bpp packing.** v0.69.3 shipped BWRY as a 4-bpp nibble-packed buffer (matching Spectra 6 / ACeP conventions), which doubled the buffer size a PicPak-class C3 actually needs and forced a repack step on the client. `bwry_4` now packs 4 pixels per byte, MSB = leftmost pixel: a 400 × 300 PicPak frame is 30 000 bytes, not 60 000, and goes straight to the SPI stream without a decode step. Full wire spec in [`docs/dev/client-protocol.md`](docs/dev/client-protocol.md). - **`bwry_4` palette indices swapped to match the controller's register.** Palette order is now `(black, white, yellow, red)` so index equals wire value: `0x0=black`, `0x1=white`, `0x2=yellow`, `0x3=red`. v0.69.3 had yellow and red the other way round, which paints a red field on a yellow buffer (and vice versa) on PicPak. ## [0.69.3], 2026-07-04 ### Added - **`bwry_4` gamut for 4-colour B/W/Red/Yellow e-paper panels.** New `BWRY_4_PALETTE` constant in [`app/quantizer.py`](app/quantizer.py) (black / white / red / yellow), plus a nibble LUT and a `bwry_4` entry in `_GAMUT_TABLE` so `pack_to_panel_bin` picks it up whenever a device declares `panel.gamut = "bwry_4"`. Dense 0-3 nibble mapping (`0x0=black`, `0x1=white`, `0x2=red`, `0x3=yellow`); no reserved values, so firmware decoders only need to switch over the four nibbles. `circuitpython_png` also gains a BWRY branch for the indexed-PNG path. - **`bwry_4` in `ACCEPTED_GAMUTS`** so clients can declare it via `POST /api/v1/device/{discover, register}` and the value persists onto the auto-provisioned instance's panel block. No new endpoints; the API surface stays put, only the allow-list widens. ### Changed - **`docs/dev/client-protocol.md`** documents the new gamut value in the accepted-value table and adds a `bwry_4` entry to the `.bin` frame-format section with the exact nibble convention firmware needs to decode. Targets PicPak-class 400 × 300 4.2" BWRY panels (60 000 bytes per frame at 4 bpp). ## [0.69.2], 2026-07-04 ### Added - **Delete-with-cascade prompt on the device card** (issue [#48](https://github.com/dmellok/tesserae/issues/48)). The delete button no longer fires a browser `confirm()`; it expands an inline form with a checkbox showing exactly what would be wiped ("Also wipe N bound dashboards, M history rows, per-device settings, uploaded calibration image"). Off by default so muscle memory doesn't destroy data; on for a clean-slate delete. - **MAC-differs auto-wipe on re-register.** When a device is deleted without ticking the wipe checkbox, Tesserae stashes the device's last-known MAC in `data/deleted_device_markers.json`. On `POST /api/v1/device/register` or a one-click register from the Discovered strip, if the incoming MAC differs from the stored MAC (or the client dropped its MAC), the leftover pages / events / settings / calibration image are wiped before the new instance is created. Same MAC keeps the state; the marker is cleared on either path so subsequent registers behave normally. - **`app.device_cleanup`** package: `list_orphan_state()` and `wipe_orphan_state()` are the shared helpers used by both flows. `app.state.deleted_device_markers.DeletedDeviceMarkers` is the tiny MAC-tracking store. ## [0.69.1], 2026-07-04 ### Added - **Optional `gamut` field on `/api/v1/device/discover` + `/api/v1/device/register`** (issue [#41](https://github.com/dmellok/tesserae/issues/41)). A generic CircuitPython client (or any REST-registering firmware) can now declare its colour target in the same payload that carries `panel_w` / `panel_h`; the value gets canonicalised through the new `app.quantizer.canonicalise_gamut` and persisted onto the auto-provisioned instance's panel block. Result: the `circuitpython_generic` kind serves every panel shape from one manifest with no per-SKU release cycle. - **Wider `ACCEPTED_GAMUTS`** list: `waveshare_e6`, `inky_7colour`, `spectra_6` (aliases to `waveshare_e6`), `acep_7colour` (aliases to `inky_7colour`), `mono`, and (per [the follow-up comment on issue #41](https://github.com/dmellok/tesserae/issues/41#issuecomment-4872979793)) `rgb24` + `rgb16` for full-colour display hybrids. Unknown values fall back to `waveshare_e6` at persistence time so a corrupt payload can't strand the device with a nonsense panel. ### Changed - **`Settings → Devices → Discovered` register flow** reads `gamut` from the cached discover payload alongside `panel_w` / `panel_h` when the admin clicks Register on a discovered device. - **`circuitpython_png` renderer honours `rgb24` and `rgb16` panels** by emitting a plain 24-bit RGB PNG (no palette quantise, no dither). `mono` was already wired to the 2-colour palette path; this release makes it reachable end-to-end from a generic `/register` call. `rgb16` panels pack the 24-bit RGB to RGB565 on-device; a raw RGB565 wire format is a bandwidth-only follow-up. - **`docs/dev/client-protocol.md`** documents the new payload field + the accepted-value table. ## [0.69.0], 2026-07-04 ### Changed - **PushManager: latest-wins coalescing per device, replacing the old `status="busy"` drop path.** Two pushes for the same device no longer stomp each other; the earlier one gets `status="superseded"` (with a matching History event) and the later one paints. User-initiated pushes (Send-file / Send-URL / Send-webpage / Send-page / test patterns / republish) pass `bypass_coalesce=True` so they always fire. Scheduler and auto-refresh flows leave the flag `False`, so back-to-back schedule ticks for the same page paint once instead of twice. - New `PushResult` status: `"superseded"`. Same shape as `"busy"` was (event row + error message), so History / HA discovery / events log all keep working; downstream consumers only need to handle the new string. `"busy"` remains in the `PushStatus` literal for backward compat but is no longer emitted. ### Added - **`bypass_coalesce` kwarg** on `PushManager.push`, `.push_image`, `.push_url_image`, `.push_webpage`. Defaults are set so the common HTTP-facing surfaces (`push_image` etc.) bypass by default (user intent), while `push(page_id)` defaults to coalescing (scheduler intent). Send-page's "Send this dashboard" button explicitly passes `bypass_coalesce=True` so a panic-click never gets silently superseded. ## [0.68.0], 2026-07-04 ### Changed - **Device-card tabs reorganised around hardware setup vs colour tuning.** The Rendering tab is retired; Panel & orientation, the orientation calibration card (Send + 1/2/3/4 answer), and dither algorithm all move onto the appropriate tabs. Panel + orientation + orientation calibration land on General (physical-mount concerns). Dither algorithm joins contrast, saturation, palette, tone and edges on Calibration (colour-rendering concerns). The four remaining tabs are Status / General / Schedule / Calibration. Legacy `?tab=rendering` URLs redirect to General. - **Retired the per-clone `calibrated` toggle.** The Calibration-tab palette-profile picker is now the single source of truth for which palette a device paints. The palette-override plumbing in `pack_to_panel_bin` no longer requires `calibrated=True` on the clone; a profile-with-palette wins unconditionally. The field stays in renderer manifests + storage so pre-v0.68 configs keep parsing, but it no longer surfaces in the UI. - **Consistent UI primitives on the Calibration tab.** Bare checkboxes for serpentine scan + preserve-line-art now use the shared `switch()` macro; the colour-index dropdown wears the standard `.input` class. Tone-editor sliders align to a fixed 200 px label column so every slider starts and ends at the same x. - **Device-card tab bar scrolls horizontally on narrow viewports.** Adds `overflow-x: auto` + `scroll-snap-type: x proximity` on `.dx-tabs` so the four tabs stay accessible on mobile without wrapping. ### Added - **Custom calibration image.** Every device grows a small "Upload custom calibration image" affordance under the Colour test patterns block. Uploaded images (PNG / JPEG / WebP) live at `data/calibration_images/.png`, get fit-to-panel with white padding at render time, and surface as a new `Your uploaded image` entry in the pattern picker. Delete button removes the file idempotently. - **Live palette preview.** Nudging any of the six/seven palette colour swatches on the Calibration tab now repaints the inline preview `` on the same tick, matching the tone-slider live behaviour that shipped in v0.67.4. Works for both Waveshare E6 (6 colours) and ACeP / Inky 7-colour (adds orange). - **Device card stays open after Save.** The combined-form redirect threads `?opened=` back to the settings index, and the device card checks that flag alongside the existing `?calibrating=` flow to render `data-collapsed="false"`. No more re-opening the card after every save. ## [0.67.5], 2026-07-04 ### Fixed - **`mypy` strict CI failure on `_error_diffusion`'s LAB scratch buffers.** The v0.67.4 introduction of the LAB colour-match path initialised the scratch buffers as `None` in the RGB-only branch, which type-checked locally against the strict-modules list but tripped the CI-wide `mypy app` run (91 files) on the buffer indexing. Declare the buffers up-front with concrete types and leave them empty in the RGB path so the indexed reads inside `if use_lab:` type-check consistently. No runtime behaviour change. ## [0.67.4], 2026-07-04 ### Added - **LAB dynamic-range compression on palette profiles.** New `_compress_lab_range` helper in `app.quantizer` rescales a source's L* channel into `[lab_compress_min, lab_compress_max]` while leaving a* and b* untouched, preserving hue while squeezing brightness into whatever the target panel can reproduce. Two matching sliders (min / max, 0-100) join the tone editor on the Calibration tab. Overrides the linear `_compress_to_calibrated_range` when set; default `(0, 100)` is a no-op fast path so profiles without the knob set render byte-identical. - **Colour-match modes (`rgb` / `lab` / `chroma-aware`)** on the error-diffusion dithers (`floyd-steinberg`, `atkinson`, `jarvis`, `stucki`). `lab` picks each pixel's nearest palette entry in CIE L*a*b* space instead of sRGB; `chroma-aware` weights a*/b* differences 2x to preserve hue over lightness. Pillow's built-in FS is RGB-only, so when a profile picks `lab` / `chroma-aware` and the algorithm is `floyd-steinberg`, the packer detours through the numpy error-diffusion path with FS weights. - **New sRGB<->LAB conversion helpers** (`_srgb_to_lab`, `_lab_to_srgb`) in `app.quantizer`. Standard D65 sRGB gamma + CIELAB nonlinearity; vectorised numpy, round-trips within a 4- per-channel delta (documented in `test_srgb_lab_round_trip_is_lossless_within_a_delta`). - **Tone-aware test-pattern preview.** The Calibration-tab preview now reflects the applied profile's palette, exposure, S-curve, LAB compression, and smoothing radius; sliders update the preview `` live via a query-string handshake before the user hits Save. Dither / colour-match / preserve-line-art stay render-time only (they're per-pixel and would need the full `pack_to_panel_bin` round-trip to replay in the preview). ## [0.67.3], 2026-07-04 ### Added - **Per-colour palette editor on the Calibration tab.** The palette recalibration card grows a grid of six (Spectra 6) or seven (Inky 7-colour) native `` swatches with a live `#rrggbb` readout below each. Users can eyeball- match each palette entry against what their panel actually reproduces, without editing JSON. New endpoint `POST /settings/devices//palette/update-palette` handles the fork-or-edit-in-place logic: bundled presets fork into a user profile named " (edited)" on first tweak; user profiles are edited in place. Bad hex values fall through to the base preset's value rather than being written as-is. ## [0.67.2], 2026-07-04 ### Added - **Experimental edge handling on the Calibration tab.** The tone editor grows an "Experimental: edge handling" fold-out with two new knobs: **Smoothing radius** (0-3 px, Gaussian blur applied before tone mapping to soften antialiased edges before dither can build a noisy tail along them) and **Preserve line-art edges** (post-dither pass that detects sharp edges in the tone- mapped source and swaps those pixels for nearest-neighbour quantise so text and hairline rules stay crisp). - **`smoothing_radius` + `preserve_line_art` on `pack_to_panel_bin`.** Both default to their pre-v0.67.2 neutral values (0 and False), so devices with no profile applied render byte-identical to v0.67.1 (`test_pack_edge_defaults_match_pre_v672`). `esp32_bin` / `pi_bin` / `pico_bin` pass the profile's edges block through. `preserve_line_art` costs zero on all-photo sources (edge mask is empty). ## [0.67.1], 2026-07-04 ### Added - **Tone + dither editor on the Calibration tab.** The palette card now grows four sliders / toggles that write to the active profile: **Exposure** (-100..+100 linear brightness shift), **S-curve** (-100..+100 mid-tone punch via a sigmoid), **Diffusion strength** (0..200 for error-diffusion dithers; 100 = normal), and **Serpentine scan** (flips scan direction each row, hides the diagonal worming pattern on gradients). Editing a bundled preset forks it into an editable user profile named " (edited)" on first tweak; subsequent edits update the fork in place. User profiles are edited directly. - **Palette-profile tone + dither now take effect at render time.** `app.push` injects `_profile_tone` and `_profile_dither` into settings alongside `_palette_override`; `esp32_bin`, `pi_bin`, `pico_bin`, and `trmnl_png_color` pass them through to `app.quantizer.pack_to_panel_bin`. Devices with no profile applied render byte-identical to pre-v0.67.1 (verified in `test_pack_neutral_knobs_match_pre_v67_defaults`). - **New route** `POST /settings/devices//palette/update-tone` handles the fork-or-edit-in-place logic behind the tone editor. ## [0.67.0], 2026-07-04 ### Added - **Palette recalibration on the Calibration tab.** Every device card grows a "Palette recalibration" section that lets the user pick from six pre-measured palette profiles per gamut (Spectra 6 + Inky 7-colour) sourced from [paperlesspaper/epdoptimize](https://github.com/paperlesspaper/epdoptimize) (`spectra6`, `spectra6legacy`, `spectra6-boeber`, `aitjcize-spectra6`, `acep`), plus a "Nominal (uncalibrated)" identity fallback. Bundled presets carry `based_on` + `attribution` fields that surface as a "via paperlesspaper / epdoptimize" line on the picker. Applied profile is stored per device at `settings.devices..palette_profile_slug`. - **Save-as-new, import, export, delete for palette profiles.** Users can fork any bundled preset into a named custom profile (saved at `data/palette_profiles/.json`), import a JSON profile shared by someone else, download a profile via `GET /settings/palette-profiles//export.json`, and delete their own custom profiles. Bundled profiles refuse deletion. - **Palette override wired through the `.bin` renderers** (`esp32_bin`, `pi_bin`, `pico_bin`, plus `trmnl_png_color` on the PNG side). When a device has a profile applied AND the renderer clone's `calibrated` toggle is on, the profile's palette wins over the built-in `_CALIBRATED_PALETTES` lookup in `app.quantizer`. `pi_png` and `trmnl_png` don't gain server-side palette override in this release (their quantisation lives on the client or is mono). - **Palette-profile schema, store, and bundled table** in the new [`app.palette_profiles`](app/palette_profiles/) package. Forward-compatible: unknown JSON fields are ignored on load, out-of-range tone values are clamped, bad hex codes fall through to `#000000` so a corrupt profile can't crash a render. - **Developer docs** at [`docs/dev/calibration.md`](docs/dev/calibration.md) explaining the schema, storage layout, and how to add a new bundled preset. ### Changed - **Contrast + saturation moved from Rendering → Calibration.** The two tone-mapping fields that were surfaced in each device card's Picture-quality subsection (Rendering tab) now render on the Calibration tab alongside the palette picker. Storage is unchanged (still per-clone `settings.renderers..contrast` / `.saturation`) so existing configs read forward without migration. - **`NOTICES.md`** carries a new sub-section documenting the paperlesspaper/epdoptimize palette data reused as bundled presets, alongside the existing entry for the calibrated palette values in `app.quantizer`. ## [0.66.1], 2026-07-04 ### Changed - **Calibration tab: two-column layout for colour test patterns.** Pattern picker + colour + submit stack on the left; preview stretches on the right and fills the vertical space so portrait panels (Seeed XIAO EE02, reTerminal E1004) render at a readable size instead of being squeezed to a thumbnail. Stacks under 720 px so the picker keeps its full width on narrow viewports. ### Removed - **Legacy "Calibrate" chip in the device-card footer.** Orientation calibration + colour test patterns are now on the Calibration tab; the footer chip introduced in v0.66.0 as a deep-link is gone since the tab itself is the surface. ## [0.66.0], 2026-07-03 ### Added - **New Calibration tab on every device card** (Settings → Devices). A fifth tab alongside Status / General / Rendering / Schedule that consolidates the existing orientation calibration flow with a new colour-test-pattern picker. Five patterns ship: palette swatches (labelled solid block per palette entry), 16-step grayscale ramp, solid fill (per-colour), text sample (three sizes), and a registration grid (1 px + 2 px lines with corner marks). The legacy footer "Calibrate" button becomes a deep-link chip that jumps straight to the new tab, so muscle memory holds. - **Test patterns are palette-locked to the panel's gamut**, so the renderer's dither pass has zero error to diffuse and what the tab's inline preview shows is byte-identical to what the panel paints. Snaps automatically to `waveshare_e6` or `inky_7colour` based on the device's declared gamut and honours the per-clone `calibrated` toggle (measured palette from [epdoptimize](https://github.com/paperlesspaper/epdoptimize) when on, nominal palette when off). Custom / unknown gamuts fall back to E6 rather than crashing. - **Two new routes** back the tab: `POST /settings/devices//test-pattern` hands PNG bytes to `PushManager.push_image` (same path the Send-file / Send-URL flows use, so the device's real renderer + transport paint the panel); `GET /settings/devices//test-pattern/preview.png` returns the same bytes for the tab's inline preview ``. The palette recalibration slot is scaffolded but disabled until LAB-aware quantisation lands. ## [0.65.2], 2026-07-03 ### Fixed - **Picking Atkinson / Jarvis / Stucki / Bayer 8x8 / halftone / crosshatch as a device's dither mode no longer crashes the push (issue [#47](https://github.com/dmellok/tesserae/issues/47)).** `app.quantizer.quantize()` only accepted the two dither modes Pillow ships built-in (`floyd-steinberg` and `none`) and raised `ValueError: unsupported Pillow dither mode: 'atkinson'` for the other six declared in `DitherMode`, even though the UI offers all eight in the device dither dropdown. `quantize()` now falls through to the same numpy-backed error-diffusion (Atkinson / Jarvis / Stucki) and ordered (Bayer / halftone / crosshatch) implementations `pack_to_panel_bin` already uses; the resulting palette-index buffer is projected back through the palette to recover an RGB image. The `trmnl_png` and `trmnl_png_color` renderers now paint any dither mode the UI lets a user pick. ## [0.65.1], 2026-07-03 ### Added - **[`docs/install/buttons`](install/buttons.md)**: new user-facing guide for the button feature. Covers the default map, per-device editor (**Settings → Devices → General → Buttons**), the full action list (`rotate_prev`, `rotate_next`, `refresh`, `step:`, `page:`, `webhook:`), manual override behaviour, dedup semantics, and how button events appear on the History page. Wired into the docs nav under **Set up a device**. - **`schema/openapi.yaml`** now documents the button contract: `button` query param on `GET /api/v1/device/{device_id}/frame`, `button` + `button_event_id` optional fields on the status body, the `rotation` envelope on both `FrameEnvelope` and `DeviceStatusResponse`, and the `Content-Location` response header on the frame endpoint's `200` + `304` branches. New reusable schemas: `RotationEnvelope` and the `ButtonQueryParam` / `ContentLocation` component definitions. - **Cross-links from the surfaces users actually land on:** the [Seeed featured hardware page](hardware/seeed.md), the [unified Seeed quickstart](quickstart/seeed-unified.md), and the ["Set up a device"](install/devices.md) guide all now point at the buttons guide. ## [0.65.0], 2026-07-03 **The reTerminal launch release.** Everything the Seeed reTerminal E-Series needs to land as a first-class Tesserae target, plus physical button wakes for the whole ESP32 firmware family, a community layer, and a stack of reliability + docs work rolled up from thirteen 0.64.x patches. Detailed per-patch notes remain below. ### Highlights - **Native firmware for the Seeed reTerminal E-Series and the wider XIAO ePaper family**, browser-flashable at [`tesserae.ink/flash`](https://tesserae.ink/flash). One codebase (`tesserae-device-firmware`) covers E1001 (7.5" mono), E1002 (7.3" Spectra 6), E1003 (10.3" mono, 16-level grey), E1004 (13.3" Spectra 6), the XIAO EE02 (13.3" Spectra 6), and the XIAO 7.5" mono. No ESP-IDF toolchain, no TRMNL account, no BYOS proxy. - **Physical button wakes** on every ESP32 client. Firmware carries the pressed button in `/frame` (`?button=`) and `/status` (`{button, button_event_id}`), the server resolves it through a per-device `button_map` (default: `left → rotate_prev`, `right → rotate_next`, `refresh → refresh`) with global + hardcoded fallbacks, dedups against the monotonic `button_event_id`, dispatches through an extensible action registry (`rotate_prev`, `rotate_next`, `refresh`, `step:`, `page:`, `webhook:`), persists the new rotation position with a sticky-until-next-anchor manual override, and pushes the resulting page synchronously so the returned frame reflects the new state on the same wake. Admin UI + full History integration land at the same time. - **`Content-Location` on `/frame`** (200 *and* 304) so a client that boots without a cached URL (non-e-ink panels, factory reset) can re-fetch the image without needing to have persisted the URL alongside its ETag. RFC 7231 §3.1.4.2 explicitly permits `Content-Location` on 304 for exactly this. - **Community layer live.** Discord server up, community CTAs (Discussions + Discord + Sponsor) on the onboarding wrap-up step, the app footer, and the Settings → About card. First community sponsor, first external client contribution (Bernhard's CircuitPython client), and first external PR all landed in the 0.64.x series ahead of the launch push. - **Widget failure isolation.** A dead upstream API (e.g. the Open-Meteo outage on 2026-07-03) no longer sinks the whole dashboard render; each widget shows its own error tile and the rest of the page paints normally. Composer no longer waits on stuck HTTP threads past the overall hydration budget. - **New docs surfaces.** Unified Seeed quickstart, Featured hardware page for the reTerminal E-Series, per-vendor Hardware SKUs section on the compatibility page, and a full protocol write-up for button events, rotation state, and the `Content-Location` header. ### Added - **Server-side handling for physical button wakes on ESP32 devices** (0.64.70): action registry, per-device + global `button_map` with hardcoded defaults, dedup by `button_event_id` (or same-button time-window fallback), sticky manual override, rotation envelope on responses, admin UI for editing the map, per-event History rows with the `button` source label + `hand-tap` icon + filter chip. - **`Content-Location` header on `/api/v1/device//frame`** (this release), on both 200 and 304 responses. Zero-cost to existing e-ink firmwares; unlocks non-e-ink and reset-cycle recovery for new client families. Documented in `client-protocol.md` alongside a note that the URL is also deterministic from the `render_id` (`/renders/.`) so the most memory- constrained targets can reconstruct without reading the header. - **Community CTAs across the app** (0.64.69): Discussions + Discord + Sponsor on the onboarding wrap-up step, the app footer, and the Settings → About card. URLs live in a single app-wide context processor so a link update happens in one place. - **Featured Seeed hardware page** on the docs site (0.64.65), reachable from the nav under Featured hardware and from the landing page's "New here?" tip. Frames the reTerminal E-Series as the ready-to-go hardware path (browser flash, battery-powered, no assembly required) with a slot for the group photo. - **Six Seeed hardware manifests** (0.64.60–0.64.65): reTerminal E1001–E1004, XIAO ePaper EE02, XIAO ePaper 7.5" mono. Plus the Waveshare PhotoPainter 7.3" (0.64.60) unified under `tesserae-device-firmware`. - **Unified Seeed quickstart** covering every SKU via one firmware + one flasher (0.64.63). - **Hardware SKUs section on the compatibility page** with a per- vendor intro map so surfacing a new vendor is one config entry (0.64.64). - **History page detail line + friendly device names for button rows** (0.64.73): device chip resolved through the registry, and a synthesised `button right → rotate_next pushed Afternoon calendar` line under the main row so the outcome is visible without expanding raw JSON. ### Changed - **Install-a-client docs** now lead with `tesserae-device-firmware` and the browser flasher; the standalone Waveshare firmware repos covered by the unified build were removed from the client table (0.64.67). - **README's Seeed section** reframed from TRMNL BYOS to Tesserae- native, with every reTerminal SKU marked ✅ and every row linking the firmware repo. E1002 corrected from ACeP to Spectra 6, E1003 dims corrected to 1872×1404. XIAO EE02 row added. Compatibility page's Seeed section gains a firmware-and-flasher intro paragraph via a new `VENDOR_INTRO` map (0.64.65). - **Docs landing page** gets a ready-to-go hardware row in the "New here?" tip alongside "I want it running" and "I have a panel to drive" (0.64.65). - **Mobile dashboard editor** drops the sticky preview at the top of the viewport in favour of the existing floating back-to-top FAB (0.64.66). Desktop's sticky-in-right-column behaviour is unchanged. - **Docker workflow's GHA cache** scoped per ref (`scope=${{ github.ref_name }}`) so parallel main + tag pushes on a release stop racing on layer blob writes (0.64.68). - **Client-protocol spec** now covers the button contract (query param on `/frame`, body fields on `/status`, response envelope shape, `refresh`-drops-ETag convention) and the `Content-Location` header on `/frame` (0.64.70 + this release). ### Fixed - **Dead upstream weather API no longer sinks the whole dashboard render** (0.64.72). Composer's `ThreadPoolExecutor` now shuts down with `cancel_futures=True` so stuck HTTP threads don't hold the composer past Playwright's `page.goto` budget. The four `weather_*` widgets pass `retries=0` and `timeout=5` to `fetch_json` so a hard Open-Meteo outage returns `{"error": ...}` to the widget in ~5s and the cell paints its error state instead of stalling the whole page. - **Mypy strict errors on `app/button_service.py`** that slipped past local pytest (0.64.71). - **Docker workflow race on release pushes** where parallel main + tag runs shared one GHA cache namespace and one always failed on `error writing layer blob: not_found` (0.64.68). - **Panel-preset table column widths + a stale Seeed manifest URL** (0.64.64). ## [0.64.73], 2026-07-03 ### Changed - **History page shows the friendly device name + a detail line for button events.** Previously button rows showed the raw device id in the target column and buried every interesting field (the pressed button name, the resolved action spec, the pushed page id, the rotation position) inside `extra`, invisible to the row renderer. `history_view` in `app/history_routes.py` now resolves the button-row target through the device registry (so `kitchen` reads as e.g. "Kitchen wall panel"), adds a device chip for the target, and synthesises a short `button_detail` string like `button right → rotate_next pushed Afternoon calendar`. The template renders that as a muted second line under the main row so the full outcome is visible without a JSON expand pane. `_button_detail` gracefully falls back to `action_description` when no spec is present (deduped / unmapped rows still read cleanly) and returns `None` when the extras are empty so the template drops the line entirely. ## [0.64.72], 2026-07-03 ### Fixed - **Dead upstream weather API no longer sinks the whole dashboard render.** The failure chain hit during the 2026-07-03 Open-Meteo outage was: `fetch_json`'s default `retries=1` compounded to ~31s of blocking per widget on a hard timeout; the composer's `ThreadPoolExecutor` context manager waited (default `wait=True`) for those stuck HTTP threads to finish before returning; the hydration step therefore held the composer past Playwright's 15s `page.goto` budget; Playwright reported a broken navigation and the screenshot captured an empty page. Two levels of fix: 1. **`app/composer.py`** now shuts the pool down with `shutdown(wait=False, cancel_futures=True)` on exit, so stuck HTTP threads no longer hold up the composer once the overall 12s cap fires. Also drops the per-widget cap from 10s to 6s so a single 15s HTTP-level timeout can't push a well-behaved widget's wall-time past the overall budget. 2. **The four `weather_*` widgets** (`weather_now`, `weather_forecast`, `weather_hourly`, `weather_now_scenic`) now call `fetch_json` with `retries=0` and `timeout=5`, so a hard Open-Meteo outage returns `{"error": ...}` to the widget in ~5s and the cell paints its `data.error` state instead of stalling the whole page render. The 10-minute per-location cache means healthy upstreams still serve fresh data without changes. ## [0.64.71], 2026-07-03 ### Fixed - **Two mypy errors in `app/button_service.py` that slipped past local pytest.** Line 142 carried an unused `type: ignore[assignment]` now that the push-manager getter type widens correctly, and the webhook payload literal inferred as `dict[str, int | str | None]`, which doesn't satisfy `_fire_webhook_async`'s `dict[str, object]` parameter under mypy's invariant value semantics. An explicit `dict[str, object]` annotation on the payload literal fixes both without changing behaviour. `mypy app/` clean. ## [0.64.70], 2026-07-03 ### Added - **Server-side handling for physical button wakes on ESP32 devices.** On a button-driven wake the firmware carries the pressed button in the frame query (`GET /api/v1/device//frame?button=`) and in the status body (`{"button": "", "button_event_id": , ...}`). The server dispatches the mapped action synchronously before selecting the frame so the returned artefact already reflects the new state on this same wake, and persists the new position so later timer wakes continue from there. - **Configurable per-device `button_map` with global fallback.** The hardcoded default is `{"left": "rotate_prev", "right": "rotate_next", "refresh": "refresh"}`. Precedence: `settings.devices..button_map` beats `settings.app.button_map` beats default. Values are `` or `:` strings so the config surface stays JSON-flat. - **Action registry** at [`app/button_actions.py`](app/button_actions.py): `rotate_prev`, `rotate_next`, `refresh`, `step:` (jump to a specific rotation step), `page:` (push a specific dashboard to this device without touching rotation state), and `webhook:` (stub, validates shape). Third-party plugins can add actions via `register(name, fn)`. Adding a new button id or action needs only a config / registry change. - **Debounce + idempotency** via `button_event_id`. The firmware sends a monotonically increasing uint; the server treats any incoming id `<= last processed` as a retry and no-ops. Firmwares without the counter fall back to a same-button-within-N-seconds window, default 3s, overridable via `settings.app.button_debounce_s`. - **`rotation` envelope on the `/frame` and `/status` responses** (`{rotation_id, step_index, step_page_id, step_count, manual_override, override_until}`). Present when the device is bound to at least one enabled rotation, omitted otherwise. Lets an admin UI show where the device is and pair a per-device `button_map` editor to reality. - **New state:** [`app/state/device_rotation_state_model.py`](app/state/device_rotation_state_model.py) + [`app/state/device_rotation_state_store.py`](app/state/device_rotation_state_store.py) persist per-device manual position, override expiry, and button dedup fingerprint. Default is "sticky until the rotation's next daily anchor" so a button press doesn't get yanked back by the scheduler a minute later; overridable via `settings.app.button_hold_seconds`. - **Client-protocol docs** at [`docs/dev/client-protocol.md`](docs/dev/client-protocol.md) now cover the button contract: query param on `/frame`, body fields on `/status`, response envelope shape, and the `refresh`-drops-ETag convention firmwares should honour. - **Unmapped buttons** log a warning and return the current frame without state change; malformed action args (`step:abc`, `page:unknown`, `webhook:ftp://…`) do the same. Rotations that aren't bound to a device silently no-op on rotation-manipulating actions but still work for `page:` / `webhook:` shortcuts. - **Tests** for the action registry, the state store, and the service integration (dedup by event id, dedup by time window, per-device config precedence, `page:` shortcut semantics, push-failure resilience). - **Webhook action fires in a daemon thread** with a 3s timeout (overridable via `settings.app.button_webhook_timeout_s`). The POST body carries `{device_id, button, button_event_id, action_spec, timestamp, rotation_id, step_index, step_page_id}`, content-type `application/json`. Failures are logged and swallowed so `/frame` never blocks on an unhealthy external endpoint. - **Per-device button map editor on the Settings → Devices card** (General tab). A JSON textarea for the raw `button_map`, plus a fold-out showing the resolved effective map (default merged with the global map merged with the per-device override). The registered actions list is generated from the runtime registry so third-party plugins that call `register(...)` at import time show up in the help text automatically. Save is wired into the existing combined save endpoint; validation rejects malformed JSON, non-string values, unknown actions, and bad action arg shapes before writing. - **Every button press writes a History row.** ButtonService emits one event log entry per dispatched press with source `button`, the button name, the resolved action spec, and the resulting state (rotation position, pushed page id, manual-override flag), covering every outcome the admin can care about: `dispatched`, `deduped`, `unmapped`, `error`, `webhook_dispatched`, and `noop`. The row is in addition to the push row `PushManager` already logs, so a state-changing wake produces two correlated rows (one for the button event, one for the resulting page push). `templates/history` and `app/history_routes.py` gain the `button` source label + icon (`hand-tap`) + filter chip so the button feed is a first-class view on the History page. ## [0.64.69], 2026-07-03 ### Added - **Community CTA block on the onboarding wrap-up step and mirrored on the Settings → About card.** Three CTAs (GitHub Discussions, Discord, Sponsor) with the same copy, icons, and styling on both surfaces so a first-time user and a long-time user land on the same set of community links. URLs live in a single spot: the app-wide context processor in `app_factory.py` exposes `community_discussions_url`, `community_discord_url`, and `community_sponsor_url`, and the templates read from there. Update in one place if a link changes. - **Discord invite link (`https://discord.gg/6qmwkGhGR7`) now shipped in the footer, the onboarding wrap-up, and the About card**, so users can find the server from wherever they are in the app. ### Changed - **App footer now carries Discussions + Discord + Sponsor links alongside the release tag.** The previous `dmello.io` blog link has been removed in favour of the community set; the release version chip and the Sponsor heart both remain. All links go through `rel="noreferrer noopener"` so the destination never sees a Referer header pointing at the host's LAN address. - **Settings → About card layout consolidated.** The standalone "Support the project" section has been folded into a new "Join the community" card that pairs Discussions + Discord + Sponsor as three CTA rows, matching the onboarding wrap-up. The "Discussions" row is removed from the meta list since the same link now lives one card down in a more prominent format. The "Tell us how you use Tesserae" survey card is unchanged. ### Fixed - **Docker workflow's GitHub Actions Cache write race on release pushes.** Pushing `main` + a `v*` tag together fires the docker workflow twice in parallel (once per ref), and both runs wrote to the same GHA cache namespace. The second write hit `error writing layer blob: not_found` on layer blobs the first run had just cleaned up, and the workflow reported a failure even though every image manifest had already published to GHCR. Scoping the cache per ref via `cache-from: type=gha,scope=${{ github.ref_name }}` (and the equivalent `cache-to`) gives `main`, each tag, and PR builds their own cache namespace so the two release-time runs stop colliding. Cache reuse within a single ref is unchanged. ## [0.64.67], 2026-07-03 ### Changed - **[`docs/install/clients`](install/clients.md)** now leads with the unified [`tesserae-device-firmware`](https://github.com/dmellok/tesserae-device-firmware) and the browser flasher at [`tesserae.ink/flash`](https://tesserae.ink/flash) as the primary ESP32 client path, and drops the standalone `tesserae-device-esp32-bin` (Waveshare 13.3" Spectra 6) and `tesserae-device-photopainter-7.3-bin` (Waveshare PhotoPainter 7.3") sections since both boards are now covered by the unified firmware. Client table gains a `tesserae-device-firmware` row listing every SKU it covers (reTerminal E1001-E1004, XIAO EE02, XIAO 7.5", Waveshare 13.3" Spectra 6, PhotoPainter 7.3"). Fixes an orphaned "the 13.3" client" reference in the `esp32-bw` section that pointed at the now removed heading. ## [0.64.66], 2026-07-02 ### Changed - **Dashboard editor's mobile preview no longer pins to the top of the viewport.** The preview card now scrolls with the rest of the page and the existing floating back-to-top FAB reappears once it leaves the viewport. The FAB's `IntersectionObserver` was already generic, so no JS change was needed. Also un-hides the "Live preview" title and the panel-dims line on mobile, since they were only hidden to maximise the preview area inside the pinned strip. Desktop's sticky-in-right-column behaviour is unchanged. ## [0.64.65], 2026-07-02 ### Added - **[`docs/hardware/seeed`](hardware/seeed.md)**: new dedicated featured-hardware page for the Seeed reTerminal E-Series and the wider XIAO ePaper family. Frames the E-Series as the ready-to-go hardware path (browser flash, battery-powered, no assembly required), lists every supported SKU with panel, colour, and resolution, walks through the getting-started flow, and reserves a slot for the group photo shoot. Reachable from the docs nav under **Featured hardware** and from the landing page's "New here?" tip. ### Changed - **[`README`](README.md)** Seeed section now leads with the native [`tesserae-device-firmware`](https://github.com/dmellok/tesserae-device-firmware) and the [`tesserae.ink/flash`](https://tesserae.ink/flash) browser flasher rather than the older TRMNL BYOS framing. Every row in the Seeed table links `tesserae-device-firmware` as the client and reflects real-hardware status for the reTerminal E1001, E1002, E1003, and E1004 (all painting). Also adds the previously-missing XIAO ePaper EE02 row and corrects the E1002 panel column to Spectra 6 (was ACeP) plus the E1003 dims to 1872×1404 landscape. - **[`docs/compatibility`](compatibility.md)** Seeed hardware section now carries an intro paragraph pointing at the firmware repo, the web flasher, and the "battery-powered, no assembly required" framing before the SKU table. Rendered by a new `VENDOR_INTRO` map in `scripts/gen_compatibility.py` so any vendor section can pick up the same treatment without a code change. - **[`docs/index`](index.md)** landing page's "New here?" tip now includes the ready-to-go hardware path (linking the new Seeed featured page) as a distinct row alongside "I want it running" and "I have a panel to drive", so a first-time visitor lands on a concrete "buy this and flash it" option without having to read the compatibility matrix. ## [0.64.64], 2026-07-02 ### Added - **[`docs/compatibility`](compatibility.md)** now includes a per-vendor **Hardware SKUs** section listing every device manifest in `hardware//*.json`, with panel dims, gamut, protocol, renderer wiring, and a product-page link per row. Vendor sections are ordered Seeed → Pimoroni → TRMNL → Waveshare (Seeed first per the Seeed ecosystem commitment), with any additional vendor directory picked up automatically without a code change. The compatibility page's intro now describes the hardware catalog + the MQTT vs REST split, and points readers at the Hardware SKUs section as the fastest way to find their device. - **`scripts/gen_compatibility.py`** grows a `_hardware_by_vendor` + `_hardware_sections` pair that walks `hardware//*.json` and renders per-vendor markdown tables. `VENDOR_ORDER` and `VENDOR_URL` hoist vendor labels / storefronts to the top of the module for a quick edit if a new vendor lands or a URL changes. ### Fixed - **Panel-preset table's Native resolution column was truncated on labels containing hyphens.** `_panel_table` split on `-`, which ate the resolution when a preset label contained a parenthesised suffix with a hyphen (`(ESP32-S3)` → resolution stranded as `S3), 800x480`). Now splits on the trailing `, ` so `Waveshare 7.3" PhotoPainter (ESP32-S3)` reads cleanly. - **Seeed XIAO EE02 hardware manifest's `url` field pointed at the reTerminal E1004 product page.** Copy-paste error from v0.64.61 (both are 13.3" Spectra 6). Corrected to the EE02's own page at seeedstudio.com. Also visible on the compatibility page's Seeed section as of the same release. ## [0.64.63], 2026-07-02 ### Added - **[`docs/quickstart/seeed-unified.md`](quickstart/seeed-unified.md)**: new unified quickstart for every Seeed ePaper device Tesserae supports via the native `tesserae-device-firmware` build. Covers the reTerminal E1001 / E1002 / E1003 / E1004, the XIAO EE02, and the XIAO ePaper 7.5" mono, all via one web-based flasher at [tesserae.ink/flash](https://tesserae.ink/flash). Includes a panel-vs-firmware-kind matrix, per-device notes on refresh behaviour and known battery reporting gaps, and a troubleshooting table for the common first-boot failures. Featured on the quickstart index above the Waveshare + TRMNL guides. ### Changed - **[`docs/quickstart/seeed-reterminal.md`](quickstart/seeed-reterminal.md)** and **[`docs/quickstart/seeed-xiao.md`](quickstart/seeed-xiao.md)** now open with a "there's a better path now" callout pointing at the unified firmware guide, and are renamed in the nav to `(TRMNL BYOS)` variants. Both pages stay for users who deliberately want to run TRMNL BYOS firmware on Seeed hardware. ## [0.64.62], 2026-07-02 ### Added - **`hardware/seeed/xiao_epaper_75.json`**: Seeed XIAO ESP32-S3 driving a 7.5" 800×480 monochrome ePaper panel (the same panel used in the TRMNL 7.5" OG DIY kit). Routes through the `esp32_bw_client` protocol + `esp32_bw_bin` renderer, producing a byte-identical frame to the Seeed reTerminal E1001 (48000 bytes, MSB=leftmost, bit-set=white). No new renderer needed. `protocol_config.model_header` set to `XIAO_ePaper_75` for firmware auto-provisioning. Coexists with the pre-existing `seeed_xiao_75` (which stays for the TRMNL-BYOS-flashed variant of the same panel). ## [0.64.61], 2026-07-02 ### Added - **`hardware/seeed/ee02.json`**: Seeed XIAO ePaper Driver Board EE02 (ESP32-S3) driving a 13.3" T133A01 6-colour Spectra E6 panel (1200×1600). Routes through the `esp32_client` protocol + `esp32_bin` renderer, producing a byte-identical frame to the Seeed reTerminal E1004 and the Waveshare 13.3E6 targets (960000 bytes, high nibble = left column, palette nibbles 0x0..0x6 with 0x4 / 0x7 reserved). No new renderer needed; the E1004 output can be re-pointed at the EE02 for cross-panel validation. `protocol_config.model_header` set to `Seeed_EE02` for firmware auto-provisioning. ## [0.64.60], 2026-07-02 ### Added - **`hardware/waveshare/photopainter_73.json`**: Waveshare PhotoPainter 7.3" (ESP32-S3, 800×480 6-colour Spectra E6, battery-powered). Routes through the `esp32_client` protocol + `esp32_bin` renderer at 800×480, producing a byte-identical frame to the Seeed reTerminal E1002 (192000 bytes, high nibble = left column, palette nibbles 0x0..0x6 with 0x4 / 0x7 reserved). No new renderer needed; the E1002 output can be re-pointed at the PhotoPainter as a sanity check. `protocol_config.model_header` set to `PhotoPainter_73` for firmware auto-provisioning. Notes flag the physical 180° panel mount inside the case; the firmware rotates on-device so the server keeps rendering in normal top-left orientation. ## [0.64.59], 2026-07-01 ### Fixed - Strip an unused `# type: ignore` comment in the v0.64.58 API error handler that mypy strict flagged in CI. Runtime behaviour unchanged; the annotation was over-cautious. ## [0.64.58], 2026-07-01 ### Fixed - **`/api/v1/device//frame` and `/status` returned 403 for a URL id that didn't correspond to any registered device.** The ``_auth_device`` helper collapsed two failure modes ("id doesn't exist" and "id exists but the token belongs to a different device") behind one 403, which read as an auth failure on what was actually a client-side URL typo. Split them: a URL id that doesn't resolve to a registered instance now returns **404 `no device with id 'foo'`**; the 403 stays for the "valid token, but for a different registered device" case. Device ids are admin-chosen (not attacker-guessable) so the resource-existence signal isn't a meaningful information leak. Reported while wiring a CircuitPython client against the REST API. - **404 / 405 / 500 responses under `/api/v1/device/*` returned HTML instead of JSON.** Firmware clients don't render HTML; a stray route (POST to `/status` with a missing id, POST to `/frame` which is GET-only, etc.) landed on Flask's default HTML error page and left the caller staring at a bytes-of-markup blob. Registered app-level error handlers that check the request path, so any 4xx / 5xx under the `/api/v1/device` prefix returns the same `{status, error}` envelope the hand-written auth failures already use. The admin UI's 404s continue to render HTML. ## [0.64.57], 2026-07-01 ### Added - **`renderers/esp32_gray_bin`**: 4-bpp linear grayscale packed buffer for IT8951-driven greyscale panels, primarily the Seeed reTerminal E1003 (10.3", 1872×1404, 16-level grayscale). Composition PNG in, raw packed .bin out (`width * height / 2` bytes; row-major, top-left origin, non-mirrored; high nibble = LEFT pixel, low nibble = RIGHT pixel; nibble value 0x0 = black, 0xF = white). Same 8-mode dither list + contrast knob as `esp32_bin`; the dither pipeline runs against a 16-entry linear gray palette so Floyd-Steinberg produces smooth photographic gradients on the panel's genuine 16 grays rather than the 1-bit alternation `esp32_bw_bin` would give. - **`app.quantizer.pack_to_panel_bin_4bpp_gray`**: the underlying packer that powers `esp32_gray_bin`. Byte contract asserted in- function (`width % 2 == 0`, image already at target dims, output length exactly `width * height / 2`) and covered by four unit tests in the renderer's smoke suite. ### Changed - **`hardware/seeed/reterminal_e1003.json` now routes through `esp32_client` + `esp32_gray_bin` (native 4-bpp grayscale path) instead of `trmnl_client` + `trmnl_png` (TRMNL BYOS 1-bit PNG path).** The unified `tesserae-device-firmware` ESP-IDF build supersedes the TRMNL BYOS workaround for this panel, unlocking the full 16 grays the E1003's IT8951 controller can render. Panel dims swapped from 1404×1872 portrait to 1872×1404 landscape to match the firmware's native orientation (which handles any physical mount-side flip itself). Same shape as the E1001/E1002/E1004 migrations in v0.64.52 and v0.64.54. ## [0.64.56], 2026-07-01 ### Changed - **`scripts/pi-client-cloud-init.yaml` no longer shells out to `install.sh`.** The client's `install.sh` was written for interactive use and its internal `sudo` calls need a controlling terminal, which cloud-init's `runcmd` can't provide, so the whole install step silently failed on real first-boot. The yaml now does the equivalent work directly: - Apt-installs the runtime deps (python3, python3-venv, python3-dev, build-essential, libopenjp2-7, libtiff6) via cloud-init's `packages:` list. - Enables SPI + I²C via cloud-init's native `rpi.interfaces` block (no `raspi-config nonint` shell-out). - Appends `dtoverlay=spi0-0cs` to `/boot/firmware/config.txt` via a `write_files` entry. - Clones `tesserae-device-pi-bin`, builds the venv, pip-installs the client + `inky[rpi]` extras as the `tesserae` user via `su -c '...' tesserae`. - Symlinks `/usr/local/bin/tesserae-pi-bin-client` and installs the systemd unit inline (same `[Unit]/[Service]/[Install]` shape the client's `install-service.sh` used to write). - **Per-flash settings moved from `user-data` edits to `meta-data` variables** using cloud-init's jinja2 template preprocessor (`## template: jinja` header). Both `server_url` and `panel model` now come out of `meta-data` via `{{ ds.meta_data.server_url }}` and `{{ ds.meta_data.model }}` references. Per-flash workflow becomes: copy the yaml unchanged, edit two lines of meta-data, boot. - **`docs/quickstart/pi-inky-cloud-init.md`** updated to reflect the meta-data flow and to add a troubleshooting row for the "literal `{{ ds.meta_data.server_url }}` in config.toml" mode (missing jinja header). ## [0.64.55], 2026-07-01 ### Fixed - **`scripts/pi-client-cloud-init.yaml` left `/home/tesserae` owned by root.** cloud-init runs `write_files` before `users` when the home directory is created in the same run, so the pre-written `.config/tesserae-pi-bin-client/config.toml` (and its parent directories) landed owned by root. The `chown` in `runcmd` only covered the `.config` leaf, missing the intermediate parent dirs. Chowning `/home/tesserae` from the top level catches everything `write_files` planted plus anything the subsequent user-creation step populates. Reported and traced against a real first-boot on Pi OS Lite. ## [0.64.54], 2026-07-01 ### Changed - **`hardware/seeed/reterminal_e1001.json` now routes through `esp32_bw_client` + `esp32_bw_bin` (native mono path) instead of `trmnl_client` + `trmnl_png` (TRMNL BYOS path).** Same pattern as the E1002/E1004 migration in v0.64.52. The unified `tesserae-device-firmware` ESP-IDF build streams frame bytes straight to the panel framebuffer without any on-device image decode, so it needs the raw 1-bpp packed buffer (48000 bytes for 800×480, MSB=leftmost pixel, bit-set=white) that `esp32_bw_bin` already produces byte-for-byte. Users who intentionally flashed TRMNL BYOS firmware on an E1001 can add a custom hardware manifest that pins the `trmnl_client` protocol. ## [0.64.53], 2026-07-01 ### Fixed - **Dockerfile was missing `COPY hardware/`**, so Docker + Home Assistant App installs shipped the code that reads the hardware catalog but not the catalog files themselves. Effect: every hardware-catalog-defined kind (all the vendor SKUs under `hardware//.json`) was invisible in the Add Device kind dropdown, and `/api/v1/device/discover` rejected every matching firmware `kind` string with "unknown device kind '...'". Native git-clone installs weren't affected because they inherit the whole source tree. The fix is a one-line addition to the Dockerfile; rebuild the image / bump the HA App to pick it up. ## [0.64.52], 2026-07-01 ### Added - **`hardware/waveshare/waveshare_133e6.json`**: Waveshare 13.3" Spectra E6 (ESP32-S3-WROOM-2-N32R16V), 1200×1600 portrait, dual-SPI panel, routed through `esp32_client` + `esp32_bin`. Pairs with the `tesserae-device-firmware` ESP-IDF unified firmware build's `boards/waveshare_133e6.h` target. - **`hardware/seeed/reterminal_e1004.json`**: Seeed reTerminal E1004, 13.3" 6-colour Spectra 6 (1200×1600 portrait) on an ESP32-S3. Routes through `esp32_client` + `esp32_bin`. Pairs with the unified firmware's `boards/seeed_reterminal_e1004.h` target. ### Changed - **`hardware/seeed/reterminal_e1002.json` now routes through `esp32_client` + `esp32_bin` (native path) instead of `trmnl_client` + `trmnl_png_color` (TRMNL BYOS path).** The E1002 is now a Tesserae-native-firmware SKU by default; the unified `tesserae-device-firmware` ESP-IDF build supersedes the TRMNL BYOS-flashed workaround for this panel. Anyone who intentionally flashed TRMNL BYOS firmware on an E1002 can add a custom hardware manifest that pins `renderers: ["trmnl_png_color"]` on the `trmnl_client` protocol; the `trmnl_png_color` renderer stays in the tree for that case and for future colour TRMNL BYOS panels. Palette nibble mapping in `esp32_bin` matches the unified firmware's `EPD_COL_*` constants byte-for-byte (black=0x0, white=0x1, yellow=0x2, red=0x3, blue=0x5, green=0x6; 0x4 and 0x7 reserved), so a frame packed by `esp32_bin` paints without any on-device translation. ## [0.64.51], 2026-07-01 ### Fixed - **Pimoroni Inky Impression 4" hardware manifest carried the wrong panel dimensions.** The current shipping SKU (PIM789) is 600×400 Spectra 6; the manifest declared 640×400. Corrected in `hardware/pimoroni/inky_impression_4.json`. - **Pi-client cloud-init's `[mqtt]` section read as required config even for REST setups.** Rewrote the comments so the block is clearly flagged as "only used when transport_mode = 'mqtt'; ignored for the default REST setup" and each field's purpose is spelled out inline. No functional change, the block was already inert for REST users; the yaml just didn't say so plainly. ### Added - **`hardware/pimoroni/inky_impression_4_acep.json`**: legacy 4" Inky Impression variant at 640×400 with the 7-colour ACeP gamut. Pre-dates the Spectra 6 refresh Pimoroni now ships under the same product name; user-owned examples still exist in the wild. Description explicitly points users at `pimoroni_inky_4` for the current Spectra 6 SKU so the two don't get confused during pairing. ## [0.64.50], 2026-07-01 ### Added - **Debug & diagnostics section on every device card (Settings → Devices → device).** Collapsed by default; expand to see the Tesserae version the server is running, the resolved device kind, the renderer clone ids the push pipeline will use, the panel block (dims + gamut + orientation + underscan), transport + MQTT topic bindings (when applicable), the on-disk instance file path, and the raw instance JSON with secrets (access_token, `_secret`-suffixed keys) masked to `***` so the block is safe to screenshot for a support thread. Answers the recurring "is the panel actually rendering with the renderer I think it is" question in one glance, and lets an operator confirm a prod server is on the release that ships a specific fix. ## [0.64.49], 2026-07-01 ### Added - **`renderers/trmnl_png_color`**: colour-panel renderer for TRMNL BYOS devices. Composition PNG in, indexed PNG out with a gamut-selected palette (Waveshare E6 for Spectra 6, Inky 7-colour for ACeP, 1-bit for mono, Waveshare E6 fallback for unlabelled colour panels). Ships on the same `tesserae/{device}/frame/trmnl` topic pattern as the mono `trmnl_png` renderer, so devices route through the same /api/display path with only the output format differing per SKU. Server-side Floyd-Steinberg against the panel's palette produces the smoothest gradients the target firmware can render (verified against `usetrmnl/trmnl-firmware` E1002_fix branch's `png_draw_6clr` / `GetSpectraPixel` decoders). - **Per-hardware `renderers` override in the hardware catalog schema.** Hardware entries under `hardware//.json` can now declare `"renderers": ["..."]` to replace the protocol's default renderer list, used when the same wire protocol drives panels with meaningfully different output formats. `_derive_manifest` picks up the override at kind-registration time; the common case (SKU inherits from protocol) is unchanged. ### Changed - **Seeed reTerminal E1002** (`hardware/seeed/reterminal_e1002.json`) now routes through `trmnl_png_color` instead of the default 1-bit `trmnl_png`. Panel gamut relabelled from `acep_7colour` to `spectra_6` to match what the TRMNL firmware for that board actually targets. Colour output is untested on real hardware pending device arrival; the plumbing is verified end-to-end via the renderer + hardware catalog test suites. ## [0.64.48], 2026-07-01 ### Fixed - **Per-device battery offset (v0.64.47) only reached the `/devices/battery` dashboard and the chart; the topbar battery indicator, the popover detail list, the bundled `device_battery` widget, and the Home Assistant discovery sensors kept showing the raw firmware-reported value.** Each of those is its own read site for `parsed.battery_pct` / `parsed.battery_mv` from the `DEVICE_STATUS` cache; the offset only landed on two of them. Now applied consistently across all five so a saved offset reflects everywhere a human (or a Home Assistant automation) sees the battery for that device. ## [0.64.47], 2026-07-01 ### Added - **Per-device battery display offset (Settings → Devices → device card).** Two signed integers, mV and percent, applied at read time to align the displayed battery with a voltmeter measurement. Voltage offset re-derives the percent from a LiPo curve so a calibrated 4.20 V cell that the device reports as 3.85 V now reads as 100% on the dashboard. The percent offset adds on top for the band-aid "UI says 85, voltmeter says 100, add 15" case. Both at zero drops the override entirely. Raw firmware readings stay untouched in the SQLite history store so a recalibration tomorrow doesn't lose the historical record; only the displayed values (dashboard, /devices/battery chart, HA discovery sensors) shift. - **Time-to-fully-charged projection on /devices/battery cards.** When the latest samples show sustained charging (positive slope on the trailing monotonic segment, above the existing magnitude threshold), the empty-projection tile flips to "Full in Xh / X days". Gated by the same sustained-segment regression that drives the days-to-empty projection, so transient ADC wobble doesn't toggle the indicator. The `Prediction` dataclass gains a `days_to_full: float | None` field; downstream consumers can opt in to the new value. - **"Clear battery history" button per device on /devices/battery.** Wipes every recorded sample for one device only, leaving other devices' histories alone. Gated by a JS confirm so an accidental click can't drop the data. Useful after a recalibration, a battery swap, or a device factory-reset where the historical curve no longer reflects the current cell. ### Changed - `app.device_loader.load_instance_file` now carries through the optional `battery_offset` block from a device instance manifest (alongside the existing `quiet_hours` carry-through), so the per-device override survives the hot-reload that fires when any device-card subsection is saved. ## [0.64.46], 2026-07-01 ### Fixed - **Settings → Devices Dismiss flashed a misleading "broker offline" error in REST-only installs.** The dismiss handler always tried to publish an empty retained payload to clear an MQTT-side heartbeat; in a REST-only setup the transport isn't connected, so the publish raised and surfaced as an error even though the in-memory dismiss had succeeded cleanly. The handler now guards on `transport().connected` and only attempts (and surfaces) the retained-clear step when a broker is actually in play. (#38) - **`circuitpython_generic` is now a real device kind.** The client protocol docs cited `kind: "circuitpython_generic"` in the discover / register examples but no matching plugin shipped, so a CircuitPython firmware following the spec literally got rejected at registration. Ships a new `devices/circuitpython_generic/` kind (pairs with the existing `circuitpython_png` renderer; v1 REST `parse_status` for the standard battery / rssi / ip / sleep fields with stringy-number coercion; configurable sleep cadence). Docs gained a note that `kind` values are server-side plugin ids and that board-specific kinds can ship alongside the generic catch-all. (#39) - **Settings → System Updates card showed a stale version from the latest published GitHub Release.** The card hit `/releases/latest`, which only knows about published Releases; if tags get pushed faster than Releases get cut (the new weekly Release cadence introduces exactly this gap), the card surfaced an older version than the footer. The card now reads `/tags` newest-first as the canonical version source, with the Release page URL constructed from the tag so GitHub auto-redirects to the published Release view when one exists. One API call, single source of truth, matches the footer. (#40) ### Changed - Releases will now be cut on a weekly cadence (Friday afternoon Melbourne) rather than per-tag. Tags continue to be pushed for every change so the in-app updater and `pip install -e .` users still pull the freshest code; Releases batch the week's changes into a curated notes view. ## [0.64.45], 2026-06-30 ### Added - **`scripts/pi-client-cloud-init.yaml`**: zero-touch install for a Raspberry Pi driving a Pimoroni Inky panel. Flash Raspberry Pi OS Lite via Pi Imager, drop the yaml into the boot partition as `user-data`, edit the Tesserae server URL and Inky panel model, insert + power on. cloud-init creates a `tesserae` user with `gpio` + `spi` group membership, pre-writes the client config, clones `tesserae-device-pi-bin`, runs its `install.sh --non-interactive` (apt deps + raspi-config SPI/I2C + dtoverlay=spi0-0cs + venv + pip install + systemd unit), and reboots so SPI / I2C take effect. After the reboot the daemon starts on its own and announces to the Tesserae server's Discovered strip for one-click registration. - **[`docs/quickstart/pi-inky-cloud-init.md`](quickstart/pi-inky-cloud-init.md)**: step-by-step walkthrough of the cloud-init path, including the Pi Imager flow, the two values to edit before flashing, the pairing flow on the Tesserae server side, and a troubleshooting table for the usual first-boot failure modes (I2C didn't enable, spi0-0cs overlay missing, server URL unreachable). - **[`docs/quickstart/pi-inky.md`](quickstart/pi-inky.md)** gains a callout linking to the cloud-init path for fresh SD cards. - **[`mkdocs.yml`](mkdocs.yml)** Hardware quickstart nav extended with the cloud-init sibling between the manual Pi + Inky entry and the Pi Pico variant. ## [0.64.44], 2026-06-30 ### Added - **`renderers/circuitpython_png/`**: new server-side renderer that emits a palette-quantized indexed PNG at the panel's exact dims. Targets CircuitPython clients on memory-constrained microcontrollers (Pico-W, ESP32-S3 / -C3 / -C6, nRF52840) where the nibble-packed `.bin` format isn't viable: there's no general-purpose decoder for it in the CircuitPython ecosystem, and the packed buffer plus a decode scratch buffer would exhaust SRAM on a Pico-W. Output is a true indexed PNG (mode "P") that `adafruit_imageload` mounts directly with minimal RAM. Palette is selected from the bound panel's gamut: `mono` -> 1-bit black/white, `spectra_6` / `waveshare_e6` -> 6-colour Spectra 6, `acep_7colour` / `inky_7colour` -> 7-colour ACeP, anything else falls back to Spectra 6 nominal. Same per-device picture-quality knobs as `trmnl_png` (dither mode + pre-dither contrast). Smoke tests cover the indexed-mode invariant, the per-gamut palette membership, and the flip path. Backlog item closed: [#34](https://github.com/dmellok/tesserae/issues/34). ## [0.64.43], 2026-06-29 ### Changed - **Marketplace Browse grid now uses `grid-auto-rows: 1fr`** so cards in the same row share the tallest card's height. v0.64.42 capped the folder chip list at 3 + "+N more", which fixed the most egregious case (a 5-folder bundle wrapping to 3 rows), but description-length variation and the "+N more" tail still wrapping to a second chip row meant a small height differential remained. Equal-height rows close that gap entirely; the action footer already has `margin-top: auto` so it floats to the bottom of any card grown to match its taller neighbour. Same pattern most app- store catalogs use. ## [0.64.42], 2026-06-29 ### Changed - **Marketplace Browse cards now cap the bundled-folder list at 3 with a "+N more" tail** instead of rendering every folder name inline. Bundles with many folders (e.g. AFL Bundle at 5, AI Brief at 4) were pushing their cards visibly taller than single-folder neighbours in the grid, since the chips wrapped onto extra rows while CSS Grid only equalises width, not height. Truncated chips carry a `title=` tooltip listing the hidden folder names so power users can still inspect the full bundle without clicking through to the source repo. Tail rendered as a muted italic chip; the three visible folder names still cover the bundle's identity at a glance. ## [0.64.41], 2026-06-29 ### Added - **[`docs/quickstart/seeed-reterminal.md`](quickstart/seeed-reterminal.md) gains a Per-model references section** under the model header table, with two links: - **E1002**: the [colour-for-calendars discussion on r/trmnl](https://www.reddit.com/r/trmnl/comments/1ucr8b2/color_for_calendars_is_here/), covering practical layout patterns for the ACeP 7-colour gamut. - **E1004**: the upstream firmware PR chain. Original bring-up in [usetrmnl/trmnl-firmware#410](https://github.com/usetrmnl/trmnl-firmware/pull/410) (now closed); active PR is [usetrmnl/trmnl-firmware#445](https://github.com/usetrmnl/trmnl-firmware/pull/445), which rebases that work onto `main` with build hardening and adds onboard SHT4x temperature / humidity reporting through the existing `SENSORS` header. ## [0.64.40], 2026-06-29 ### Added - **Per-hardware quickstart guides under [`docs/quickstart/`](quickstart/index.md).** Eight family-grouped pages mirroring the [tesserae.ink](https://tesserae.ink) quickstart format (four numbered steps: install / pair / compose / schedule), one per supported hardware family: - Raspberry Pi + Pimoroni Inky Impression. - Pi Pico Plus 2 W + Inky 13.3". - Waveshare 13.3" Spectra 6 (ESP32-S3). - Waveshare 7.3" PhotoPainter (ESP32-S3). - Waveshare 4.2" B/W (ESP32). - TRMNL OG / TRMNL X (stock firmware, no flashing). - Seeed reTerminal E-Series (E1001 / E1002 / E1003 / E1004). - Seeed XIAO 7.5" ePaper Panel. - Kindle Paperwhite + KOReader. Plus a [Quickstart overview](quickstart/index.md) that groups the guides by hardware family. README and `mkdocs.yml` updated to surface the new section. The existing `docs/install/clients.md` reference page stays as the protocol-level depth doc; quickstarts are the consumer-oriented entry point. ## [0.64.39], 2026-06-28 ### Fixed - **`hardware/seeed/reterminal_e1003.json` no longer claims the device ships speaking TRMNL BYOS out of the box.** The reTerminal E1003 ships with Seeed's own ESP32-S3 UI; the BYOS path requires reflashing with TRMNL firmware first, which is what the README has always said but the manifest's `description` and `notes_md` previously contradicted. The worked example in [`docs/dev/adding-hardware.md`](dev/adding-hardware.md) inherits the fix since it inlines the same JSON. ## [0.64.38], 2026-06-28 ### Added - **Six hardware catalog manifests for previously-undeclared SKUs.** Each routes to an existing protocol so no Python changes are required; the Settings UI's kind picker now offers the SKU under its vendor with the correct panel preselected. - `hardware/seeed/reterminal_e1001.json` (7.5" mono, 800x480), protocol `trmnl_client`, model header `reTerminal E1001`. - `hardware/seeed/reterminal_e1002.json` (7.3" ACeP 7-colour, 800x480), protocol `trmnl_client`, model header `reTerminal E1002`. - `hardware/seeed/xiao_75.json` (XIAO 7.5" mono, 800x480), protocol `trmnl_client`. - `hardware/pimoroni/inky_impression_4.json` (640x400 Spectra 6), protocol `pi_bin_client`. - `hardware/waveshare/wave42_bw.json` (4.2" B/W, 400x300), protocol `esp32_bw_client`. - `hardware/trmnl/x.json` (10.3" mono, 1872x1404), protocol `trmnl_client`, model header `x`. None are flagged as verified on real hardware yet; each manifest's `notes_md` says so explicitly. The intent is to remove the "drop a JSON" step gating first-touch testing so a tester with the hardware can pair and report status without a code change. ## [0.64.37], 2026-06-27 ### Fixed - **LXC docs and cloud-init.yaml now use `images:debian/trixie/cloud`** on the cloud-init launch command. The slim `images:debian/trixie` image used in the manual-setup section does not include cloud-init, so the user-data block was silently ignored. The /cloud variant ships with cloud-init pre-installed and is the right pick for any automated provisioning. Manual-setup instructions keep the slim image since cloud-init is not involved there. - **Hardware sizing note in `docs/install/lxc.md`** updated to mark 2GB as confirmed (about 1GB headroom after install) rather than testing pending. Confirmed by a community installer on a CM4 2GB. - **Added a troubleshooting tip** for MicroCloud setups where `microcloud init` does not auto-detect NVMe / dedicated storage, documenting the `--storage ` flag on `lxc launch` plus the `lxc storage list` lookup. ## [0.64.36], 2026-06-27 ### Fixed - **weather_forecast / weather_hourly label stayed stale on cache hit.** The v0.64.16 fix overlaid the user-edited ``label`` onto the cached blob before returning, but missed the duplicate ``place`` field that the newer variants paint. A location rename on the same ``(lat, lon, units[, hours])`` cache key updated ``label`` but the variant template still showed the stale ``place``, so the rename appeared not to stick until the user toggled units or picked a new city (which changed the cache key). Now both fields are overlaid together. - **news_rss and news_wikipedia_otd cache key was incomplete.** ``max_items`` was used to slice the result but wasn't part of the cache key, so changing it in the editor served the prior fetch's slice for up to the 10-minute TTL. Added ``max_items`` to the cache filename so a size change refetches at the new size. ## [0.64.35], 2026-06-27 ### Changed - **README's inline "Adding a new SKU" section removed** in favour of the dedicated [docs/dev/adding-hardware.md](dev/adding-hardware.md) page added in 0.64.33. The contribution path stays discoverable via a new link in the Full Documentation callout at the top of the README. ## [0.64.34], 2026-06-27 ### Fixed - **[`docs/widgets/gallery.md`](widgets/gallery.md)** regenerated, was stale at 30 widget cards while the actual bundled set is 33. Re-ran `scripts/gen_widget_gallery.py` to refresh the page from the live plugin manifests. - **[`docs/credits.md`](credits.md)** bundled-fonts table missing three OFL entries: Archivo Narrow, Bodoni Moda, and Jost. All three live under `plugins/fonts_core/static/` and ship with the host. Now listed with their designers. ## [0.64.33], 2026-06-27 ### Changed - **README cut from 379 to 138 lines.** Hardware lineup tables now carry per-row Client + Status columns with vendor product page links, covering the Seeed reTerminal E Series, TRMNL OG / X, the TRMNL 7.5" OG DIY Kit, the XIAO 7.5" ePaper panel, the Pimoroni Inky Impression lineup, and the Waveshare ESP32 panels. Feature lists, install variants, and detailed compatibility tables moved to the docs site. - **[`docs/install/clients.md`](install/clients.md)** adds REST in the Transport column for the clients that support it (`pi-png`, `photopainter-7.3-bin`), drops the dead `tesserae-trmnl-client` reference for direct links to TRMNL's firmware repo and KOReader's `trmnl-display` plugin, and adds the missing `tesserae-device-pico-bin` row. ### Added - **[`docs/dev/adding-hardware.md`](dev/adding-hardware.md)** walks contributors through the hardware-catalog schema with the bundled reTerminal E1003 entry as a worked example. Covers the two-tier protocol-plus-SKU model, when to use the catalog vs writing a new protocol folder, the schema reference, how catalog kinds register alongside folder kinds, backwards-compat rules (folder wins on conflict, `deprecated_aliases`, orphan handling), and the per-protocol `protocol_config` notes. ### Fixed - **`hardware/seeed/reterminal_e1003.json`** product URL corrected to the actual Seeed Studio listing (`p-6731`, was `p-6533`). ## [0.64.32], 2026-06-27 ### Added - **Hardware catalog: data-only SKU definitions.** Adding an e-paper SKU that uses one of the existing protocol-level device kinds (TRMNL BYOS, MQTT bin, MQTT PNG, REST pull) no longer requires forking a ``devices//`` folder. Drop a JSON file under ``hardware//.json`` declaring the panel block, vendor metadata, and any protocol-specific defaults; the loader walks ``hardware/`` after the folder-based discovery and registers each SKU as a kind that borrows the protocol's parse/validate hooks but carries its own manifest. Folder-defined kinds always win on id conflict so existing installs see no behaviour change. Schema lives at ``schema/hardware.schema.json``; the catalog supports a ``protocol_config`` free-form block (each protocol decides what's valid there), a ``config_schema_extends`` additive merge over the protocol's config form, and ``deprecated_aliases`` so a renamed kind can keep resolving for legacy device-instance files. ## [0.64.31], 2026-06-26 ### Added - **Duplicate dashboard.** A copy button on the dashboards list and in the page editor header clones the current dashboard (cells, theme, style, font, device bindings, panel override, per-cell options) under ``" copy"``, falling back to ``" copy 2"`` / ``" copy 3"`` on name collision. Page id and every cell id are regenerated so the copy is fully independent of the source, useful for iterating on a layout without risk to a dashboard already bound to a panel. Per [discussions#6](https://github.com/dmellok/tesserae/discussions/6). ## [0.64.30], 2026-06-26 ### Added - **[`docs/install/lxc.md`](install/lxc.md)** documents installing Tesserae inside an LXC container, covering Proxmox VE for x86 homelab hosts and Ubuntu MicroCloud for arm64 Raspberry Pi. Lists the package set, user setup, and the data directory layout, and notes the LAN-only auth model with a VPN-for-remote-access pointer. - **[`scripts/cloud-init.yaml`](https://github.com/dmellok/tesserae/blob/main/scripts/cloud-init.yaml)** automates the same install via cloud-init. Drops in as the `user-data` field for MicroCloud / Proxmox / any cloud-init-aware image. Installs the venv, downloads Playwright's bundled Chromium so webpage widgets work out of the box, and registers the systemd unit. ## [0.64.29], 2026-06-25 ### Changed - **[``docs/dev/openapi.md``](dev/openapi.md)** gains an install block showing ``pip install "openapi-generator-cli[jdk4py]"`` for users who don't have a system JDK on PATH (the generator itself is a Java program; ``jdk4py`` is a pip extra that bundles a JDK runtime). Also adds a short note flagging that openapi-generator's output is heavyweight by design and a poor fit for memory-constrained MCU targets, where a hand-written minimal client against the spec is the better path. ## [0.64.28], 2026-06-25 ### Added - `pillow-heif` added to the host dependencies. Registers a HEIF decoder with Pillow at import time so any picture-family widget can transcode iPhone HEIC originals to JPEG on the proxy path. Wheel-bundled `libheif` on every tier-1 platform, no system package step required. ## [0.64.27], 2026-06-25 ### Added - **Settings → About**, a new tab carrying the install's project meta (version, license, source / docs / discussions links), a prompt to fill out a six-question Tally survey on how people use Tesserae (since v0.64.22 ripped phone-home telemetry, asking is the only signal channel left), and a "Support the project" card linking to GitHub Sponsors. Survey is anonymous, optional per question. Three cards, no forms, no manifest sections; the route passes the version and the two outbound URLs in. ### Removed - **"Test broker connection" diagnostics card on Settings → Server.** The card was rarely used now that REST is the default transport, and sitting next to the much taller Session card it produced a visible height mismatch at the bottom of the page. The underlying ``auth.diagnostics_test_broker`` route is left intact (no broken bookmarks); the UI surface is gone. ## [0.64.26], 2026-06-25 ### Changed - **README badges restyled to ``for-the-badge`` with Phosphor icons.** Swapped from ``flat-square`` plain badges to the bolder ``for-the-badge`` style, with each badge's logo loaded inline as a base64-encoded Phosphor regular SVG (``scales`` for license, ``tag`` for the latest release, ``check-circle`` for CI, ``chats-circle`` for Discussions, ``cloud-arrow-up`` for the Codespaces launcher, ``heart`` for Sponsor). Keeps the brand consistent with the rest of the project (Tesserae's UI already uses Phosphor end-to-end via the bundled woff2 fonts under [``static/icons/phosphor/``](static/icons/phosphor/)). ## [0.64.25], 2026-06-25 ### Fixed - **One stale encrypted secret no longer cascade-fails the entire ``get_section()`` call.** Follow-up to [#29](https://github.com/dmellok/tesserae/issues/29): RealGandy tried the v0.64.24 workaround (delete the wrapped HA token from ``data/core/settings.json``, restart, re-paste) and still got the same ``AES-GCM authentication failed`` error. The cause was that another plugin's older ``*_secret`` value (likely ``spotify``, ``github``, ``marketplace``, or similar, anything configured before the key changed) was still wrapped under the pre-rotation key. ``_unwrap_tree`` walked the plugins section in dict-iteration order and bailed out the moment it hit the first failing decryption, before it ever reached the freshly-rewrapped HA token. So re-entering one plugin's secret appeared to do nothing. ``_unwrap_tree`` is now tolerant of per-value failures: each bad value is logged + replaced with an empty string, the rest of the section walk continues. Plugins downstream observe the empty string the same way they'd observe "never set" and fall through to their own "not configured" sentinels (the v0.64.24 "re-enter the secret" entity-picker banner, the plugin's own ``is_configured()`` short-circuit, etc.). One broken secret can't take out the rest of the section any more. The manifest-aware ``get_for_runtime`` path stays strict so a caller asking for a specific token still gets a loud error rather than an empty string and a downstream 401. Net effect for someone in RealGandy's spot: they can delete every ``*_secret`` value in ``plugins.*`` (or just the ones for plugins they care about) in one pass and re-enter them as they need to, without the cascade making each individual re-entry look like it did nothing. ## [0.64.24], 2026-06-25 ### Fixed - **Editor entity picker now surfaces a re-enter banner when a plugin's stored secret can't be decrypted, instead of silently rendering an empty dropdown.** Reported by RealGandy in [#29](https://github.com/dmellok/tesserae/issues/29) against a Docker compose install where the HA long-lived token had been added under one ``TESSERAE_SECRET_KEY`` (or session-derived key) and was being read under another. ``_materialize_cell_options`` in [``app/page_routes.py``](app/page_routes.py) had a catch-all that swallowed every exception from ``plugin.choices(...)`` and rendered ``[]``, which surfaced as "no entities to select" with zero indication of the cause. ``SecretBoxError`` is now caught specifically and the picker shows a single sentinel choice with the label ``"Stored secret for can't be decrypted, re-enter it under Settings → Plugins → "``. Other exception types still fall through to the empty-list treatment so a real network timeout or upstream outage doesn't mis-blame decryption. Workaround for any user hitting this on their existing data: stop the container, delete the ``plugins..token_secret`` (or other ``_secret``-suffixed) key from ``data/core/settings.json``, restart, and re-paste the secret in Settings → Plugins. The re-wrap uses the current key so decryption succeeds on every subsequent run. The root cause (which key resolution diverged across runs) is documented in the comment thread on issue #29. ## [0.64.23], 2026-06-24 ### Fixed - **Weather widget location reset on any reload-on-change save.** Changing an unrelated cell's plugin / panel preset / theme on the page editor triggers a save-all loop that POSTs every cell's form before reloading the page. Cells using the ``location_search`` field would come back blank from that reload even though the user hadn't touched them. Root cause was in [``templates/_components.html:228``](templates/_components.html#L228): the ``location_search_field`` macro rendered the saved location via ``value | tojson``. ``tojson`` marks its output safe (it's intended for ``