github dmellok/tesserae v0.152.0

latest release: mcp-v0.8.0
2 days ago

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/<id>/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/<id>/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:<id>, rotate_next, webhook:<url>, …), 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/<id>.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/<id> 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/<id>/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/<id>, 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
    client, a community firmware by @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 <img> 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/<key>/data (probe) and GET /widgets/<key>/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/<id>/elements/<eid>/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
    (<i class="ph-bold ph-heart">, 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 <canvas> 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.<name>; 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.<name>, 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.<name>.<path> 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/<id>/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/<id>/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 <id> --out <dir> --lg --extra <presets.json> 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.

  • MCP: less friction authoring widgets via Studio. Three additions to the
    /api/mcp surface: GET /widgets/<id>/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/<id>/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/<id> 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/<id>,
    POST /api/mcp/reload, GET /api/mcp/widgets?origin=authored, and a faithful
    GET /api/mcp/widgets/<id>/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 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/<id>/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).

Don't miss a new tesserae release

NewReleases is sending notifications on new releases.