feat(ui): two columns for the stage, and a console that links out
All checks were successful
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
parent 578a54ef87
commit ee575ed33f
2 changed files with 184 additions and 83 deletions

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>

View File

@@ -1048,6 +1048,36 @@ tr.mine .share-bar > i {
padding: 6px 0 2px;
}
/* Side by side once there is room for both.
*
* Stacked, the console pushes the panel past a phone screen's height and the
* mouth and the lines describing it cannot be read at the same time — which is
* the entire point of having both. The breakpoint is where the console still
* fits its widest line (a height, a kind, an amount and an address) without
* wrapping; below it they stack, and the figure takes the full width back. */
@media (min-width: 1100px) {
.stage-wrap {
display: grid;
/* 400px is not a taste: it is what one line needs — the height, kind and
amount columns plus a shortened address and three gaps come to ~354px,
and the column's own padding takes the rest. Measured at 360px every
line wrapped to two, which is worse than stacking. The breakpoint is
where that column can exist without starving the figure. */
grid-template-columns: minmax(0, 1fr) clamp(400px, 36%, 470px);
padding: 6px 0 0;
}
.stage-log {
border-left: var(--rule);
/* Stretched to the figure's height so the panel has a straight bottom edge
whatever the console is holding. Scrolls rather than growing: the buffer
is fixed, but a wrapped line must not lengthen the panel. */
height: 100%;
overflow-y: auto;
padding-top: 10px;
}
}
.stage {
display: block;
}
@@ -1181,19 +1211,21 @@ tr.mine .share-bar > i {
.stage-log-height {
color: var(--text-muted);
min-width: 66px;
min-width: 62px;
}
/* Direction repeats what the mark did, in a glyph, so a line and its dot can be
matched without reading the label. Same reasoning as the flow chart: the
encoding is geometry, never a second hue — every kind wears the one colour. */
.stage-log-kind {
min-width: 72px;
min-width: 66px;
color: var(--data-bright);
}
.stage-log-amount {
min-width: 132px;
/* Sized to the widest real amount rather than generously: the column has to
survive being squeezed into the console's own column at 1000px. */
min-width: 104px;
color: var(--text-primary);
}
@@ -1201,6 +1233,33 @@ tr.mine .share-bar > i {
color: var(--text-muted);
}
/* Console links: the block a note landed in, and the account it credited.
*
* Underlined on hover rather than tinted, following `a.ticker-height`: the
* columns already carry meaning through colour — muted for the height and the
* address, bright for the kind — and recolouring a link on hover would make it
* momentarily read as a different column. The dotted rule is the affordance;
* it costs no colour at all. */
.stage-log a {
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 3px;
text-decoration-color: var(--border-strong);
}
.stage-log a:hover {
color: var(--data-bright);
text-decoration-color: var(--data-bright);
}
/* The count of what is not shown. It fits on the line in the wide layout and
wraps in the narrow column, so it carries the height column's indent either
way — wrapped and flush left it reads as a new record rather than as a note
about the one above it. */
.stage-log-more {
padding-left: 62px;
}
/* A meter: one ratio against its limit.
*
* The track is the same hue at wash weight rather than a neutral grey, so the