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
+8 -1
View File
@@ -8,6 +8,7 @@ import {
dashboardQueryOptions,
panelQueryOptions,
} from "@/components/Dashboard/queries"
import { useDashboardTheme } from "@/components/Dashboard/settings"
import { useFlowSocket } from "@/components/Flow/useFlowSocket"
import { isLoggedIn } from "@/hooks/useAuth"
import { useIsMobile } from "@/hooks/useMobile"
@@ -55,6 +56,11 @@ function PanelRoute() {
})
const stacked = useIsMobile()
const rail = dashboards.length > 1
// The screen is the dashboard here, so its theme covers the whole of it —
// the rail and the letterbox around a scaled canvas included. This is the
// surface the setting exists for: a panel in a room has no other way to be
// told which theme to wear.
const theme = useDashboardTheme(dashboard as Dashboard | undefined)
if (panel && dashboards.length === 0) {
return (
@@ -69,7 +75,8 @@ function PanelRoute() {
return (
<main
className={cn(
"relative h-svh w-full p-4",
"relative h-svh w-full bg-background p-4 text-foreground",
theme,
stacked ? "overflow-y-auto" : "overflow-hidden",
)}
style={rail ? { paddingLeft: RAIL_INSET } : undefined}