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:
2026-08-16 09:33:02 +02:00
co-authored by Claude Fable 5
parent 895bd89b2b
commit be6d4fc13c
15 changed files with 1388 additions and 7 deletions
@@ -6,6 +6,9 @@ import { type LogLine, liveStore } from "./liveStore"
import { flowKeys } from "./queries"
const RECONNECT_MIN = 1000
/** How many components want the socket open. */
let mounted = 0
const RECONNECT_MAX = 30000
type FlowEvent =
@@ -52,6 +55,14 @@ export function useFlowSocket(onAuthFailure?: () => void): void {
const queryClient = useQueryClient()
useEffect(() => {
// The editor and a dashboard can both be mounted; one socket serves both,
// and the second caller just rides along.
mounted += 1
if (mounted > 1) {
return () => {
mounted -= 1
}
}
closed.current = false
const connect = () => {
@@ -126,6 +137,7 @@ export function useFlowSocket(onAuthFailure?: () => void): void {
connect()
return () => {
mounted -= 1
closed.current = true
if (timer.current) clearTimeout(timer.current)
socket.current?.close()