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);
}
/*
* 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
* moving is emphasized (Material). `<MotionConfig reducedMotion="user">` only
* covers `motion/react`, so CSS asks for itself.
*/
@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
reading changes by animating one number rather than re-pathing. */
.widget-gauge-arc {
+248 -109
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 { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -18,10 +18,15 @@ 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
// 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"
@@ -231,7 +236,9 @@ export function WidgetFrame({
<div
data-testid="widget-frame"
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,
)}
onClick={onClick}
@@ -549,45 +556,113 @@ function NotificationWidget({ widget }: WidgetProps) {
// 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) {
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,
live,
/** What the control draws: what it sent, until the engine answers. */
value: held ? held.value : live?.value,
send: (value: unknown) => {
if (!target) return
publish.mutate({
name: target,
value,
dashboard,
widget: widget.id,
label: widget.title || widget.id,
kind: widget.type,
})
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 } = usePublish(widget, dashboard)
const { target, send, pending, pulse } = usePublish(widget, dashboard)
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 (
<Button
variant="secondary"
className="w-full min-w-0"
disabled={pending}
onClick={() => send(cfg.value ?? true)}
>
<span className="truncate">
{text(cfg.label, widget.title || "Send")}
</span>
</Button>
<>
{pulse}
<Button
variant="secondary"
className="w-full min-w-0"
disabled={pending}
onClick={() => send(cfg.value ?? true)}
>
<span className="truncate">
{text(cfg.label, widget.title || "Send")}
</span>
</Button>
</>
)
}
@@ -599,22 +674,26 @@ function ButtonWidget({ widget, dashboard }: WidgetProps) {
*/
function SwitchWidget({ widget, dashboard }: WidgetProps) {
const cfg = config(widget)
const { target, live, send } = usePublish(widget, dashboard)
const { target, value, send, pulse } = usePublish(widget, dashboard)
if (!target) return <Unbound />
const on = live?.value === true
const on = value === true
return cfg.style === "button" ? (
<Button
variant={on ? "default" : "secondary"}
className="w-full min-w-0"
aria-pressed={on}
aria-label={widget.title || target}
onClick={() => send(!on)}
>
<span className="truncate">{on ? "On" : "Off"}</span>
</Button>
<>
{pulse}
<Button
variant={on ? "default" : "secondary"}
className="w-full min-w-0"
aria-pressed={on}
aria-label={widget.title || target}
onClick={() => send(!on)}
>
<span className="truncate">{on ? "On" : "Off"}</span>
</Button>
</>
) : (
<div className="flex items-center justify-between gap-2">
{pulse}
<span className="text-sm">{on ? "On" : "Off"}</span>
<Switch
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) {
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 max = num(cfg.max, 100)
const step = num(cfg.step, 1)
@@ -635,17 +777,11 @@ function SliderWidget({ widget, dashboard }: WidgetProps) {
const [dragging, setDragging] = useState<number | null>(null)
if (!target) return <Unbound />
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}`
const current = dragging ?? (typeof value === "number" ? value : min)
return (
<div className="grid gap-2">
{pulse}
<div className="flex items-baseline justify-between">
<span className="text-2xl tabular-nums">{current}</span>
{cfg.unit ? (
@@ -654,25 +790,18 @@ 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))}
// 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={() => {
if (dragging !== null) send(dragging)
setDragging(null)
@@ -682,13 +811,14 @@ function SliderWidget({ widget, dashboard }: WidgetProps) {
setDragging(null)
}}
/>
<SliderTicks min={min} max={max} step={step} />
</div>
)
}
function InputWidget({ widget, dashboard }: WidgetProps) {
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)
if (!target) return <Unbound />
@@ -700,16 +830,19 @@ function InputWidget({ widget, dashboard }: WidgetProps) {
}
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()
}}
/>
<>
{pulse}
<Input
value={draft ?? text(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()
}}
/>
</>
)
}
@@ -721,65 +854,71 @@ function InputWidget({ widget, dashboard }: WidgetProps) {
*/
function DropdownWidget({ widget, dashboard }: WidgetProps) {
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 }[]
if (!target) return <Unbound />
if (cfg.style === "segmented") {
const chosen = options.findIndex(
(option) => text(option.value) === text(live?.value),
(option) => text(option.value) === text(value),
)
return (
// 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
className="relative grid w-full min-w-0 items-center rounded-full border border-border p-1"
style={{
gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))`,
}}
>
<legend className="sr-only">{widget.title || target}</legend>
{chosen >= 0 ? (
// Equal tracks and no gap, so a segment is exactly its share of the
// padded box and the thumb needs no measuring.
<span
aria-hidden
className="widget-segment-thumb pointer-events-none absolute inset-y-1 rounded-full bg-accent"
style={{
left: `calc(0.25rem + ${chosen} * (100% - 0.5rem) / ${options.length})`,
width: `calc((100% - 0.5rem) / ${options.length})`,
}}
/>
) : null}
{options.map((option, index) => (
<button
key={text(option.value)}
type="button"
aria-pressed={index === chosen}
onClick={() => send(option.value)}
className={cn(
"relative z-10 h-11 min-w-0 truncate rounded-full px-2.5 text-sm transition-colors md:h-8",
index === chosen
? "text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50",
)}
>
{option.label ?? text(option.value)}
</button>
))}
</fieldset>
// The pulse stays outside the fieldset: that one is positioned itself,
// and the overlay belongs to the tile rather than to the control.
<>
{pulse}
{/* 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
className="relative grid w-full min-w-0 items-center rounded-full border border-border p-1"
style={{
gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))`,
}}
>
<legend className="sr-only">{widget.title || target}</legend>
{chosen >= 0 ? (
// Equal tracks and no gap, so a segment is exactly its share of the
// padded box and the thumb needs no measuring.
<span
aria-hidden
className="widget-segment-thumb pointer-events-none absolute inset-y-1 rounded-full bg-accent"
style={{
left: `calc(0.25rem + ${chosen} * (100% - 0.5rem) / ${options.length})`,
width: `calc((100% - 0.5rem) / ${options.length})`,
}}
/>
) : null}
{options.map((option, index) => (
<button
key={text(option.value)}
type="button"
aria-pressed={index === chosen}
onClick={() => send(option.value)}
className={cn(
"relative z-10 h-11 min-w-0 truncate rounded-full px-2.5 text-sm transition-colors md:h-8",
index === chosen
? "text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50",
)}
>
{option.label ?? text(option.value)}
</button>
))}
</fieldset>
</>
)
}
return (
<div className="grid gap-1.5">
{pulse}
<Label className="sr-only">{widget.title || target}</Label>
<Select
value={text(live?.value)}
onValueChange={(value) => send(asOriginal(value, options))}
value={text(value)}
onValueChange={(selected) => send(asOriginal(selected, options))}
>
<SelectTrigger className="w-full" aria-label={widget.title || target}>
<SelectValue placeholder="Choose" />