Build dashboards you can actually look at and press
Widgets bind to a message name and read it live off the socket the editor already had — lifted out of the flow editor so a dashboard route gets the same values, which also gives the home page live data for free. The input widgets close the loop the other way: a slider publishes into the graph and whatever consumes that message runs. Verified end to end in the running app — moving a slider set a flow input, and the stat bound to what the flow computed from it followed. View mode is plain CSS grid. A wall panel that only displays should not download the code that lets someone drag things around, and it now does not. Editing is a widget picker, a per-widget width control and a settings card fed by the message catalog. No new dependencies: the slider is a range input, the gauge is an arc, and the markdown is a five-line subset. Charts are the one widget still missing — they need a charting library and the chart tokens the design guidelines reserved — so they are stored and validated but not offered. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011LF61rxW1FG5YCD2J9YqjY
This commit is contained in:
@@ -0,0 +1,446 @@
|
||||
import { useState } from "react"
|
||||
|
||||
import type { WidgetDef } from "@/client"
|
||||
import { useLiveValue } from "@/components/Flow/liveStore"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { usePublishMessage } from "./queries"
|
||||
|
||||
/** Widget types that put a value into the graph rather than read one. */
|
||||
export const INPUT_WIDGETS = new Set([
|
||||
"button",
|
||||
"switch",
|
||||
"slider",
|
||||
"input",
|
||||
"dropdown",
|
||||
])
|
||||
|
||||
export type WidgetKind = WidgetDef["type"]
|
||||
|
||||
export const WIDGET_LABELS: Record<WidgetKind, string> = {
|
||||
stat: "Value",
|
||||
gauge: "Gauge",
|
||||
chart: "Chart",
|
||||
markdown: "Text",
|
||||
button: "Button",
|
||||
switch: "Switch",
|
||||
slider: "Slider",
|
||||
input: "Input",
|
||||
dropdown: "Dropdown",
|
||||
}
|
||||
|
||||
/** Default footprint per type, in grid units. */
|
||||
export const WIDGET_SIZES: Record<WidgetKind, { w: number; h: number }> = {
|
||||
stat: { w: 3, h: 2 },
|
||||
gauge: { w: 3, h: 3 },
|
||||
chart: { w: 6, h: 4 },
|
||||
markdown: { w: 6, h: 2 },
|
||||
button: { w: 3, h: 2 },
|
||||
switch: { w: 3, h: 2 },
|
||||
slider: { w: 4, h: 2 },
|
||||
input: { w: 4, h: 2 },
|
||||
dropdown: { w: 4, h: 2 },
|
||||
}
|
||||
|
||||
function config(widget: WidgetDef): Record<string, unknown> {
|
||||
return (widget.config ?? {}) as Record<string, unknown>
|
||||
}
|
||||
|
||||
function text(value: unknown, fallback = ""): string {
|
||||
return value === null || value === undefined ? fallback : String(value)
|
||||
}
|
||||
|
||||
function num(value: unknown, fallback: number): number {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : fallback
|
||||
}
|
||||
|
||||
/** Formats a reading the way a panel across the room should read it. */
|
||||
function format(value: unknown, precision: number | null): string {
|
||||
if (value === null || value === undefined) return "—"
|
||||
if (typeof value === "boolean") return value ? "On" : "Off"
|
||||
if (typeof value === "number") {
|
||||
return precision === null ? String(value) : value.toFixed(precision)
|
||||
}
|
||||
if (typeof value === "object") return JSON.stringify(value)
|
||||
return String(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* The frame every widget sits in.
|
||||
*
|
||||
* A card rather than floating chrome: a dashboard is content, and the panels
|
||||
* that float are the ones that sit over something.
|
||||
*/
|
||||
export function WidgetFrame({
|
||||
title,
|
||||
children,
|
||||
actions,
|
||||
className,
|
||||
}: {
|
||||
title?: string
|
||||
children: React.ReactNode
|
||||
actions?: React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full flex-col gap-2 overflow-hidden rounded-lg border border-border bg-card p-4 shadow-e1",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{title || actions ? (
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
{title ? (
|
||||
<span className="truncate text-sm text-muted-foreground">
|
||||
{title}
|
||||
</span>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{actions}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex min-h-0 flex-1 flex-col justify-center">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Unbound() {
|
||||
return <p className="text-sm text-muted-foreground">Pick a message.</p>
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Display
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function StatWidget({ widget }: { widget: WidgetDef }) {
|
||||
const cfg = config(widget)
|
||||
const message = text(cfg.message)
|
||||
const live = useLiveValue(message || undefined)
|
||||
if (!message) return <Unbound />
|
||||
|
||||
const precision = cfg.precision === undefined ? null : num(cfg.precision, 1)
|
||||
return (
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="truncate text-3xl tabular-nums">
|
||||
{format(live?.value, precision)}
|
||||
</span>
|
||||
{cfg.unit ? (
|
||||
<span className="text-base text-muted-foreground">
|
||||
{text(cfg.unit)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A dial, drawn as an arc.
|
||||
*
|
||||
* The number is always written out as well: a reading that only exists as an
|
||||
* angle is unreadable to anyone who cannot judge one.
|
||||
*/
|
||||
function GaugeWidget({ widget }: { widget: WidgetDef }) {
|
||||
const cfg = config(widget)
|
||||
const message = text(cfg.message)
|
||||
const live = useLiveValue(message || undefined)
|
||||
if (!message) return <Unbound />
|
||||
|
||||
const min = num(cfg.min, 0)
|
||||
const max = num(cfg.max, 100)
|
||||
const value = typeof live?.value === "number" ? live.value : null
|
||||
const fraction =
|
||||
value === null
|
||||
? 0
|
||||
: Math.min(1, Math.max(0, (value - min) / (max - min || 1)))
|
||||
|
||||
// A 240° arc, the shape a dial is expected to have.
|
||||
const radius = 42
|
||||
const sweep = 240
|
||||
const start = 150
|
||||
const point = (angle: number) => {
|
||||
const radians = (angle * Math.PI) / 180
|
||||
return [50 + radius * Math.cos(radians), 50 + radius * Math.sin(radians)]
|
||||
}
|
||||
const arc = (from: number, to: number) => {
|
||||
const [x1, y1] = point(from)
|
||||
const [x2, y2] = point(to)
|
||||
const large = Math.abs(to - from) > 180 ? 1 : 0
|
||||
return `M ${x1} ${y1} A ${radius} ${radius} 0 ${large} 1 ${x2} ${y2}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center">
|
||||
<svg
|
||||
viewBox="0 0 100 78"
|
||||
className="h-full max-h-full w-full"
|
||||
role="img"
|
||||
aria-label={`${format(value, 1)} of ${max}`}
|
||||
>
|
||||
<path
|
||||
d={arc(start, start + sweep)}
|
||||
fill="none"
|
||||
stroke="var(--muted)"
|
||||
strokeWidth={9}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{fraction > 0 ? (
|
||||
<path
|
||||
d={arc(start, start + sweep * fraction)}
|
||||
fill="none"
|
||||
stroke="var(--primary)"
|
||||
strokeWidth={9}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
) : null}
|
||||
<text
|
||||
x={50}
|
||||
y={54}
|
||||
textAnchor="middle"
|
||||
className="fill-foreground text-[13px] tabular-nums"
|
||||
>
|
||||
{format(
|
||||
value,
|
||||
cfg.precision === undefined ? 1 : num(cfg.precision, 1),
|
||||
)}
|
||||
{cfg.unit ? text(cfg.unit) : ""}
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A very small markdown subset: headings, bold, code, links, list items.
|
||||
*
|
||||
* Enough for the labels and notes a dashboard carries, and not worth a parser.
|
||||
*/
|
||||
function MarkdownWidget({ widget }: { widget: WidgetDef }) {
|
||||
const content = text(config(widget).content)
|
||||
const lines = content.split("\n")
|
||||
return (
|
||||
<div className="grid gap-1 text-sm">
|
||||
{lines.map((line, index) => {
|
||||
const heading = /^(#{1,3})\s+(.*)$/.exec(line)
|
||||
const body = heading ? heading[2] : line.replace(/^[-*]\s+/, "")
|
||||
const bullet = !heading && /^[-*]\s+/.test(line)
|
||||
return (
|
||||
<p
|
||||
// Plain text: position is the only identity a line has.
|
||||
key={`line-${index}`}
|
||||
className={cn(
|
||||
heading?.[1] === "#" && "text-lg font-medium",
|
||||
heading?.[1] === "##" && "font-medium",
|
||||
heading?.[1] === "###" && "text-muted-foreground",
|
||||
bullet && "pl-4",
|
||||
)}
|
||||
>
|
||||
{bullet ? "• " : ""}
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Publishing, with the value shown as sent until the engine confirms it. */
|
||||
function usePublish(widget: WidgetDef) {
|
||||
const cfg = config(widget)
|
||||
const target = text(cfg.target)
|
||||
const publish = usePublishMessage()
|
||||
const live = useLiveValue(target || undefined)
|
||||
return {
|
||||
target,
|
||||
live,
|
||||
send: (value: unknown) => {
|
||||
if (!target) return
|
||||
publish.mutate({ name: target, value })
|
||||
},
|
||||
pending: publish.isPending,
|
||||
}
|
||||
}
|
||||
|
||||
function ButtonWidget({ widget }: { widget: WidgetDef }) {
|
||||
const cfg = config(widget)
|
||||
const { target, send, pending } = usePublish(widget)
|
||||
if (!target) return <Unbound />
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
disabled={pending}
|
||||
onClick={() => send(cfg.value ?? true)}
|
||||
>
|
||||
{text(cfg.label, widget.title || "Send")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function SwitchWidget({ widget }: { widget: WidgetDef }) {
|
||||
const { target, live, send } = usePublish(widget)
|
||||
if (!target) return <Unbound />
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm">{live?.value === true ? "On" : "Off"}</span>
|
||||
<Switch
|
||||
checked={live?.value === true}
|
||||
aria-label={widget.title || target}
|
||||
onCheckedChange={(checked) => send(checked)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SliderWidget({ widget }: { widget: WidgetDef }) {
|
||||
const cfg = config(widget)
|
||||
const { target, live, send } = usePublish(widget)
|
||||
const min = num(cfg.min, 0)
|
||||
const max = num(cfg.max, 100)
|
||||
const step = num(cfg.step, 1)
|
||||
// While dragging, the handle follows the finger rather than the engine.
|
||||
const [dragging, setDragging] = useState<number | null>(null)
|
||||
if (!target) return <Unbound />
|
||||
|
||||
const current =
|
||||
dragging ?? (typeof live?.value === "number" ? live.value : min)
|
||||
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="text-2xl tabular-nums">{current}</span>
|
||||
{cfg.unit ? (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{text(cfg.unit)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={current}
|
||||
aria-label={widget.title || target}
|
||||
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
|
||||
// per pixel and flood whatever is listening.
|
||||
onPointerUp={() => {
|
||||
if (dragging !== null) send(dragging)
|
||||
setDragging(null)
|
||||
}}
|
||||
onKeyUp={() => {
|
||||
if (dragging !== null) send(dragging)
|
||||
setDragging(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InputWidget({ widget }: { widget: WidgetDef }) {
|
||||
const cfg = config(widget)
|
||||
const { target, live, send } = usePublish(widget)
|
||||
const [draft, setDraft] = useState<string | null>(null)
|
||||
if (!target) return <Unbound />
|
||||
|
||||
const asNumber = cfg.dtype === "float" || cfg.dtype === "int"
|
||||
const commit = () => {
|
||||
if (draft === null) return
|
||||
send(asNumber ? Number(draft) || 0 : draft)
|
||||
setDraft(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
value={draft ?? text(live?.value)}
|
||||
type={asNumber ? "number" : "text"}
|
||||
aria-label={widget.title || target}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") commit()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownWidget({ widget }: { widget: WidgetDef }) {
|
||||
const cfg = config(widget)
|
||||
const { target, live, send } = usePublish(widget)
|
||||
const options = (cfg.options ?? []) as { label?: string; value?: unknown }[]
|
||||
if (!target) return <Unbound />
|
||||
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="sr-only">{widget.title || target}</Label>
|
||||
<Select
|
||||
value={text(live?.value)}
|
||||
onValueChange={(value) => send(asOriginal(value, options))}
|
||||
>
|
||||
<SelectTrigger aria-label={widget.title || target}>
|
||||
<SelectValue placeholder="Choose" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={text(option.value)} value={text(option.value)}>
|
||||
{option.label ?? text(option.value)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Radix hands back a string; the message wants whatever was configured. */
|
||||
function asOriginal(selected: string, options: { value?: unknown }[]): unknown {
|
||||
const match = options.find((option) => text(option.value) === selected)
|
||||
return match ? match.value : selected
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const RENDERERS: Partial<
|
||||
Record<WidgetKind, (props: { widget: WidgetDef }) => React.ReactNode>
|
||||
> = {
|
||||
stat: StatWidget,
|
||||
gauge: GaugeWidget,
|
||||
markdown: MarkdownWidget,
|
||||
button: ButtonWidget,
|
||||
switch: SwitchWidget,
|
||||
slider: SliderWidget,
|
||||
input: InputWidget,
|
||||
dropdown: DropdownWidget,
|
||||
}
|
||||
|
||||
export function WidgetBody({ widget }: { widget: WidgetDef }) {
|
||||
const Renderer = RENDERERS[widget.type]
|
||||
if (!Renderer) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{WIDGET_LABELS[widget.type]} widgets are not drawn yet.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
return <Renderer widget={widget} />
|
||||
}
|
||||
Reference in New Issue
Block a user