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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user