feat(ui): count the wait instead of describing it
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:
@@ -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 ?? ''}
|
||||
/>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user