flow: structured dtypes, and the widgets that read them

A series, record or list message declares its shape instead of riding
DType.JSON, so a widget binds a shape rather than some JSON and a wrong
binding is refused before anything runs. A list declares its item type,
which is what keeps list[float] expressible for a pipeline.

On top of that: an agenda over a list, a notification over a record, and
a dashboard alert channel that publishes engine faults as one — so a
panel can show what went wrong without a flow wiring it by hand.

Also: only None means a node published nothing, a falsy value of the
wrong shape is now the named error it always should have been; and the
gauge's readout says its size is viewBox geometry rather than type scale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 15:06:45 +02:00
co-authored by Claude Opus 5
parent 18837e8880
commit 413501c6ce
16 changed files with 751 additions and 43 deletions
+155 -1
View File
@@ -43,9 +43,13 @@ export type WidgetKind = WidgetDef["type"]
*/
export const WIDGET_DTYPES: Partial<Record<WidgetKind, string[]>> = {
gauge: ["float", "int"],
// A chart reading the engine's ring. One that queries binds a `series`
// answer and a `record` request instead, checked in `widgetIssue`.
chart: ["float", "int"],
slider: ["float", "int"],
switch: ["bool"],
agenda: ["list"],
notification: ["record"],
}
/** Whether a message of this payload type may drive this kind of widget. */
@@ -59,6 +63,8 @@ export const WIDGET_LABELS: Record<WidgetKind, string> = {
gauge: "Gauge",
chart: "Chart",
markdown: "Text",
agenda: "Agenda",
notification: "Notification",
button: "Button",
switch: "Switch",
slider: "Slider",
@@ -72,6 +78,8 @@ export const WIDGET_SIZES: Record<WidgetKind, { w: number; h: number }> = {
gauge: { w: 3, h: 3 },
chart: { w: 6, h: 4 },
markdown: { w: 6, h: 2 },
agenda: { w: 4, h: 4 },
notification: { w: 4, h: 2 },
button: { w: 3, h: 2 },
switch: { w: 3, h: 2 },
slider: { w: 4, h: 2 },
@@ -120,6 +128,20 @@ export function widgetIssue(widget: WidgetDef): string | null {
if (widget.type === "markdown") return null
const cfg = config(widget)
if (widget.type === "chart" && cfg.source === "query") {
if (!text(cfg.request)) return "This chart does not ask for anything yet."
if (!text(cfg.message)) return "This chart has no answer to draw yet."
const answer = text(cfg.dtype)
if (answer && answer !== "series") {
return `${text(cfg.message)} is a ${answer}; a chart that queries draws a series.`
}
const asked = text(cfg.request_dtype)
if (asked && asked !== "record") {
return `${text(cfg.request)} is a ${asked}; a request is a record.`
}
return null
}
if (widget.type === "chart") {
const series = seriesOf(widget)
if (series.length === 0) return "This chart has no series yet."
@@ -317,8 +339,11 @@ function GaugeWidget({ widget }: WidgetProps) {
<text
x={50}
y={54}
// User units of the viewBox, not the text scale: the readout has to
// stay proportional to the dial at whatever size the tile is.
fontSize={13}
textAnchor="middle"
className="fill-foreground text-[13px] tabular-nums"
className="fill-foreground tabular-nums"
>
{format(
value,
@@ -365,6 +390,119 @@ function MarkdownWidget({ widget }: WidgetProps) {
)
}
/** One item of an agenda, as the `list` message declares it. */
type AgendaItem = { title: string; ts: number; all_day?: boolean }
const DAY_MS = 86_400_000
/**
* Which day something falls on, said the way a person would.
*
* Today and tomorrow by name, the rest of the week by weekday, and anything
* further out by date — past a week "Thursday" stops telling you which one.
*/
function dayLabel(when: Date, now: Date): string {
const midnight = new Date(now).setHours(0, 0, 0, 0)
const days = Math.floor(
(new Date(when).setHours(0, 0, 0, 0) - midnight) / DAY_MS,
)
if (days === 0) return "Today"
if (days === 1) return "Tomorrow"
if (days < 7) return when.toLocaleDateString(undefined, { weekday: "long" })
return when.toLocaleDateString()
}
/**
* What is coming up, from a `list` of items the message declares.
*
* The shape is the widget's contract rather than a path per binding: every
* item is `{title, ts}` with an optional `all_day`, so a flow answering with
* a calendar decides what an entry is called and this only has to draw it.
*/
function AgendaWidget({ widget }: WidgetProps) {
const cfg = config(widget)
const message = text(cfg.message)
const live = useLiveValue(message || undefined)
if (!message) return <Unbound />
const now = new Date()
const today = new Date(now).setHours(0, 0, 0, 0) / 1000
const items = (Array.isArray(live?.value) ? live.value : [])
.filter(
(item): item is AgendaItem =>
typeof item?.title === "string" && Number.isFinite(item?.ts),
)
.filter((item) => item.ts >= today)
.sort((a, b) => a.ts - b.ts)
.slice(0, num(cfg.count, 5))
if (items.length === 0) {
return <p className="text-sm text-muted-foreground">Nothing coming up.</p>
}
return (
<ul className="grid gap-1.5 text-sm">
{items.map((item, index) => {
const when = new Date(item.ts * 1000)
return (
<li
// Two entries can share a title and a time; position is the identity.
key={`item-${index}`}
className="flex items-baseline gap-2"
>
<span className="shrink-0 text-muted-foreground tabular-nums">
{dayLabel(when, now)}
{item.all_day
? ""
: ` ${when.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
})}`}
</span>
<span className="truncate">{item.title}</span>
</li>
)
})}
</ul>
)
}
/**
* The last thing worth saying, held until something replaces it.
*
* No state of its own: the live store already keeps the latest value of a
* message, so what was published stays on the panel until the next one lands.
*/
function NotificationWidget({ widget }: WidgetProps) {
const cfg = config(widget)
const message = text(cfg.message)
const live = useLiveValue(message || undefined)
if (!message) return <Unbound />
const record = (live?.value ?? null) as Record<string, unknown> | null
const title = text(record?.title)
const body = text(record?.body)
if (!title && !body) {
return <p className="text-sm text-muted-foreground">Nothing to report.</p>
}
return (
<div className="grid gap-1">
{title ? (
<p
className={cn(
"font-medium",
record?.severity === "error" && "text-destructive",
)}
>
{title}
</p>
) : null}
{body ? <p className="text-sm text-muted-foreground">{body}</p> : null}
</div>
)
}
// ---------------------------------------------------------------------------
// Input
// ---------------------------------------------------------------------------
@@ -437,6 +575,12 @@ function SliderWidget({ widget, dashboard }: WidgetProps) {
const current =
dragging ?? (typeof live?.value === "number" ? live.value : min)
// A 2022 °C setpoint at 0.1 is unusable without marks to aim at. Past
// fifty of them the ticks are a smear, so the browser gets none.
const steps = step > 0 ? (max - min) / step : 0
const ticks = Number.isFinite(steps) && steps > 0 && steps <= 50 ? steps : 0
const ticksId = `ticks-${widget.id}`
return (
<div className="grid gap-2">
<div className="flex items-baseline justify-between">
@@ -447,12 +591,20 @@ function SliderWidget({ widget, dashboard }: WidgetProps) {
</span>
) : null}
</div>
{ticks ? (
<datalist id={ticksId}>
{Array.from({ length: Math.floor(ticks) + 1 }, (_, index) => (
<option key={index} value={min + index * step} />
))}
</datalist>
) : null}
<input
type="range"
min={min}
max={max}
step={step}
value={current}
list={ticks ? ticksId : undefined}
aria-label={widget.title || target}
className="h-11 w-full accent-[var(--primary)] md:h-8"
onChange={(event) => setDragging(Number(event.target.value))}
@@ -543,6 +695,8 @@ const RENDERERS: Partial<
gauge: GaugeWidget,
chart: ChartWidget,
markdown: MarkdownWidget,
agenda: AgendaWidget,
notification: NotificationWidget,
button: ButtonWidget,
switch: SwitchWidget,
slider: SliderWidget,