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:
@@ -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 {
|
||||
|
||||
@@ -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 20–22 °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 0–1 at 0.01 reads "0.25" and 0–100 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 20–22 °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" />
|
||||
|
||||
Reference in New Issue
Block a user