feat(ui): count the wait instead of describing it
All checks were successful
deploy / build (push) Successful in 43s
deploy / deploy-api (push) Has been skipped
deploy / deploy-web (push) Successful in 5s

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 14:35:50 +03:00
parent ee575ed33f
commit a245caef99
3 changed files with 90 additions and 2 deletions

View File

@@ -195,6 +195,7 @@ export default function App() {
{route.index === 'wormhole' ? (
<WormholeStage
chain={chain}
lastBlock={state.blocks[0] ?? null}
decimals={info?.token_decimals ?? 12}
symbol={info?.token_symbol ?? ''}
/>

View File

@@ -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<number | null>(null)
const lastHeight = useRef<number | null>(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<HTMLSpanElement>(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({
<div className="stage-caption">
{last === null ? (
<span className="stage-idle">Watching for the next block.</span>
// 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.
<span className="stage-idle">Nothing has moved through yet.</span>
) : (
<>
<strong>#{last.height.toLocaleString('en-US')}</strong>
@@ -576,7 +647,16 @@ export function WormholeStage({
<ol className="stage-log" aria-label="Recent notes through the wormhole">
{log.length === 0 ? (
<li className="stage-log-empty">
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.'
) : (
<>
<span ref={elapsed} className="stage-elapsed">
0
</span>{' '}
ms since <BlockLink chain={chain} height={lastBlock.height} />
</>
)}
</li>
) : (
log.map((line, i) => {

View File

@@ -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;