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:
@@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query"
|
||||
import { Ban, ChevronDown, Plus, X } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
|
||||
import type { MessageInfo, WidgetDef } from "@/client"
|
||||
import type { MessageInfo, SettingDef, WidgetDef } from "@/client"
|
||||
import { DEFAULT_RANGE, RANGES } from "@/components/Common/RangePicker"
|
||||
import {
|
||||
PANEL_SECTION,
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { MAX_SEGMENTS, type Segment, segmentsOf } from "./BarWidget"
|
||||
import { MAX_SERIES, refreshFor } from "./ChartWidget"
|
||||
@@ -46,6 +47,13 @@ import {
|
||||
} from "./DashboardView"
|
||||
import { ICON_COLORS, ICON_NAMES, ICONS } from "./icons"
|
||||
import { messageCatalogQueryOptions } from "./queries"
|
||||
import {
|
||||
SETTING_DTYPES,
|
||||
type SettingName,
|
||||
settingIssue,
|
||||
settingOf,
|
||||
THEME_CHOICES,
|
||||
} from "./settings"
|
||||
import {
|
||||
acceptsDtype,
|
||||
INPUT_WIDGETS,
|
||||
@@ -78,13 +86,17 @@ function MessagePicker({
|
||||
value,
|
||||
label,
|
||||
testId,
|
||||
placeholder = "Pick a message",
|
||||
filter,
|
||||
onPick,
|
||||
}: {
|
||||
kind: WidgetKind
|
||||
/** Omitted where the slot is not a widget's at all — a dashboard setting
|
||||
* binds by payload type alone, and hands in `filter` instead. */
|
||||
kind?: WidgetKind
|
||||
value: string
|
||||
label: string
|
||||
testId?: string
|
||||
placeholder?: string
|
||||
/** What this slot takes, when the widget's own type does not decide it —
|
||||
* a querying chart asks with one shape and draws another. */
|
||||
filter?: (message: MessageInfo) => boolean
|
||||
@@ -92,7 +104,11 @@ function MessagePicker({
|
||||
}) {
|
||||
const { data } = useQuery(messageCatalogQueryOptions())
|
||||
const catalog = data?.data ?? []
|
||||
const choices = filter ? catalog.filter(filter) : choicesFor(kind, catalog)
|
||||
const choices = filter
|
||||
? catalog.filter(filter)
|
||||
: kind
|
||||
? choicesFor(kind, catalog)
|
||||
: catalog
|
||||
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
@@ -107,7 +123,7 @@ function MessagePicker({
|
||||
}
|
||||
>
|
||||
<SelectTrigger data-testid={testId}>
|
||||
<SelectValue placeholder="Pick a message" />
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{choices.map((message) => (
|
||||
@@ -944,6 +960,73 @@ export function WidgetPanel({
|
||||
const sizeKey = (size: { width: number; height: number }) =>
|
||||
`${size.width}x${size.height}`
|
||||
|
||||
/**
|
||||
* The optional half of a setting: which message, if any, drives it.
|
||||
*
|
||||
* Deliberately drawn as an addition rather than a requirement — the placeholder
|
||||
* says what leaving it alone means, and the value control above it is the whole
|
||||
* setting until something is picked here. Binding is how a flow takes the
|
||||
* setting over; a node publishing on a cron is what a schedule is in this
|
||||
* system, so there is no scheduling UI to build.
|
||||
*
|
||||
* The picker records the payload type beside the name, which is what lets the
|
||||
* pairing be judged from the document alone — the same rule a widget's binding
|
||||
* is held to, and the same one the server enforces.
|
||||
*/
|
||||
function SettingBinding({
|
||||
name,
|
||||
setting,
|
||||
onChange,
|
||||
}: {
|
||||
name: SettingName
|
||||
setting: SettingDef
|
||||
onChange: (setting: SettingDef) => void
|
||||
}) {
|
||||
const want = SETTING_DTYPES[name]
|
||||
const issue = settingIssue(name, setting)
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<MessagePicker
|
||||
value={str(setting.message)}
|
||||
label="Driven by"
|
||||
testId={`dashboard-${name}-message`}
|
||||
placeholder={`Nothing — always ${valueLabel(name, setting.value)}`}
|
||||
filter={(message) => message.dtype === want}
|
||||
onPick={(message, dtype) =>
|
||||
onChange({ ...setting, message, dtype })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{setting.message ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={`Stop driving ${name}`}
|
||||
data-testid={`dashboard-${name}-unbind`}
|
||||
onClick={() => onChange({ value: setting.value })}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{issue ? (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{issue}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** How a setting's own value reads in the "nothing is driving it" line. */
|
||||
function valueLabel(name: SettingName, value: unknown): string {
|
||||
if (name === "locked") return value === true ? "read-only" : "editable"
|
||||
const chosen = THEME_CHOICES.find(([option]) => option === value)
|
||||
return (chosen?.[1] ?? "System").toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* The dashboard's own settings, in the panel its widgets use.
|
||||
*
|
||||
@@ -968,6 +1051,12 @@ export function DashboardPanel({
|
||||
}) {
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
const canvas = canvasOf(dashboard)
|
||||
const theme = settingOf(dashboard, "theme")
|
||||
const locked = settingOf(dashboard, "locked")
|
||||
|
||||
/** Settings are a map, so one of them changing rewrites the whole of it. */
|
||||
const setSetting = (name: SettingName, setting: SettingDef) =>
|
||||
onChange({ settings: { ...(dashboard.settings ?? {}), [name]: setting } })
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -1095,6 +1184,55 @@ export function DashboardPanel({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<span className={PANEL_SECTION}>Theme</span>
|
||||
<Segmented
|
||||
value={str(theme.value) || "system"}
|
||||
options={THEME_CHOICES}
|
||||
label="Dashboard theme"
|
||||
testId="dashboard-theme"
|
||||
onChange={(value) => setSetting("theme", { ...theme, value })}
|
||||
/>
|
||||
<SettingBinding
|
||||
name="theme"
|
||||
setting={theme}
|
||||
onChange={(setting) => setSetting("theme", setting)}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
What this dashboard wears wherever it is shown — a screen on a
|
||||
wall has nobody to set the device preference System otherwise
|
||||
follows. Bind a message and a flow drives it instead: a node
|
||||
publishing on a cron is what a schedule looks like here, and the
|
||||
choice above stays the fallback.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<span className={PANEL_SECTION}>Lock</span>
|
||||
<div className="flex items-center justify-between gap-2 text-sm">
|
||||
Read-only
|
||||
<Switch
|
||||
checked={locked.value === true}
|
||||
aria-label="Read-only"
|
||||
data-testid="dashboard-lock"
|
||||
onCheckedChange={(value) =>
|
||||
setSetting("locked", { ...locked, value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<SettingBinding
|
||||
name="locked"
|
||||
setting={locked}
|
||||
onChange={(setting) => setSetting("locked", setting)}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Locked, the controls on this dashboard are shown but stop
|
||||
publishing, and the surface says so. It is a read-only surface
|
||||
rather than a permission: what a paired screen may reach is still
|
||||
decided by its own credential.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<span className={PANEL_SECTION}>Contents</span>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
||||
Reference in New Issue
Block a user