Add the dashboard settings channel, wired for theme and lock

A dashboard could only ever receive as a set of tiles. This adds the dashboard
itself as a receiver: `settings` maps a name to a value plus an optional
binding. Unbound, the setting is simply its value — a wall panel that is always
dark costs no flow. Bound, a flow drives it live and the value is the fallback.

Two settings are wired: `theme` (system/light/dark) and `locked` (read-only).
There is no schedule field on purpose — a node publishing to the bound message
on a cron is what a schedule is here, which is the point of a channel.

- `messages_for()` now walks a dashboard's bound settings as well as its
  widgets' bindings. Without this a paired screen is refused its own theme
  message, on the one surface the setting exists for; it bounds the socket too.
- `locked` is gated in `usePublish`, so every control inherits it, and each
  control also draws itself disabled — a dead button reads as broken otherwise.
  The panel surface says Read-only in the corner.
- The theme is a class on the dashboard's own surface, never the root: inside
  the app shell it must not flip the chrome. `.light` gains the tokens `.dark`
  already had (mirrored in the index repo) so both directions work on a subtree.
- Settings bindings are type-checked from the document alone, the rule widget
  bindings follow, and mirrored on the server.
- A bound setting is drawn on the flow canvas as a dashboard-level endpoint.
- The demo's house flow now publishes `home.panel_theme`, which the demo
  dashboard's theme binds to: the panel goes dark after sunset, at no tile cost.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tULRZJUkZsw7rMJ3h4xvu
This commit is contained in:
2026-08-22 13:17:56 +02:00
co-authored by Claude Opus 5
parent 2f0e50fc9f
commit 092a5333e8
19 changed files with 803 additions and 62 deletions
+19
View File
@@ -146,6 +146,11 @@ def process(tick, mode="comfort", away=False, setpoint=21.0):
"indoor": round(indoor, 2),
"outdoor": round(outdoor, 2),
"humidity": round(humidity, 1),
# Not a reading: what the panel on the wall should wear. The dashboard
# binds its `theme` setting to this, which is what "switch the panel at
# sunset" looks like here — a flow publishing, not a rule in the
# dashboard.
"panel_theme": "dark" if hour < 7.0 or hour >= 20.0 else "light",
}
'''
@@ -331,6 +336,8 @@ HOUSE_NODES = [
# downstream of it is paced by this rather than by the tick.
{"name": "outdoor", "dtype": "float", "interval": 60},
{"name": "humidity", "dtype": "float"},
# Twice a day at most, so it is paced rather than sent every tick.
{"name": "panel_theme", "dtype": "str", "interval": 60},
],
},
{
@@ -1214,6 +1221,18 @@ def seed_dashboard(api: Api) -> None:
"columns": COLUMNS,
"canvas_width": CANVAS[0],
"canvas_height": CANVAS[1],
# The settings channel: the dashboard itself as a receiver. Costs
# no tile, which is why it fits a panel with all 17 rows spoken
# for. `system` is the fallback until the flow first says
# otherwise; `locked` is left unset, which is the static default
# and keeps every control on the demo usable.
"settings": {
"theme": {
"value": "system",
"message": msg(HOUSE, "panel_theme"),
"dtype": "str",
}
},
"pages": PAGES,
},
)