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
+17 -8
View File
@@ -569,7 +569,7 @@ function NotificationWidget({ widget }: WidgetProps) {
function ButtonWidget({ widget, dashboard }: WidgetProps) {
const cfg = config(widget)
const { target, send, pending, pulse } = usePublish(widget, dashboard)
const { target, send, pending, pulse, locked } = usePublish(widget, dashboard)
if (!target) return <Unbound />
// Nothing to hold: a button carries no reading, so the pulse and a refusal
// are the whole of its feedback.
@@ -579,7 +579,7 @@ function ButtonWidget({ widget, dashboard }: WidgetProps) {
<Button
variant="secondary"
className="w-full min-w-0"
disabled={pending}
disabled={pending || locked}
onClick={() => send(cfg.value ?? true)}
>
<span className="truncate">
@@ -598,7 +598,7 @@ function ButtonWidget({ widget, dashboard }: WidgetProps) {
*/
function SwitchWidget({ widget, dashboard }: WidgetProps) {
const cfg = config(widget)
const { target, value, send, pulse } = usePublish(widget, dashboard)
const { target, value, send, pulse, locked } = usePublish(widget, dashboard)
if (!target) return <Unbound />
const on = value === true
@@ -610,6 +610,7 @@ function SwitchWidget({ widget, dashboard }: WidgetProps) {
className="w-full min-w-0"
aria-pressed={on}
aria-label={widget.title || target}
disabled={locked}
onClick={() => send(!on)}
>
<span className="truncate">{on ? "On" : "Off"}</span>
@@ -622,6 +623,7 @@ function SwitchWidget({ widget, dashboard }: WidgetProps) {
<Switch
checked={on}
aria-label={widget.title || target}
disabled={locked}
onCheckedChange={(checked) => send(checked)}
/>
</div>
@@ -693,7 +695,7 @@ function SliderTicks({
function SliderWidget({ widget, dashboard }: WidgetProps) {
const cfg = config(widget)
const { target, value, send, pulse } = usePublish(widget, dashboard)
const { target, value, send, pulse, locked } = usePublish(widget, dashboard)
const min = num(cfg.min, 0)
const max = num(cfg.max, 100)
const step = num(cfg.step, 1)
@@ -721,6 +723,7 @@ function SliderWidget({ widget, dashboard }: WidgetProps) {
step={step}
value={current}
aria-label={widget.title || target}
disabled={locked}
className="h-11 w-full accent-[var(--primary)] md:h-8"
onChange={(event) => setDragging(Number(event.target.value))}
// Only the release publishes: dragging would otherwise send a value
@@ -742,7 +745,7 @@ function SliderWidget({ widget, dashboard }: WidgetProps) {
function InputWidget({ widget, dashboard }: WidgetProps) {
const cfg = config(widget)
const { target, value, send, pulse } = usePublish(widget, dashboard)
const { target, value, send, pulse, locked } = usePublish(widget, dashboard)
const [draft, setDraft] = useState<string | null>(null)
if (!target) return <Unbound />
@@ -760,6 +763,7 @@ function InputWidget({ widget, dashboard }: WidgetProps) {
value={draft ?? text(value)}
type={asNumber ? "number" : "text"}
aria-label={widget.title || target}
disabled={locked}
onChange={(event) => setDraft(event.target.value)}
onBlur={commit}
onKeyDown={(event) => {
@@ -778,7 +782,7 @@ function InputWidget({ widget, dashboard }: WidgetProps) {
*/
function DropdownWidget({ widget, dashboard }: WidgetProps) {
const cfg = config(widget)
const { target, value, send, pulse } = usePublish(widget, dashboard)
const { target, value, send, pulse, locked } = usePublish(widget, dashboard)
const options = (cfg.options ?? []) as { label?: string; value?: unknown }[]
if (!target) return <Unbound />
@@ -820,9 +824,10 @@ function DropdownWidget({ widget, dashboard }: WidgetProps) {
key={text(option.value)}
type="button"
aria-pressed={index === chosen}
disabled={locked}
onClick={() => send(option.value)}
className={cn(
"relative z-10 h-11 min-w-0 truncate rounded-full px-2.5 text-sm transition-colors md:h-8",
"relative z-10 h-11 min-w-0 truncate rounded-full px-2.5 text-sm transition-colors disabled:opacity-50 md:h-8",
index === chosen
? "text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50",
@@ -844,7 +849,11 @@ function DropdownWidget({ widget, dashboard }: WidgetProps) {
value={text(value)}
onValueChange={(selected) => send(asOriginal(selected, options))}
>
<SelectTrigger className="w-full" aria-label={widget.title || target}>
<SelectTrigger
className="w-full"
aria-label={widget.title || target}
disabled={locked}
>
<SelectValue placeholder="Choose" />
</SelectTrigger>
<SelectContent>