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:
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user