From dbeb49f5d397c421ac71265f4b7beb1700a88832 Mon Sep 17 00:00:00 2001 From: stroblme Date: Sat, 22 Aug 2026 11:51:20 +0200 Subject: [PATCH] Hold what a control sent until the engine confirms it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_018tULRZJUkZsw7rMJ3h4xvu --- .../src/components/Dashboard/dashboard.css | 29 ++ frontend/src/components/Dashboard/widgets.tsx | 357 ++++++++++++------ 2 files changed, 277 insertions(+), 109 deletions(-) diff --git a/frontend/src/components/Dashboard/dashboard.css b/frontend/src/components/Dashboard/dashboard.css index c13d4c7..422c65e 100644 --- a/frontend/src/components/Dashboard/dashboard.css +++ b/frontend/src/components/Dashboard/dashboard.css @@ -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). `` 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 { diff --git a/frontend/src/components/Dashboard/widgets.tsx b/frontend/src/components/Dashboard/widgets.tsx index edfec36..a193200 100644 --- a/frontend/src/components/Dashboard/widgets.tsx +++ b/frontend/src/components/Dashboard/widgets.tsx @@ -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({
(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 ? ( + + ) : 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 + // Nothing to hold: a button carries no reading, so the pulse and a refusal + // are the whole of its feedback. return ( - + <> + {pulse} + + ) } @@ -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 - const on = live?.value === true + const on = value === true return cfg.style === "button" ? ( - + <> + {pulse} + + ) : (
+ {pulse} {on ? "On" : "Off"} Number.isInteger(steps / count)) ?? 4 +} + +/** + * The scale under the track, drawn rather than declared. + * + * A `datalist` gives unlabelled marks at best — no browser renders + * `