From a245caef99ad70ff0b04e3b4e3a2269bf64e5962 Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Mon, 14 Sep 2026 14:35:50 +0300 Subject: [PATCH] feat(ui): count the wait instead of describing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The empty console explained what would eventually appear there, which is the least interesting thing it could say while the chain is visibly producing blocks a few feet away. It now counts milliseconds since the last block, with the height linked, and resets each time one lands. The clock is `performance.now()`, not `observed_at`. That field is the server's wall clock, and subtracting it from the browser's would fold in whatever the two disagree by — a viewer whose machine runs fast would watch the counter go backwards. "Since this page saw it" is both unfakeable and the honest claim. Counting starts at the first block that arrives while the page is open, not at the first the ticker hands over: that one came from the snapshot and may be a minute old, so starting from when it was rendered would begin at zero and lie about it. The number is written straight to the DOM. Sixty React renders a second would redraw the mouth, the population and the whole console for one span of text — the same reason the flight marks are not React state. Under reduced motion it updates four times a second instead of sixty. The caption's idle line changes with it: "watching for the next block" beside a counter timing a block the page has plainly seen was a contradiction, so it now says which of the two is actually missing. Refs #17 Co-Authored-By: Claude Opus 5 --- web/src/App.tsx | 1 + web/src/components/WormholeStage.tsx | 84 +++++++++++++++++++++++++++- web/src/index.css | 7 +++ 3 files changed, 90 insertions(+), 2 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 82efeb5..1452463 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -195,6 +195,7 @@ export default function App() { {route.index === 'wormhole' ? ( diff --git a/web/src/components/WormholeStage.tsx b/web/src/components/WormholeStage.tsx index e0b973f..b863359 100644 --- a/web/src/components/WormholeStage.tsx +++ b/web/src/components/WormholeStage.tsx @@ -30,6 +30,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { Link } from 'react-router-dom' import { fetchWormhole } from '../api/rest' +import type { RecentBlock } from '../api/generated/RecentBlock' import type { WormholePulse } from '../api/socket' import { shortAddress, tokens } from '../lib/format' import { href } from '../lib/routes' @@ -187,10 +188,13 @@ function BlockLink({ chain, height }: { chain: string | null; height: number }) export function WormholeStage({ chain, + lastBlock, decimals, symbol, }: { chain: string | null + /** The newest block in the ticker, for the waiting counter. */ + lastBlock: RecentBlock | null decimals: number symbol: string }) { @@ -213,6 +217,69 @@ export function WormholeStage({ /** Monotonic line id. See `ConsoleLine.id` for why this is not derived. */ const nextLineId = useRef(0) + /** + * When *this page* saw the newest block, from the monotonic clock. + * + * Not `observed_at`. That is the server's wall clock, and subtracting it from + * the browser's would fold in whatever the two disagree by — a viewer whose + * machine is a minute fast would watch the counter run backwards. + * `performance.now()` cannot be skewed or stepped, and "since this page saw + * it" is the honest claim anyway. + * + * `null` until a block actually arrives while the page is open: the first + * block the ticker hands over came from the snapshot and may be a minute old, + * so counting from the moment it was *rendered* would start at zero and lie. + */ + const [blockSeenAt, setBlockSeenAt] = useState(null) + const lastHeight = useRef(null) + useEffect(() => { + if (lastBlock === null) return + if (lastHeight.current === null) { + // The snapshot's tail, not an arrival. Note it and wait for a real one. + lastHeight.current = lastBlock.height + return + } + if (lastBlock.height !== lastHeight.current) { + lastHeight.current = lastBlock.height + setBlockSeenAt(performance.now()) + } + }, [lastBlock]) + + /** + * The waiting counter, written straight to the DOM. + * + * A number changing sixty times a second through React state would re-render + * this component — the mouth, the population, the whole console — for one + * span of text. Same reasoning as the flight marks. + */ + const elapsed = useRef(null) + useEffect(() => { + if (blockSeenAt === null || log.length > 0) return + let frame = 0 + let timer = 0 + const paint = () => { + if (elapsed.current) { + const ms = Math.max(0, Math.round(performance.now() - blockSeenAt)) + elapsed.current.textContent = ms.toLocaleString('en-US') + } + } + if (reduced) { + // Still a counter, at a quarter of the flicker. + paint() + timer = window.setInterval(paint, 250) + } else { + const tick = () => { + paint() + frame = requestAnimationFrame(tick) + } + frame = requestAnimationFrame(tick) + } + return () => { + if (frame) cancelAnimationFrame(frame) + if (timer) window.clearInterval(timer) + } + }, [blockSeenAt, log.length, reduced]) + useEffect(() => { if (chain === null) return const controller = new AbortController() @@ -540,7 +607,11 @@ export function WormholeStage({
{last === null ? ( - Watching for the next block. + // The caption is about wormhole activity, the console's counter about + // blocks. Saying "watching for the next block" here while the counter + // beside it times one the page has plainly seen reads as a + // contradiction; this says which of the two is still missing. + Nothing has moved through yet. ) : ( <> #{last.height.toLocaleString('en-US')} @@ -576,7 +647,16 @@ export function WormholeStage({
    {log.length === 0 ? (
  1. - Each note a block commits or releases will be listed here as it happens. + {blockSeenAt === null || lastBlock === null ? ( + 'Each note a block commits or releases will be listed here as it happens.' + ) : ( + <> + + 0 + {' '} + ms since + + )}
  2. ) : ( log.map((line, i) => { diff --git a/web/src/index.css b/web/src/index.css index 2837c41..47f05fd 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1209,6 +1209,13 @@ tr.mine .share-bar > i { color: var(--text-muted); } +/* The waiting counter. Tabular figures so a number climbing through four + digits does not shuffle the words after it sideways sixty times a second. */ +.stage-elapsed { + color: var(--data-bright); + font-variant-numeric: tabular-nums; +} + .stage-log-height { color: var(--text-muted); min-width: 62px;