Hold what a control sent until the engine confirms it

An input widget published over HTTP and read the result back over the socket,
so between the two it drew the pre-publish value — a slider handle let go of
visibly snapped back. usePublish now holds the sent value until the echo
matches, the publish is refused, or 3 s pass, and every input widget (button,
switch, slider, input, dropdown) draws that instead of the live value.

A publish in flight pulses a primary ring over the tile: an absolutely
positioned, inert overlay, so nothing resizes or shifts. A refusal drops the
hold and toasts, naming the message. Success stays silent.

The slider also draws its own scale — min, max and a few labelled stops that
land on steps — replacing the unlabelled datalist marks that dropped out past
fifty steps.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tULRZJUkZsw7rMJ3h4xvu
This commit is contained in:
2026-08-22 11:51:20 +02:00
co-authored by Claude Opus 5
parent 912986142f
commit 16ba11fb07
2 changed files with 277 additions and 109 deletions
@@ -72,12 +72,41 @@
height: calc(var(--h) * 5rem + (var(--h) - 1) * 0.75rem); height: calc(var(--h) * 5rem + (var(--h) - 1) * 0.75rem);
} }
/*
* A publish in flight, drawn as a ring just inside the tile's own edge.
*
* An overlay rather than anything the widget owns: it takes no layout box and
* no clicks, so a control being used never resizes its tile or moves what sits
* around it. Full opacity at rest, so a panel that asks for no motion still
* gets the ring — the animation only breathes it.
*/
.widget-transmit {
position: absolute;
inset: 0;
pointer-events: none;
border-radius: var(--radius-lg);
box-shadow: inset 0 0 0 2px var(--primary);
}
/* /*
* Motion. A value settling is a neutral state change; a selection indicator * Motion. A value settling is a neutral state change; a selection indicator
* moving is emphasized (Material). `<MotionConfig reducedMotion="user">` only * moving is emphasized (Material). `<MotionConfig reducedMotion="user">` only
* covers `motion/react`, so CSS asks for itself. * covers `motion/react`, so CSS asks for itself.
*/ */
@media (prefers-reduced-motion: no-preference) { @media (prefers-reduced-motion: no-preference) {
/* One beat per second, which reads as "on its way" from across a room
without becoming the loudest thing in a browser tab. */
.widget-transmit {
animation: widget-transmit var(--duration-pulse) var(--ease-standard)
infinite alternate;
}
@keyframes widget-transmit {
from {
opacity: 0.2;
}
}
/* The arc is the full 240 degrees and the dash hides the rest of it, so the /* The arc is the full 240 degrees and the dash hides the rest of it, so the
reading changes by animating one number rather than re-pathing. */ reading changes by animating one number rather than re-pathing. */
.widget-gauge-arc { .widget-gauge-arc {
+178 -39
View File
@@ -1,6 +1,6 @@
import { useState } from "react" import { useEffect, useState } from "react"
import type { WidgetDef } from "@/client" import type { ApiError, WidgetDef } from "@/client"
import { useLiveValue } from "@/components/Flow/liveStore" import { useLiveValue } from "@/components/Flow/liveStore"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@@ -18,10 +18,15 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip" } from "@/components/ui/tooltip"
import useCustomToast from "@/hooks/useCustomToast"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { handleError } from "@/utils"
import { BarWidget, segmentsOf } from "./BarWidget" import { BarWidget, segmentsOf } from "./BarWidget"
import { ChartWidget } from "./ChartWidget" import { ChartWidget } from "./ChartWidget"
import { ClockWidget } from "./ClockWidget" import { ClockWidget } from "./ClockWidget"
// The transmit overlay'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 { ForecastWidget } from "./ForecastWidget"
import { IconWidget } from "./IconWidget" import { IconWidget } from "./IconWidget"
import { usePublishMessage } from "./queries" import { usePublishMessage } from "./queries"
@@ -231,7 +236,9 @@ export function WidgetFrame({
<div <div
data-testid="widget-frame" data-testid="widget-frame"
className={cn( className={cn(
"flex h-full flex-col gap-2 overflow-hidden rounded-lg border border-border bg-card p-4 shadow-e1", // `relative` is what the transmit overlay hangs off: a control's pulse
// is drawn over the whole tile and must take no layout box at all.
"relative flex h-full flex-col gap-2 overflow-hidden rounded-lg border border-border bg-card p-4 shadow-e1",
className, className,
)} )}
onClick={onClick} onClick={onClick}
@@ -549,35 +556,102 @@ function NotificationWidget({ widget }: WidgetProps) {
// Input // Input
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** Publishing, with the value shown as sent until the engine confirms it. */ /**
* 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) { function usePublish(widget: WidgetDef, dashboard: string) {
const cfg = config(widget) const cfg = config(widget)
const target = text(cfg.target) const target = text(cfg.target)
const publish = usePublishMessage() const publish = usePublishMessage()
const live = useLiveValue(target || undefined) 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 { return {
target, target,
live, /** What the control draws: what it sent, until the engine answers. */
value: held ? held.value : live?.value,
send: (value: unknown) => { send: (value: unknown) => {
if (!target) return if (!target) return
publish.mutate({ setHeld({ value })
publish.mutate(
{
name: target, name: target,
value, value,
dashboard, dashboard,
widget: widget.id, widget: widget.id,
label: widget.title || widget.id, label: widget.title || widget.id,
kind: widget.type, 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, pending: publish.isPending,
} }
} }
function ButtonWidget({ widget, dashboard }: WidgetProps) { function ButtonWidget({ widget, dashboard }: WidgetProps) {
const cfg = config(widget) const cfg = config(widget)
const { target, send, pending } = usePublish(widget, dashboard) const { target, send, pending, pulse } = usePublish(widget, dashboard)
if (!target) return <Unbound /> if (!target) return <Unbound />
// Nothing to hold: a button carries no reading, so the pulse and a refusal
// are the whole of its feedback.
return ( return (
<>
{pulse}
<Button <Button
variant="secondary" variant="secondary"
className="w-full min-w-0" className="w-full min-w-0"
@@ -588,6 +662,7 @@ function ButtonWidget({ widget, dashboard }: WidgetProps) {
{text(cfg.label, widget.title || "Send")} {text(cfg.label, widget.title || "Send")}
</span> </span>
</Button> </Button>
</>
) )
} }
@@ -599,11 +674,13 @@ function ButtonWidget({ widget, dashboard }: WidgetProps) {
*/ */
function SwitchWidget({ widget, dashboard }: WidgetProps) { function SwitchWidget({ widget, dashboard }: WidgetProps) {
const cfg = config(widget) const cfg = config(widget)
const { target, live, send } = usePublish(widget, dashboard) const { target, value, send, pulse } = usePublish(widget, dashboard)
if (!target) return <Unbound /> if (!target) return <Unbound />
const on = live?.value === true const on = value === true
return cfg.style === "button" ? ( return cfg.style === "button" ? (
<>
{pulse}
<Button <Button
variant={on ? "default" : "secondary"} variant={on ? "default" : "secondary"}
className="w-full min-w-0" className="w-full min-w-0"
@@ -613,8 +690,10 @@ function SwitchWidget({ widget, dashboard }: WidgetProps) {
> >
<span className="truncate">{on ? "On" : "Off"}</span> <span className="truncate">{on ? "On" : "Off"}</span>
</Button> </Button>
</>
) : ( ) : (
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
{pulse}
<span className="text-sm">{on ? "On" : "Off"}</span> <span className="text-sm">{on ? "On" : "Off"}</span>
<Switch <Switch
checked={on} checked={on}
@@ -625,9 +704,72 @@ function SwitchWidget({ widget, dashboard }: WidgetProps) {
) )
} }
/**
* How many intervals the scale under a slider is cut into.
*
* A mark lands on a step wherever the range divides evenly, so a value aimed
* at is one the slider can stop on. Five labels is what stays readable across a
* room; a range of five steps or fewer is simply labelled in full.
*/
function tickIntervals(steps: number): number {
if (!Number.isFinite(steps) || steps <= 0) return 4
if (steps <= 5) return Math.max(1, Math.round(steps))
return [4, 3, 2].find((count) => Number.isInteger(steps / count)) ?? 4
}
/**
* The scale under the track, drawn rather than declared.
*
* A `datalist` gives unlabelled marks at best — no browser renders
* `<option label>` for a range — and a 2022 °C setpoint is unusable without
* numbers to aim at.
*/
function SliderTicks({
min,
max,
step,
}: {
min: number
max: number
step: number
}) {
const span = max - min
if (!(span > 0)) return null
const intervals = tickIntervals(step > 0 ? span / step : 0)
// Taken off the step, so 01 at 0.01 reads "0.25" and 0100 at 1 reads "25"
// without a precision setting of its own.
const digits = (String(step).split(".")[1] ?? "").length
return (
// Decoration: the input itself announces min, max and where it stands.
<div
aria-hidden
className="relative h-4 text-xs text-muted-foreground tabular-nums"
>
{Array.from({ length: intervals + 1 }, (_, index) => {
const percent = (index / intervals) * 100
return (
<span
key={index}
className="absolute top-0"
// Shifted by its own share of itself: the first label sits flush
// left and the last flush right, so neither hangs off the tile.
style={{
left: `${percent}%`,
transform: `translateX(-${percent}%)`,
}}
>
{Number((min + (span * index) / intervals).toFixed(digits))}
</span>
)
})}
</div>
)
}
function SliderWidget({ widget, dashboard }: WidgetProps) { function SliderWidget({ widget, dashboard }: WidgetProps) {
const cfg = config(widget) const cfg = config(widget)
const { target, live, send } = usePublish(widget, dashboard) const { target, value, send, pulse } = usePublish(widget, dashboard)
const min = num(cfg.min, 0) const min = num(cfg.min, 0)
const max = num(cfg.max, 100) const max = num(cfg.max, 100)
const step = num(cfg.step, 1) const step = num(cfg.step, 1)
@@ -635,17 +777,11 @@ function SliderWidget({ widget, dashboard }: WidgetProps) {
const [dragging, setDragging] = useState<number | null>(null) const [dragging, setDragging] = useState<number | null>(null)
if (!target) return <Unbound /> if (!target) return <Unbound />
const current = const current = dragging ?? (typeof value === "number" ? value : min)
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 ( return (
<div className="grid gap-2"> <div className="grid gap-2">
{pulse}
<div className="flex items-baseline justify-between"> <div className="flex items-baseline justify-between">
<span className="text-2xl tabular-nums">{current}</span> <span className="text-2xl tabular-nums">{current}</span>
{cfg.unit ? ( {cfg.unit ? (
@@ -654,25 +790,18 @@ function SliderWidget({ widget, dashboard }: WidgetProps) {
</span> </span>
) : null} ) : null}
</div> </div>
{ticks ? (
<datalist id={ticksId}>
{Array.from({ length: Math.floor(ticks) + 1 }, (_, index) => (
<option key={index} value={min + index * step} />
))}
</datalist>
) : null}
<input <input
type="range" type="range"
min={min} min={min}
max={max} max={max}
step={step} step={step}
value={current} value={current}
list={ticks ? ticksId : undefined}
aria-label={widget.title || target} aria-label={widget.title || target}
className="h-11 w-full accent-[var(--primary)] md:h-8" className="h-11 w-full accent-[var(--primary)] md:h-8"
onChange={(event) => setDragging(Number(event.target.value))} onChange={(event) => setDragging(Number(event.target.value))}
// Only the release publishes: dragging would otherwise send a value // Only the release publishes: dragging would otherwise send a value
// per pixel and flood whatever is listening. // per pixel and flood whatever is listening. Letting go hands the
// value to the hold, so the handle stays where it was put.
onPointerUp={() => { onPointerUp={() => {
if (dragging !== null) send(dragging) if (dragging !== null) send(dragging)
setDragging(null) setDragging(null)
@@ -682,13 +811,14 @@ function SliderWidget({ widget, dashboard }: WidgetProps) {
setDragging(null) setDragging(null)
}} }}
/> />
<SliderTicks min={min} max={max} step={step} />
</div> </div>
) )
} }
function InputWidget({ widget, dashboard }: WidgetProps) { function InputWidget({ widget, dashboard }: WidgetProps) {
const cfg = config(widget) const cfg = config(widget)
const { target, live, send } = usePublish(widget, dashboard) const { target, value, send, pulse } = usePublish(widget, dashboard)
const [draft, setDraft] = useState<string | null>(null) const [draft, setDraft] = useState<string | null>(null)
if (!target) return <Unbound /> if (!target) return <Unbound />
@@ -700,8 +830,10 @@ function InputWidget({ widget, dashboard }: WidgetProps) {
} }
return ( return (
<>
{pulse}
<Input <Input
value={draft ?? text(live?.value)} value={draft ?? text(value)}
type={asNumber ? "number" : "text"} type={asNumber ? "number" : "text"}
aria-label={widget.title || target} aria-label={widget.title || target}
onChange={(event) => setDraft(event.target.value)} onChange={(event) => setDraft(event.target.value)}
@@ -710,6 +842,7 @@ function InputWidget({ widget, dashboard }: WidgetProps) {
if (event.key === "Enter") commit() if (event.key === "Enter") commit()
}} }}
/> />
</>
) )
} }
@@ -721,20 +854,24 @@ function InputWidget({ widget, dashboard }: WidgetProps) {
*/ */
function DropdownWidget({ widget, dashboard }: WidgetProps) { function DropdownWidget({ widget, dashboard }: WidgetProps) {
const cfg = config(widget) const cfg = config(widget)
const { target, live, send } = usePublish(widget, dashboard) const { target, value, send, pulse } = usePublish(widget, dashboard)
const options = (cfg.options ?? []) as { label?: string; value?: unknown }[] const options = (cfg.options ?? []) as { label?: string; value?: unknown }[]
if (!target) return <Unbound /> if (!target) return <Unbound />
if (cfg.style === "segmented") { if (cfg.style === "segmented") {
const chosen = options.findIndex( const chosen = options.findIndex(
(option) => text(option.value) === text(live?.value), (option) => text(option.value) === text(value),
) )
return ( return (
// The one segmented shape: a single border pill, no dividers, // The pulse stays outside the fieldset: that one is positioned itself,
// transparent segments, bg-accent on the selected one — held by a thumb // and the overlay belongs to the tile rather than to the control.
// that slides rather than a fill that jumps from cell to cell. A <>
// `fieldset` carries `min-inline-size: min-content` from the UA sheet, {pulse}
// which `w-full` does not override. {/* The one segmented shape: a single border pill, no dividers,
transparent segments, bg-accent on the selected one — held by a
thumb that slides rather than a fill that jumps from cell to cell.
A `fieldset` carries `min-inline-size: min-content` from the UA
sheet, which `w-full` does not override. */}
<fieldset <fieldset
className="relative grid w-full min-w-0 items-center rounded-full border border-border p-1" className="relative grid w-full min-w-0 items-center rounded-full border border-border p-1"
style={{ style={{
@@ -771,15 +908,17 @@ function DropdownWidget({ widget, dashboard }: WidgetProps) {
</button> </button>
))} ))}
</fieldset> </fieldset>
</>
) )
} }
return ( return (
<div className="grid gap-1.5"> <div className="grid gap-1.5">
{pulse}
<Label className="sr-only">{widget.title || target}</Label> <Label className="sr-only">{widget.title || target}</Label>
<Select <Select
value={text(live?.value)} value={text(value)}
onValueChange={(value) => send(asOriginal(value, options))} onValueChange={(selected) => send(asOriginal(selected, options))}
> >
<SelectTrigger className="w-full" aria-label={widget.title || target}> <SelectTrigger className="w-full" aria-label={widget.title || target}>
<SelectValue placeholder="Choose" /> <SelectValue placeholder="Choose" />