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 2f0e50fc9f
commit 092a5333e8
19 changed files with 803 additions and 62 deletions
+44
View File
@@ -435,6 +435,13 @@ export const DashboardDef_InputSchema = {
type: 'array',
title: 'Pages'
},
settings: {
additionalProperties: {
'$ref': '#/components/schemas/SettingDef'
},
type: 'object',
title: 'Settings'
},
version: {
type: 'integer',
title: 'Version',
@@ -496,6 +503,13 @@ export const DashboardDef_OutputSchema = {
type: 'array',
title: 'Pages'
},
settings: {
additionalProperties: {
'$ref': '#/components/schemas/SettingDef'
},
type: 'object',
title: 'Settings'
},
version: {
type: 'integer',
title: 'Version',
@@ -2640,6 +2654,36 @@ export const SeriesPointSchema = {
title: 'SeriesPoint'
} as const;
export const SettingDefSchema = {
properties: {
value: {
title: 'Value'
},
message: {
type: 'string',
title: 'Message',
default: ''
},
dtype: {
type: 'string',
title: 'Dtype',
default: ''
}
},
type: 'object',
title: 'SettingDef',
description: `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.`
} as const;
export const ShareRequestSchema = {
properties: {
lib_name: {
+24
View File
@@ -117,6 +117,9 @@ export type DashboardDef_Input = {
canvas_height?: number;
icon?: string;
pages?: Array<PageDef_Input>;
settings?: {
[key: string]: SettingDef;
};
version?: number;
has_draft?: boolean;
};
@@ -132,6 +135,9 @@ export type DashboardDef_Output = {
canvas_height?: number;
icon?: string;
pages?: Array<PageDef_Output>;
settings?: {
[key: string]: SettingDef;
};
version?: number;
has_draft?: boolean;
};
@@ -919,6 +925,24 @@ export type SeriesPoint = {
avg_lag_ms: number;
};
/**
* 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.
*/
export type SettingDef = {
value?: unknown;
message?: string;
dtype?: string;
};
export type ShareRequest = {
lib_name: string;
};
@@ -166,11 +166,13 @@ const handleAt = (hue: number) => ({
function Level({
label,
value,
disabled,
onChange,
onCommit,
}: {
label: string
value: number
disabled?: boolean
onChange: (value: number) => void
onCommit: () => void
}) {
@@ -185,6 +187,7 @@ function Level({
min={0}
max={100}
value={value}
disabled={disabled}
className="h-11 w-full accent-[var(--primary)] md:h-8"
onChange={(event) => onChange(Number(event.target.value))}
// Only the release publishes, as the slider widget does: a drag would
@@ -211,7 +214,7 @@ function Level({
* control that can announce one, and neither of them on a keyboard.
*/
export function ColorWidget({ widget, dashboard }: WidgetProps) {
const { target, value, send, pulse } = usePublish(widget, dashboard)
const { target, value, send, pulse, locked } = usePublish(widget, dashboard)
// While dragging, the wheel follows the finger rather than the engine.
const [draft, setDraft] = useState<Triple | null>(null)
if (!target)
@@ -230,6 +233,7 @@ export function ColorWidget({ widget, dashboard }: WidgetProps) {
/** The hue under the pointer: where it is relative to the wheel's centre. */
const aim = (event: React.PointerEvent<HTMLDivElement>) => {
if (locked) return
const box = event.currentTarget.getBoundingClientRect()
const x = event.clientX - (box.left + box.width / 2)
const y = event.clientY - (box.top + box.height / 2)
@@ -252,7 +256,11 @@ export function ColorWidget({ widget, dashboard }: WidgetProps) {
circle, so it says what it is and answers the same keys. */}
<div
role="slider"
tabIndex={0}
// Not a native control, so the state it is in is said rather
// than inherited — and the ring keeps its colours, which are the
// reading, while the handle stops answering.
tabIndex={locked ? -1 : 0}
aria-disabled={locked || undefined}
aria-label={`${name} hue`}
aria-valuemin={0}
aria-valuemax={359}
@@ -271,6 +279,7 @@ export function ColorWidget({ widget, dashboard }: WidgetProps) {
}}
onPointerUp={commit}
onKeyDown={(event) => {
if (locked) return
const step =
event.key === "ArrowRight" || event.key === "ArrowUp"
? HUE_STEP
@@ -302,12 +311,14 @@ export function ColorWidget({ widget, dashboard }: WidgetProps) {
<Level
label="Saturation"
value={saturation}
disabled={locked}
onChange={(next) => setDraft([hue, next, brightness])}
onCommit={commit}
/>
<Level
label="Brightness"
value={brightness}
disabled={locked}
onChange={(next) => setDraft([hue, saturation, next])}
onCommit={commit}
/>
@@ -78,6 +78,7 @@ import {
usePublishDashboard,
useSaveDashboard,
} from "./queries"
import { useDashboardTheme } from "./settings"
import {
WIDGET_LABELS,
WIDGET_SIZES,
@@ -211,6 +212,10 @@ export function DashboardEditor({
// A phone reads the dashboard rather than arranges it, so the grid library
// never mounts there. See DESIGN-GUIDELINES.md → Responsive.
const stacked = useIsMobile()
// The dashboard's own theme, on the surface only: the shell around the
// canvas stays whatever the person editing chose for the app. Non-stacked,
// `CanvasSurface` states it on the canvas box itself.
const theme = useDashboardTheme(draft)
const navigate = useNavigate()
const queryClient = useQueryClient()
const save = useSaveDashboard(dashboard.name)
@@ -444,7 +449,12 @@ export function DashboardEditor({
) : stacked ? (
// One column at the viewport's width. Edit mode still picks a widget and
// opens its settings; only the arrangement is missing.
<div className="h-full overflow-y-auto">
<div
className={cn(
"h-full overflow-y-auto bg-background text-foreground",
theme,
)}
>
<DashboardView
dashboard={draft}
stacked
@@ -9,6 +9,7 @@ import type {
} from "@/client"
import { cn } from "@/lib/utils"
import "./dashboard.css"
import { LockedProvider, useDashboardTheme } from "./settings"
import { WidgetBody, WidgetFrame, widgetIssue } from "./widgets"
export type Dashboard = DashboardDef_Output
@@ -95,6 +96,11 @@ export const rowsOf = (dashboard: Dashboard) =>
* Scaling rather than reflowing is the point. A side panel opening, or a
* narrower screen, changes only the scale — the arrangement being designed,
* and the dot grid under it, stay the layout the panel will actually show.
*
* This box is also exactly what the dashboard's `theme` setting applies to: it
* is the panel, so a dashboard forced light or dark paints its own ground here
* and leaves the shell around it alone. Which is why it states a background at
* all — without one it would borrow whatever it was dropped into.
*/
export function CanvasSurface({
dashboard,
@@ -124,6 +130,7 @@ export function CanvasSurface({
const { width, height } = canvasOf(dashboard)
const scale = Math.min(box.width / width, box.height / height)
const theme = useDashboardTheme(dashboard)
return (
<div ref={ref} className="relative size-full overflow-hidden">
@@ -131,7 +138,11 @@ export function CanvasSurface({
then move it. */}
{scale > 0 ? (
<div
className={cn("absolute overflow-hidden", dots && "dot-canvas")}
className={cn(
"absolute overflow-hidden bg-background text-foreground",
theme,
dots && "dot-canvas",
)}
data-testid="canvas-surface"
style={
{
@@ -292,33 +303,35 @@ export function DashboardView({
const columns = columnsOf(dashboard)
return (
<div
className={cn("widget-grid", stacked && "widget-stacked")}
data-placed={isPlaced(widgets) || undefined}
style={
{
"--widget-cols": columns,
// The row height follows the canvas, so the CSS grid and the
// editor's grid library cannot drift apart.
"--row-height": `${rowHeightOf(dashboard)}px`,
} as React.CSSProperties
}
>
{widgets.map((widget) => (
<div
key={widget.id}
style={widgetStyle(widget, columns)}
className="widget-cell"
>
{renderWidget ? (
renderWidget(widget)
) : (
<WidgetFrame title={widget.title} issue={widgetIssue(widget)}>
<WidgetBody widget={widget} dashboard={dashboard.name} />
</WidgetFrame>
)}
</div>
))}
</div>
<LockedProvider dashboard={dashboard}>
<div
className={cn("widget-grid", stacked && "widget-stacked")}
data-placed={isPlaced(widgets) || undefined}
style={
{
"--widget-cols": columns,
// The row height follows the canvas, so the CSS grid and the
// editor's grid library cannot drift apart.
"--row-height": `${rowHeightOf(dashboard)}px`,
} as React.CSSProperties
}
>
{widgets.map((widget) => (
<div
key={widget.id}
style={widgetStyle(widget, columns)}
className="widget-cell"
>
{renderWidget ? (
renderWidget(widget)
) : (
<WidgetFrame title={widget.title} issue={widgetIssue(widget)}>
<WidgetBody widget={widget} dashboard={dashboard.name} />
</WidgetFrame>
)}
</div>
))}
</div>
</LockedProvider>
)
}
@@ -1,8 +1,11 @@
import { Lock } from "lucide-react"
import type { Dashboard } from "@/components/Dashboard/DashboardView"
import {
CanvasSurface,
DashboardView,
} from "@/components/Dashboard/DashboardView"
import { useDashboardLocked } from "@/components/Dashboard/settings"
/**
* One dashboard filling whatever screen it landed on.
@@ -10,6 +13,10 @@ import {
* The whole of what a wall panel draws, shared by the single-dashboard route
* (`/view/{name}`) and the paired-panel one (`/panel/{id}`) so a device shows
* the same thing either way — the second merely has a rail beside it.
*
* The dashboard's theme is stated by the route rather than here: on these two
* the screen *is* the dashboard, so it has to cover the letterbox and the rail
* as well as the canvas.
*/
export function PanelSurface({
dashboard,
@@ -20,13 +27,48 @@ export function PanelSurface({
* its size, which reads as nothing at all. Stack it instead. */
stacked?: boolean
}) {
if (stacked) return <DashboardView dashboard={dashboard} stacked />
// The panel's own surface, scaled to fit. No dots: nothing is being
// arranged here.
return (
<CanvasSurface dashboard={dashboard}>
{() => <DashboardView dashboard={dashboard} />}
</CanvasSurface>
<div className="relative size-full">
{stacked ? (
<DashboardView dashboard={dashboard} stacked />
) : (
// The panel's own surface, scaled to fit. No dots: nothing is being
// arranged here.
<CanvasSurface dashboard={dashboard}>
{() => <DashboardView dashboard={dashboard} />}
</CanvasSurface>
)}
<LockNotice dashboard={dashboard} />
</div>
)
}
/**
* What a locked dashboard looks like, beyond controls that read as disabled.
*
* Without it a read-only panel is a panel whose buttons do nothing, which
* reads as broken rather than as locked. Frosted chrome over content, like
* every other floating surface, and it says the state in words — a glyph on
* its own is not a label.
*
* Live, because `locked` may be driven by a flow: the notice appears and goes
* with the lock rather than with the page load.
*/
function LockNotice({ dashboard }: { dashboard: Dashboard }) {
// Read straight from the document rather than through `useLocked`: that
// provider sits inside the grid, and this notice is beside it.
const locked = useDashboardLocked(dashboard)
if (!locked) return null
return (
<div
// Announced rather than merely drawn: `locked` may be driven by a flow,
// so the state can change under someone already looking at the page.
aria-live="polite"
data-testid="dashboard-locked"
className="pointer-events-none absolute bottom-0 right-0 flex items-center gap-1.5 rounded-full border border-border bg-card/80 px-3 py-1.5 text-sm text-muted-foreground shadow-e2 backdrop-blur-md"
>
<Lock className="size-4" aria-hidden />
Read-only
</div>
)
}
+142 -4
View File
@@ -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">
+16 -1
View File
@@ -8,6 +8,7 @@ import { handleError } from "@/utils"
// chunked per entry — so the sheet is pulled in wherever the pulse is drawn.
import "./dashboard.css"
import { usePublishMessage } from "./queries"
import { useLocked } from "./settings"
/**
* How long a control shows what it sent before falling back to the engine.
@@ -35,6 +36,17 @@ function confirms(live: unknown, sent: unknown): boolean {
* is refused, or on `HOLD_MS`; success is silent, because the echo is the
* confirmation.
*
* A locked dashboard is gated here and only here: every control publishes
* through this hook, so one check covers all of them and a control added later
* inherits it. `locked` is handed back so each control can also *read* as
* disabled — a dashboard that silently swallows a press looks broken rather
* than locked.
*
* ponytail: this is a read-only surface, not an authorisation boundary. The
* server still takes a publish from a panel credential whose dashboard says
* locked, because the credential's own allowlist is what bounds it. Making it
* a real lock means carrying the flag into `_panel_may`.
*
* Its own module rather than `widgets.tsx`, which every widget file is
* imported *by*: a control drawn in a file of its own can only reach this
* without closing that circle if it does not sit there.
@@ -42,6 +54,7 @@ function confirms(live: unknown, sent: unknown): boolean {
export function usePublish(widget: WidgetDef, dashboard: string) {
const cfg = (widget.config ?? {}) as Record<string, unknown>
const target = cfg.target == null ? "" : String(cfg.target)
const locked = useLocked()
const publish = usePublishMessage()
const live = useLiveValue(target || undefined)
const { showErrorToast } = useCustomToast()
@@ -62,8 +75,10 @@ export function usePublish(widget: WidgetDef, dashboard: string) {
target,
/** What the control draws: what it sent, until the engine answers. */
value: held ? held.value : live?.value,
/** Whether this dashboard is read-only; controls draw themselves disabled. */
locked,
send: (value: unknown) => {
if (!target) return
if (!target || locked) return
setHeld({ value })
publish.mutate(
{
@@ -0,0 +1,126 @@
import { createContext, useContext } from "react"
import type { DashboardDef_Output, SettingDef } from "@/client"
import { useLiveValue } from "@/components/Flow/liveStore"
/**
* The dashboard's own settings channel.
*
* A widget is a receiver a dashboard *contains*; this is the dashboard itself
* as one. Each setting is a value plus an optional binding:
*
* - **Unbound** — no message — the setting is simply its value. That is what
* gives a wall panel which is always dark without a flow behind it.
* - **Bound** — a flow drives it live, and the stored 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 here, which is the whole reason this is a channel
* rather than a switching rule bolted onto each setting.
*
* A bound setting is read the way a widget reads a value — the same live store
* and the same socket — so a panel's own settings messages are part of what
* its credential is entitled to (`flow/panels.py`, `messages_for`).
*/
/**
* What a setting may be driven by, by payload type. The same table is enforced
* on the server (`app/flow/dashboards.py`); a name missing from it is a
* setting this build does not act on rather than an error.
*/
export const SETTING_DTYPES: Record<string, string> = {
theme: "str",
locked: "bool",
}
/** The settings this build actually wires up. */
export type SettingName = "theme" | "locked"
/** What `theme` may be set to. `system` follows whatever the device says. */
export const THEME_CHOICES = [
["system", "System"],
["light", "Light"],
["dark", "Dark"],
] as const
/** The generated client marks the map optional, because the server fills it in. */
export function settingOf(
dashboard: DashboardDef_Output | undefined,
name: SettingName,
): SettingDef {
return dashboard?.settings?.[name] ?? {}
}
/**
* What is wrong with a setting's binding, if anything.
*
* Judged from the document alone — the picker records the payload type beside
* the name — so this is the same rule `widgetIssue` holds a tile to, and the
* editor cannot author a document the server would refuse.
*/
export function settingIssue(name: string, setting: SettingDef): string | null {
const want = SETTING_DTYPES[name]
if (!want || !setting.message || !setting.dtype) return null
if (setting.dtype === want) return null
return `${setting.message} is a ${setting.dtype}; ${name} is driven by a ${want}.`
}
/** What the setting is worth now: live if it is bound, stored otherwise. */
function useSetting(
dashboard: DashboardDef_Output | undefined,
name: SettingName,
): unknown {
const setting = settingOf(dashboard, name)
// `undefined` while nothing has arrived, which is exactly when the stored
// value is meant to stand in.
const live = useLiveValue(setting.message || undefined)
return live?.value ?? setting.value
}
/**
* The class that themes a dashboard's own surface, or `""` to follow the app.
*
* A class rather than the root, because inside the app shell this must not
* flip the chrome around it. `.light` and `.dark` both redefine the tokens on
* whatever carries them (`index.css`), so either direction works on a subtree.
*/
export function useDashboardTheme(
dashboard: DashboardDef_Output | undefined,
): "" | "light" | "dark" {
const value = useSetting(dashboard, "theme")
return value === "dark" || value === "light" ? value : ""
}
/** Whether this dashboard is read-only, live if the setting is bound. */
export function useDashboardLocked(
dashboard: DashboardDef_Output | undefined,
): boolean {
return useSetting(dashboard, "locked") === true
}
const LockedContext = createContext(false)
/**
* Marks everything drawn under it read-only.
*
* A context rather than a prop threaded through sixteen renderers: what a
* control needs to know is one bit, and the one place it is acted on is
* `usePublish`. Edit mode deliberately never mounts this — arranging a
* dashboard is not the same as using it.
*/
export function LockedProvider({
dashboard,
children,
}: {
dashboard: DashboardDef_Output | undefined
children: React.ReactNode
}) {
const locked = useDashboardLocked(dashboard)
return (
<LockedContext.Provider value={locked}>{children}</LockedContext.Provider>
)
}
/** Whether the dashboard around this control is read-only. */
export const useLocked = () => useContext(LockedContext)
+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>
+5
View File
@@ -98,6 +98,11 @@
* segment is therefore drawn inside a gutter of the fill rather than ever
* bordering the track.
*/
/* `.light` is the symmetric half of `.dark` below: both are plain classes, so
either themes a subtree as well as the whole document — which is what lets a
dashboard forced to one theme sit inside a shell on the other. `:root` is
listed second so the parity grep keeps its anchor line. */
.light,
:root {
--background: #ffffff;
--foreground: #333232;
+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}
+10 -5
View File
@@ -4,9 +4,11 @@ import { createFileRoute, redirect } from "@tanstack/react-router"
import type { Dashboard } from "@/components/Dashboard/DashboardView"
import { PanelSurface } from "@/components/Dashboard/PanelSurface"
import { dashboardQueryOptions } 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"
import { cn } from "@/lib/utils"
/**
* What a wall panel is pointed at when it shows one dashboard and nothing else.
@@ -34,16 +36,19 @@ function PanelView() {
useFlowSocket()
const { data: dashboard } = useQuery(dashboardQueryOptions(name))
const stacked = useIsMobile()
// A tab pointed at one dashboard is that dashboard, so its theme covers the
// whole page rather than only the canvas inside it.
const theme = useDashboardTheme(dashboard as Dashboard | undefined)
if (!dashboard) return <main className="h-svh w-full" />
return (
<main
className={
stacked
? "h-svh w-full overflow-y-auto p-4"
: "h-svh w-full overflow-hidden p-4"
}
className={cn(
"h-svh w-full bg-background p-4 text-foreground",
theme,
stacked ? "overflow-y-auto" : "overflow-hidden",
)}
>
<PanelSurface dashboard={dashboard as Dashboard} stacked={stacked} />
</main>