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 3e7b161950
commit d958d7cde6
18 changed files with 784 additions and 62 deletions
+87
View File
@@ -96,6 +96,42 @@ WIDGET_DTYPES: dict[str, set[str]] = {
}
#: What a dashboard-wide setting may be bound to, by payload type. The channel
#: is general — a setting is a value plus an optional binding — but the wired
#: ones are a closed set, and a name missing here is simply a setting this
#: build does not act on. Mirrored in the client
#: (``frontend/src/components/Dashboard/settings.tsx``).
SETTING_DTYPES: dict[str, str] = {
# "system" | "light" | "dark". A panel in a room has no way to set the
# device preference the app otherwise inherits.
"theme": "str",
# Read-only: the input widgets stop publishing.
"locked": "bool",
}
class SettingDef(BaseModel):
"""One dashboard-wide setting: a value, and optionally where it comes from.
Unbound — no ``message`` — the setting is simply ``value``, which is what
makes a panel that is always dark cost no flow at all. Bound, a flow drives
it live and ``value`` is the fallback: what the dashboard uses until
something arrives, and whenever the message is silent.
A schedule is not a third case. A node publishing to the bound message on a
cron *is* the schedule, which is the whole reason this is a channel rather
than a switching rule per setting.
"""
value: Any = None
#: The message that drives it, or empty for a setting that is just a value.
message: str = ""
#: The payload type the editor recorded when it bound that message, so the
#: pairing can be judged from the document alone — the rule widget bindings
#: are held to.
dtype: str = ""
class Placement(BaseModel):
"""Where a widget sits in its section's grid, in grid units."""
@@ -311,6 +347,11 @@ class DashboardDef(BaseModel):
#: letters of the title.
icon: str = ""
pages: list[PageDef] = Field(default_factory=list)
#: Settings the whole dashboard carries, by name — see ``SettingDef``. The
#: one channel a dashboard consumes as a dashboard rather than as a set of
#: tiles, so a screen on a wall can be told things nobody standing at it
#: could set.
settings: dict[str, SettingDef] = Field(default_factory=dict)
#: Bumped on every save; a save based on an older one is refused.
version: int = 1
#: Whether there are unpublished changes. Reported by the store on read,
@@ -322,10 +363,35 @@ class DashboardDef(BaseModel):
def _check_name(cls, value: str) -> str:
return _validate_name(value)
@model_validator(mode="after")
def _check_settings(self) -> DashboardDef:
"""Refuse a setting driven by a message it cannot carry.
Judged from the document alone, exactly as a widget's binding is: the
picker records the payload type beside the name, so neither the editor
nor a wall panel has to fetch the catalogue to know the wiring is
wrong. A name this build does not know is left alone rather than
refused — an older installation reading a newer document simply does
not act on it.
"""
for name, setting in self.settings.items():
want = SETTING_DTYPES.get(name)
if want and setting.dtype and setting.dtype != want:
raise ValueError(
f"the '{name}' setting needs a '{want}' message, "
f"not a '{setting.dtype}'"
)
return self
@property
def widgets(self) -> list[WidgetDef]:
return [w for p in self.pages for s in p.sections for w in s.widgets]
@property
def setting_messages(self) -> list[str]:
"""Every message a bound setting reads. Empty for a static dashboard."""
return [s.message for s in self.settings.values() if s.message]
class DashboardSummary(BaseModel):
"""A dashboard in a list, without its contents."""
@@ -544,6 +610,25 @@ class DashboardStore:
defn = DashboardDef.model_validate_json(path.read_text())
except Exception:
continue
# A bound setting is a consumer too — the dashboard itself reading
# a message rather than any tile on it — so the canvas accounts for
# it the same way. ``widget`` is what the endpoint id is built
# from, and no widget id can collide with it: a dot is not a legal
# name character.
for name, setting in defn.settings.items():
if not setting.message.startswith(prefix):
continue
found.append(
{
"dashboard": defn.name,
"dashboard_title": defn.title or defn.name,
"widget": f"settings.{name}",
"title": f"{defn.title or defn.name} {name}",
"type": "setting",
"provides": "",
"requires": [setting.message],
}
)
for widget in defn.widgets:
# A control produces the message; a tile consumes it.
produces = widget.target if widget.target.startswith(prefix) else ""
@@ -599,6 +684,7 @@ __all__ = [
"DASHBOARD_DIR",
"HISTORY_CAP",
"INPUT_WIDGETS",
"SETTING_DTYPES",
"WIDGET_DTYPES",
"DashboardDef",
"DashboardExists",
@@ -609,6 +695,7 @@ __all__ = [
"PageDef",
"Placement",
"SectionDef",
"SettingDef",
"WidgetDef",
"default_dashboard",
]
+6 -1
View File
@@ -85,11 +85,15 @@ def find(panel_id: str) -> PanelDef | None:
def messages_for(panel_id: str, store: DashboardStore) -> set[str]:
"""Every message the widgets of this panel's dashboards read or write.
"""Every message this panel's dashboards read or write.
What a screen is entitled to see, as its own dashboards define it. Read
from the published documents, since that is what a panel draws, and empty
for a panel that is gone — which is the same answer as "nothing".
A dashboard's own bound settings count, not only its widgets': the theme a
panel is driven to is a message no tile on it draws, and a wall panel
refused its own theme message is the one surface the setting exists for.
"""
panel = find(panel_id)
if panel is None:
@@ -100,6 +104,7 @@ def messages_for(panel_id: str, store: DashboardStore) -> set[str]:
defn = store.read(dashboard)
except DashboardNotFound:
continue
names.update(defn.setting_messages)
for widget in defn.widgets:
names.update(widget.messages)
if widget.target:
+80
View File
@@ -313,6 +313,86 @@ def test_a_panel_speaks_only_its_own_widgets_messages(
)
def test_a_panel_may_read_its_dashboards_own_settings(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
"""A bound setting is no widget's binding, and a panel still needs it.
The allowlist is a walk of what the dashboards name, so the walk has to
reach the dashboard itself: a theme driven over a message is the one thing
a screen on a wall cannot be told any other way, and a panel refused that
message would leave the feature silently doing nothing on the only surface
it exists for.
"""
from fluksio.api.routes.flows import panel_scope
_dashboard_with(
client, superuser_token_headers, "panel_themed", [PANEL_WIDGETS[-1]]
)
saved = client.get(
f"{DASHBOARDS}/panel_themed?draft=true", headers=superuser_token_headers
).json()
saved["settings"] = {
"theme": {"value": "dark", "message": "demo.panel_theme", "dtype": "str"},
# Bound to nothing: a static setting entitles a panel to nothing.
"locked": {"value": False},
}
written = client.put(
f"{DASHBOARDS}/panel_themed", headers=superuser_token_headers, json=saved
)
assert written.status_code == 200, written.text
published = client.post(
f"{DASHBOARDS}/panel_themed/publish",
headers=superuser_token_headers,
json={"version": written.json()["version"]},
)
assert published.status_code == 200, published.text
_panels(
client,
superuser_token_headers,
{"panels": [{"id": "hall", "dashboards": ["panel_themed"]}]},
)
panel_headers = _pair(client, superuser_token_headers, "hall")
messages = f"{settings.API_V1_STR}/messages"
# Not 403: the gate lets it through, and the engine then answers for a
# message no flow in this suite declares.
assert (
client.get(
f"{messages}/demo.panel_theme/history", headers=panel_headers
).status_code
== 200
)
# And the socket is bounded by the same walk, so the value actually lands.
token = panel_headers["Authorization"][7:]
assert panel_scope(token, client.app) == {
"demo.panel_theme",
"demo.temperature",
}
# A setting still buys nothing beyond itself.
assert (
client.get(
f"{messages}/demo.unrelated/history", headers=panel_headers
).status_code
== 403
)
def test_a_dashboard_setting_bound_to_the_wrong_type_is_refused(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
"""The same answer the server gives a mis-wired widget."""
saved = _dashboard(client, superuser_token_headers, "panel_mistyped")
saved["settings"] = {"theme": {"value": "dark", "message": "a.b", "dtype": "float"}}
refused = client.put(
f"{DASHBOARDS}/panel_mistyped", headers=superuser_token_headers, json=saved
)
assert refused.status_code == 422, refused.text
def test_unpairing_a_screen_leaves_its_panel_standing(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
+60
View File
@@ -8,6 +8,7 @@ from fluksio.flow.dashboards import (
DashboardStore,
PageDef,
SectionDef,
SettingDef,
WidgetDef,
default_dashboard,
)
@@ -353,3 +354,62 @@ def test_a_dashboard_is_cut_into_a_sane_number_of_columns():
for columns in (0, 49):
with pytest.raises(ValueError):
DashboardDef(name="house", columns=columns)
def test_a_setting_refuses_a_message_it_cannot_carry():
"""The same rule a widget binding is held to, from the document alone."""
DashboardDef(
name="house",
settings={"theme": SettingDef(value="dark", message="a.b", dtype="str")},
)
DashboardDef(
name="house",
settings={"locked": SettingDef(value=False, message="a.b", dtype="bool")},
)
with pytest.raises(ValueError):
DashboardDef(
name="house",
settings={"theme": SettingDef(value="dark", message="a.b", dtype="float")},
)
with pytest.raises(ValueError):
DashboardDef(
name="house",
settings={"locked": SettingDef(value=False, message="a.b", dtype="str")},
)
def test_an_unbound_setting_is_just_its_value():
"""The static case, which is what makes a dark panel cost no flow.
No message means nothing to type-check and nothing for a panel to be
entitled to — and a name this build does not know is left alone rather
than refused, so an older installation reads a newer document.
"""
defn = DashboardDef(
name="house",
settings={
"theme": SettingDef(value="dark"),
"someday": SettingDef(value=7, message="a.b", dtype="int"),
},
)
assert defn.settings["theme"].value == "dark"
assert defn.setting_messages == ["a.b"]
def test_a_bound_setting_is_drawn_on_the_canvas(store: DashboardStore):
"""A dashboard consuming a message is an endpoint like a tile is."""
store.write(
DashboardDef(
name="house",
settings={"theme": SettingDef(value="system", message="home.theme")},
)
)
(binding,) = store.bindings_for("home")
assert binding["widget"] == "settings.theme"
assert binding["type"] == "setting"
assert binding["requires"] == ["home.theme"]
assert not binding["provides"]
assert store.bindings_for("other") == []