Add a colour-wheel widget to the dashboard

A custom hue ring — a conic gradient, not a canvas — with saturation and
brightness sliders beside or under it depending on the tile's shape, sized
for a wall panel and reachable from a keyboard. It publishes [h, s, v] by
default, which is what the reference installation's DMX encoders read, and
`format` switches that to [r, g, b] or "#rrggbb".

`usePublish` moves to its own module so a widget in a file of its own can
reach it without importing `widgets.tsx` back.
This commit is contained in:
2026-08-22 12:50:59 +02:00
parent 1b1b530cfa
commit 8224d12c8c
12 changed files with 725 additions and 99 deletions
+18 -93
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react"
import { useState } from "react"
import type { ApiError, WidgetDef } from "@/client"
import type { WidgetDef } from "@/client"
import { useLiveValue } from "@/components/Flow/liveStore"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -18,18 +18,17 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import useCustomToast from "@/hooks/useCustomToast"
import { cn } from "@/lib/utils"
import { handleError } from "@/utils"
import { BarWidget, segmentsOf } from "./BarWidget"
import { ChartWidget } from "./ChartWidget"
import { ClockWidget } from "./ClockWidget"
// The transmit overlay's rule lives beside the other widget CSS; a widget is
import { COLOR_DTYPES, ColorWidget, colorFormatOf } from "./ColorWidget"
// The segmented thumb's rule lives beside the other widget CSS; a widget is
// drawn by the editor as well as by the view, so the sheet is pulled in here.
import "./dashboard.css"
import { ForecastWidget } from "./ForecastWidget"
import { IconWidget } from "./IconWidget"
import { usePublishMessage } from "./queries"
import { usePublish } from "./publish"
/** Widget types that put a value into the graph rather than read one. */
export const INPUT_WIDGETS = new Set([
@@ -38,6 +37,7 @@ export const INPUT_WIDGETS = new Set([
"slider",
"input",
"dropdown",
"color",
])
export type WidgetKind = WidgetDef["type"]
@@ -61,6 +61,9 @@ export const WIDGET_DTYPES: Partial<Record<WidgetKind, string[]>> = {
notification: ["record"],
bar: ["float", "int"],
forecast: ["list"],
// Either shape a colour can travel as; its `format` decides which of the two
// this widget means, which `widgetIssue` holds the binding to.
color: ["list", "str"],
// An icon maps weather strings, bool hints and numbers alike, and a clock
// binds nothing at all, so neither has a row to be held to.
}
@@ -87,6 +90,7 @@ export const WIDGET_LABELS: Record<WidgetKind, string> = {
slider: "Slider",
input: "Input",
dropdown: "Dropdown",
color: "Colour",
}
/** Default footprint per type, in grid units. */
@@ -106,6 +110,7 @@ export const WIDGET_SIZES: Record<WidgetKind, { w: number; h: number }> = {
slider: { w: 4, h: 2 },
input: { w: 4, h: 2 },
dropdown: { w: 4, h: 2 },
color: { w: 4, h: 4 },
}
function config(widget: WidgetDef): Record<string, unknown> {
@@ -189,6 +194,12 @@ export function widgetIssue(widget: WidgetDef): string | null {
if (!acceptsDtype(widget.type, dtype)) {
return `${bound} is a ${dtype}; a ${WIDGET_LABELS[widget.type].toLowerCase()} cannot carry that.`
}
if (widget.type === "color") {
const want = COLOR_DTYPES[colorFormatOf(widget)]
if (dtype && dtype !== want) {
return `${bound} is a ${dtype}; this wheel sends ${colorFormatOf(widget)}, which is a ${want}.`
}
}
// Only a bar nests further readings, and an unrecorded type binds anything.
// Read through `segmentsOf` so a stacked bar is judged segment by segment
// rather than only in the one-reading shape it used to carry.
@@ -556,93 +567,6 @@ function NotificationWidget({ widget }: WidgetProps) {
// Input
// ---------------------------------------------------------------------------
/**
* How long a control shows what it sent before falling back to the engine.
*
* ponytail: a flat 3 s rather than anything the engine tells us. A publish the
* server takes but nothing ever echoes — a message no flow consumes — would
* otherwise leave the tile holding a value that is not the truth, forever.
*/
const HOLD_MS = 3000
/** Whether what came back over the socket is what this control sent. */
function confirms(live: unknown, sent: unknown): boolean {
// Readings are scalars nearly always; a dropdown may carry a record, and
// comparing those as text is cheaper than a deep walk for the same answer.
return live === sent || JSON.stringify(live) === JSON.stringify(sent)
}
/**
* Publishing, with the value shown as sent until the engine confirms it.
*
* A control publishes over HTTP and reads the result back over the socket, so
* between the two the live value is still the old one — a handle let go of
* would snap back to it. The hold ends when the echo matches, when the publish
* is refused, or on `HOLD_MS`; success is silent, because the echo is the
* confirmation.
*/
function usePublish(widget: WidgetDef, dashboard: string) {
const cfg = config(widget)
const target = text(cfg.target)
const publish = usePublishMessage()
const live = useLiveValue(target || undefined)
const { showErrorToast } = useCustomToast()
// Boxed: holding `false` or `null` is not the same as holding nothing.
const [held, setHeld] = useState<{ value: unknown } | null>(null)
useEffect(() => {
if (!held) return
const timer = setTimeout(() => setHeld(null), HOLD_MS)
return () => clearTimeout(timer)
}, [held])
useEffect(() => {
if (held && confirms(live?.value, held.value)) setHeld(null)
}, [held, live])
return {
target,
/** What the control draws: what it sent, until the engine answers. */
value: held ? held.value : live?.value,
send: (value: unknown) => {
if (!target) return
setHeld({ value })
publish.mutate(
{
name: target,
value,
dashboard,
widget: widget.id,
label: widget.title || widget.id,
kind: widget.type,
},
{
onError: (error) => {
// Back to the engine's own value, and say which message refused it
// — a panel showing several controls cannot tell them apart.
setHeld(null)
handleError.call(
(detail: string) => showErrorToast(`${target}: ${detail}`),
error as ApiError,
)
},
},
)
},
/**
* The in-flight pulse, drawn over the whole tile.
*
* Absolutely positioned and inert, so it neither resizes the widget nor
* moves anything around it. Every control renders it; the frame is what it
* hangs off.
*/
pulse: publish.isPending ? (
<span aria-hidden className="widget-transmit" />
) : null,
pending: publish.isPending,
}
}
function ButtonWidget({ widget, dashboard }: WidgetProps) {
const cfg = config(widget)
const { target, send, pending, pulse } = usePublish(widget, dashboard)
@@ -963,6 +887,7 @@ const RENDERERS: Partial<
slider: SliderWidget,
input: InputWidget,
dropdown: DropdownWidget,
color: ColorWidget,
}
export function WidgetBody({ widget, dashboard }: WidgetProps) {