feat(ui): two columns for the stage, and a console that links out
deploy / build (push) Successful in 1m24s
deploy / deploy-api (push) Has been skipped
deploy / deploy-web (push) Successful in 4s

Stacked, the mouth and the lines describing it could not be read at the same
time, which is the whole point of having both. They sit side by side once
there is room: the figure takes the space left over and the console takes a
fixed column.

The breakpoint is measured rather than chosen. At 1000px the console came out
360px wide and *every* line wrapped to two — worse than stacking — because one
line needs about 354px for its height, kind and amount columns plus a
shortened address and three gaps. The column's minimum is 400px and the
breakpoint is where that can exist without starving the figure.

The console now links out. A block height goes to the block, an address to the
account, and the count of what is not shown names its block instead of saying
"this block" — so the line that tells you 897 notes went unlisted is also the
way to go and read them. Links fall back to plain text before the chain is
known, because `href` would otherwise render `//block/43466`, which looks
clickable and goes nowhere.

They are marked with a dotted underline rather than a colour, following
`a.ticker-height`: the console's columns already carry meaning through colour —
muted for the height and address, bright for the kind — and tinting a link
would make it momentarily read as a different column.

Refs #17

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 14:25:59 +03:00
co-authored by Claude Opus 5
parent 578a54ef87
commit ee575ed33f
2 changed files with 184 additions and 83 deletions
+122 -80
View File
@@ -27,9 +27,12 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { fetchWormhole } from '../api/rest'
import type { WormholePulse } from '../api/socket'
import { shortAddress, tokens } from '../lib/format'
import { href } from '../lib/routes'
import { useObserverInstance } from '../lib/observer-context'
const HEIGHT = 210
@@ -169,6 +172,19 @@ function ease(t: number): number {
return t * t * (3 - 2 * t)
}
/**
* A block height, as a link to it where there is a chain to link into.
*
* Falls back to plain text rather than a dead link when the chain is not yet
* known: `href` would render `//block/43466`, which looks clickable and goes
* nowhere. The number is the same either way, so nothing is lost.
*/
function BlockLink({ chain, height }: { chain: string | null; height: number }) {
const label = `#${height.toLocaleString('en-US')}`
if (chain === null) return <>{label}</>
return <Link to={href({ chain, block: String(height) })}>{label}</Link>
}
export function WormholeStage({
chain,
decimals,
@@ -449,90 +465,107 @@ export function WormholeStage({
const { cx, cy } = geo
return (
<div ref={wrap} className="stage-wrap">
<svg
className="chart stage"
width={width}
height={HEIGHT}
viewBox={`0 0 ${width} ${HEIGHT}`}
role="img"
aria-label={[
outstanding === null
? 'The wormhole.'
: `The wormhole, holding ${outstanding} notes committed and not yet spent.`,
last === null
? 'Waiting for the next block.'
: `Block ${last.height} committed ${last.notesIn} notes and released ${last.notesOut}.`,
].join(' ')}
>
{/* The mouth. Three ellipses of the one hue at descending wash weight —
<div className="stage-wrap">
{/* The ref is on the figure rather than the panel: in the two-column
layout the SVG's width is its *column's* width, and measuring the
panel would size the mouth for space the console is occupying. */}
<div ref={wrap} className="stage-figure">
<svg
className="chart stage"
width={width}
height={HEIGHT}
viewBox={`0 0 ${width} ${HEIGHT}`}
role="img"
aria-label={[
outstanding === null
? 'The wormhole.'
: `The wormhole, holding ${outstanding} notes committed and not yet spent.`,
last === null
? 'Waiting for the next block.'
: `Block ${last.height} committed ${last.notesIn} notes and released ${last.notesOut}.`,
].join(' ')}
>
{/* The mouth. Three ellipses of the one hue at descending wash weight —
a throat seen edge-on, not a second series. */}
<ellipse className="stage-rim stage-rim-3" cx={cx} cy={cy} rx={rx} ry={ry} />
<ellipse className="stage-rim stage-rim-2" cx={cx} cy={cy} rx={rx * 0.64} ry={ry * 0.64} />
<ellipse className="stage-rim stage-rim-1" cx={cx} cy={cy} rx={rx * 0.33} ry={ry * 0.33} />
<ellipse className="stage-core" cx={cx} cy={cy} rx={rx * 0.11} ry={ry * 0.11} />
{/* The notes already inside, turning slowly. Drawn before the marks in
flight so an arriving note passes in front of the population it is
about to join. */}
{stock?.shells.map((shell, si) => (
<g key={si} transform={`translate(${cx} ${cy}) scale(1 ${geo.squash})`}>
<g
className="stage-shell"
style={{
animationDuration: `${shell.seconds}s`,
animationDirection: shell.reverse ? 'reverse' : 'normal',
}}
>
{shell.marks.map((m, mi) => (
<circle className="stage-held" key={mi} cx={m.x} cy={m.y} r={m.r} />
))}
</g>
</g>
))}
{particles.current.map((_, i) => (
<circle
key={i}
className="stage-note"
ref={(node) => {
pool.current[i] = node
}}
<ellipse className="stage-rim stage-rim-3" cx={cx} cy={cy} rx={rx} ry={ry} />
<ellipse
className="stage-rim stage-rim-2"
cx={cx}
cy={cy}
r={2}
opacity={0}
rx={rx * 0.64}
ry={ry * 0.64}
/>
))}
</svg>
<ellipse
className="stage-rim stage-rim-1"
cx={cx}
cy={cy}
rx={rx * 0.33}
ry={ry * 0.33}
/>
<ellipse className="stage-core" cx={cx} cy={cy} rx={rx * 0.11} ry={ry * 0.11} />
<div className="stage-caption">
{last === null ? (
<span className="stage-idle">Watching for the next block.</span>
) : (
<>
<strong>#{last.height.toLocaleString('en-US')}</strong>
<span>
{last.notesIn.toLocaleString('en-US')} in
{last.notesIn > 0 && ` · ${tokens(last.amountIn, decimals, 1)} ${symbol}`}
</span>
<span>
{last.notesOut.toLocaleString('en-US')} out
{last.notesOut > 0 && ` · ${tokens(last.amountOut, decimals, 1)} ${symbol}`}
</span>
</>
)}
{/* Outside the block branch on purpose: the held population is drawn
{/* The notes already inside, turning slowly. Drawn before the marks in
flight so an arriving note passes in front of the population it is
about to join. */}
{stock?.shells.map((shell, si) => (
<g key={si} transform={`translate(${cx} ${cy}) scale(1 ${geo.squash})`}>
<g
className="stage-shell"
style={{
animationDuration: `${shell.seconds}s`,
animationDirection: shell.reverse ? 'reverse' : 'normal',
}}
>
{shell.marks.map((m, mi) => (
<circle className="stage-held" key={mi} cx={m.x} cy={m.y} r={m.r} />
))}
</g>
</g>
))}
{particles.current.map((_, i) => (
<circle
key={i}
className="stage-note"
ref={(node) => {
pool.current[i] = node
}}
cx={cx}
cy={cy}
r={2}
opacity={0}
/>
))}
</svg>
<div className="stage-caption">
{last === null ? (
<span className="stage-idle">Watching for the next block.</span>
) : (
<>
<strong>#{last.height.toLocaleString('en-US')}</strong>
<span>
{last.notesIn.toLocaleString('en-US')} in
{last.notesIn > 0 && ` · ${tokens(last.amountIn, decimals, 1)} ${symbol}`}
</span>
<span>
{last.notesOut.toLocaleString('en-US')} out
{last.notesOut > 0 && ` · ${tokens(last.amountOut, decimals, 1)} ${symbol}`}
</span>
</>
)}
{/* Outside the block branch on purpose: the held population is drawn
from the moment the page loads, and a reader arriving between blocks
would otherwise see forty-five turning marks with nothing saying
what they are. */}
{stock !== null && outstanding !== null && (
<span className="stage-idle">
{outstanding.toLocaleString('en-US')} notes held
{stock.per > 1 && ` · one mark per ${stock.per.toLocaleString('en-US')}`}
</span>
)}
{reduced && <span className="stage-idle">motion off</span>}
{stock !== null && outstanding !== null && (
<span className="stage-idle">
{outstanding.toLocaleString('en-US')} notes held
{stock.per > 1 && ` · one mark per ${stock.per.toLocaleString('en-US')}`}
</span>
)}
{reduced && <span className="stage-idle">motion off</span>}
</div>
</div>
{/* What the marks were. The stage on its own is a dot travelling inward,
@@ -554,7 +587,7 @@ export function WormholeStage({
return (
<li key={line.id}>
<span className="stage-log-height">
{repeat ? '' : `#${line.height.toLocaleString('en-US')}`}
{repeat ? '' : <BlockLink chain={chain} height={line.height} />}
</span>
<span className={`stage-log-kind stage-log-${line.kind}`}>
{kind.glyph} {kind.label}
@@ -562,10 +595,19 @@ export function WormholeStage({
<span className="stage-log-amount">
{tokens(line.amount, decimals, 4)} {symbol}
</span>
<span className="stage-log-to">{shortAddress(line.to)}</span>
<span className="stage-log-to">
{chain === null ? (
shortAddress(line.to)
) : (
<Link to={href({ chain, account: line.to })} title={line.to}>
{shortAddress(line.to)}
</Link>
)}
</span>
{line.elided > 0 && (
<span className="stage-idle">
+{line.elided.toLocaleString('en-US')} more in this block
<span className="stage-idle stage-log-more">
+{line.elided.toLocaleString('en-US')} more in{' '}
<BlockLink chain={chain} height={line.height} />
</span>
)}
</li>