From ee575ed33f7a8b3c27620b768efdcb713077d377 Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Mon, 14 Sep 2026 14:25:59 +0300 Subject: [PATCH] feat(ui): two columns for the stage, and a console that links out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/src/components/WormholeStage.tsx | 202 ++++++++++++++++----------- web/src/index.css | 65 ++++++++- 2 files changed, 184 insertions(+), 83 deletions(-) diff --git a/web/src/components/WormholeStage.tsx b/web/src/components/WormholeStage.tsx index aa493c1..e0b973f 100644 --- a/web/src/components/WormholeStage.tsx +++ b/web/src/components/WormholeStage.tsx @@ -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 {label} +} + export function WormholeStage({ chain, decimals, @@ -449,90 +465,107 @@ export function WormholeStage({ const { cx, cy } = geo return ( -
- - {/* The mouth. Three ellipses of the one hue at descending wash weight — +
+ {/* 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. */} +
+ + {/* The mouth. Three ellipses of the one hue at descending wash weight — a throat seen edge-on, not a second series. */} - - - - - - {/* 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) => ( - - - {shell.marks.map((m, mi) => ( - - ))} - - - ))} - - {particles.current.map((_, i) => ( - { - pool.current[i] = node - }} + + - ))} - + + -
- {last === null ? ( - Watching for the next block. - ) : ( - <> - #{last.height.toLocaleString('en-US')} - - {last.notesIn.toLocaleString('en-US')} in - {last.notesIn > 0 && ` · ${tokens(last.amountIn, decimals, 1)} ${symbol}`} - - - {last.notesOut.toLocaleString('en-US')} out - {last.notesOut > 0 && ` · ${tokens(last.amountOut, decimals, 1)} ${symbol}`} - - - )} - {/* 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) => ( + + + {shell.marks.map((m, mi) => ( + + ))} + + + ))} + + {particles.current.map((_, i) => ( + { + pool.current[i] = node + }} + cx={cx} + cy={cy} + r={2} + opacity={0} + /> + ))} + + +
+ {last === null ? ( + Watching for the next block. + ) : ( + <> + #{last.height.toLocaleString('en-US')} + + {last.notesIn.toLocaleString('en-US')} in + {last.notesIn > 0 && ` · ${tokens(last.amountIn, decimals, 1)} ${symbol}`} + + + {last.notesOut.toLocaleString('en-US')} out + {last.notesOut > 0 && ` · ${tokens(last.amountOut, decimals, 1)} ${symbol}`} + + + )} + {/* 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 && ( - - {outstanding.toLocaleString('en-US')} notes held - {stock.per > 1 && ` · one mark per ${stock.per.toLocaleString('en-US')}`} - - )} - {reduced && motion off} + {stock !== null && outstanding !== null && ( + + {outstanding.toLocaleString('en-US')} notes held + {stock.per > 1 && ` · one mark per ${stock.per.toLocaleString('en-US')}`} + + )} + {reduced && motion off} +
{/* What the marks were. The stage on its own is a dot travelling inward, @@ -554,7 +587,7 @@ export function WormholeStage({ return (
  • - {repeat ? '' : `#${line.height.toLocaleString('en-US')}`} + {repeat ? '' : } {kind.glyph} {kind.label} @@ -562,10 +595,19 @@ export function WormholeStage({ {tokens(line.amount, decimals, 4)} {symbol} - {shortAddress(line.to)} + + {chain === null ? ( + shortAddress(line.to) + ) : ( + + {shortAddress(line.to)} + + )} + {line.elided > 0 && ( - - +{line.elided.toLocaleString('en-US')} more in this block + + +{line.elided.toLocaleString('en-US')} more in{' '} + )}
  • diff --git a/web/src/index.css b/web/src/index.css index fa29016..2837c41 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -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