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 42a792b6de
commit dbeb49f5d3
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 {