feat(ui): say what each mark was
All checks were successful
deploy / build (push) Successful in 7m36s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 16s

The stage drew a dot per note and left the reader to guess. A dot travelling
inward could be anybody paying anybody, and without text beside it the
animation is an ornament rather than a reading of the chain.

Each pulse now carries a sample of the individual notes — kind, recipient,
amount — and a console below the stage lists them newest-first, the way the
block ticker reads, which also means no auto-scroll to fight with and the line
a reader is looking for never moves.

`kind` is the third split this codebase makes on the producing extrinsic and
the first that separates all three cases: a reward has no extrinsic at all
because it is paid in block initialisation, a transfer has an ordinary one, and
an exit has a `verify_*_batch`. The recorded sender distinguishes none of them.

Two caps, and they turned out to interact. `MAX_PULSE_NOTES` bounds the wire —
a block has carried hundreds of notes and sending every one for a handful of
lines is kilobytes a block of text nobody reads. `CONSOLE_LINES_PER_BLOCK`
bounds one block's share of the console, because without it a single busy block
fills every line and twelve lines from one block look exactly like twelve
blocks of one, which loses the reader all sense of rate.

The count of what is not shown hangs on the block's first line rather than its
last: the list is newest-first, so a count on the last line is the first thing
trimmed, and a block of 260 showed four notes while saying nothing about it.
That one reached a screenshot before it was caught.

The console fills before the reduced-motion guard, so a viewer who asked for
less motion still gets the whole reading — twelve lines, no moving marks,
population still drawn, caption still live.

Refs #17

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 14:01:16 +03:00
parent 4f59af9847
commit ad22866d3c
9 changed files with 326 additions and 5 deletions

View File

@@ -785,6 +785,37 @@ One mark stands for many and the caption always says how many, printed whether
or not a block has arrived yet: forty-five turning marks with nothing explaining
them is worse than no marks at all.
**A mark is not self-explanatory, so every mark gets a line.** A dot travelling
inward could be anybody paying anybody; without text beside it the stage is an
ornament. `ServerMessage::Wormhole` therefore carries a *sample* of the
individual notes — kind, recipient, amount — and the console below the stage
lists them newest-first, the way the block ticker reads, which also means no
auto-scroll to fight and the line a reader is looking for never moves.
`kind` is the third split this codebase makes on the producing extrinsic, and
the only one that separates all three cases: `reward` has **no extrinsic at
all** (block initialisation paying a miner), `transfer` has an ordinary one, and
`exit` has a `verify_*_batch`. The recorded sender distinguishes none of them —
it is the same sentinel for a reward and an exit both.
Two caps, and they interact:
- `MAX_PULSE_NOTES` bounds the wire. A block has carried hundreds of notes and
sending every one to draw a handful of lines is kilobytes a block for text
nobody reads. The **counts beside them stay complete**.
- `CONSOLE_LINES_PER_BLOCK` bounds one block's share of the console. Without it
a single busy block fills all twelve lines and twelve lines from one block
look exactly like twelve blocks of one — the reader loses all sense of rate.
The count of what is not shown hangs on the block's **first** line, not its
last. The list is newest-first, so a count on the last line is the first thing
`CONSOLE_LINES` trims, and a block of 260 would show four notes and never say
so. That bug shipped to a screenshot before it was caught.
The console is fed **before** the `reduced` guard, so a viewer who asked for
less motion still gets the whole reading — verified by forcing the hook: twelve
lines, zero moving marks, population still drawn, caption still live.
Three rules keep `WormholeStage` honest, and each cost a rewrite:
- **Marks are capped; the caption is not.** A block committing 900 notes draws

View File

@@ -1055,6 +1055,13 @@ async fn index_events(
/// A block that touched no notes broadcasts nothing. On a chain producing a
/// block every twelve seconds an empty pulse would be pure noise, and a client
/// that sees silence correctly draws nothing.
/// Individual notes carried on one pulse.
///
/// Enough to fill the console a few times over at a block a second; far short
/// of the hundreds a busy block can carry, which is the point — the counts
/// beside them are complete and these are only what gets a line of text.
const MAX_PULSE_NOTES: usize = 12;
fn broadcast_wormhole_pulse(
chain: &Arc<ChainRuntime>,
height: u64,
@@ -1063,6 +1070,7 @@ fn broadcast_wormhole_pulse(
) {
let (mut notes_in, mut notes_out) = (0u32, 0u32);
let (mut amount_in, mut amount_out) = (0u128, 0u128);
let mut notes: Vec<blackbeard_entities::WormholeNote> = Vec::new();
for event in decoded {
if event.pallet != "Wormhole" || event.variant != "NativeTransferred" {
@@ -1081,13 +1089,40 @@ fn broadcast_wormhole_pulse(
})
.unwrap_or(0);
if event.extrinsic_index.is_some_and(|i| exits.contains(&i)) {
// Three kinds, told apart by the producing extrinsic. A reward is
// emitted in the block's own initialisation phase and so carries no
// extrinsic at all; the recorded sender cannot distinguish any of them,
// because it is the same sentinel for a reward and an exit both.
let kind = match event.extrinsic_index {
Some(i) if exits.contains(&i) => "exit",
Some(_) => "transfer",
None => "reward",
};
if kind == "exit" {
notes_out = notes_out.saturating_add(1);
amount_out = amount_out.saturating_add(amount);
} else {
notes_in = notes_in.saturating_add(1);
amount_in = amount_in.saturating_add(amount);
}
// A sample. The counts above stay complete, and the page says how many
// it is not showing — a block has carried hundreds of these.
if notes.len() < MAX_PULSE_NOTES {
let to = event
.fields
.get("to")
.and_then(|v| v.as_str())
.and_then(blackbeard_core::wormhole::ss58_of);
if let Some(to) = to {
notes.push(blackbeard_entities::WormholeNote {
kind: kind.to_string(),
to,
amount: blackbeard_entities::BigUintDec(amount.to_string()),
});
}
}
}
if notes_in == 0 && notes_out == 0 {
@@ -1102,6 +1137,7 @@ fn broadcast_wormhole_pulse(
notes_out,
amount_in: blackbeard_entities::BigUintDec(amount_in.to_string()),
amount_out: blackbeard_entities::BigUintDec(amount_out.to_string()),
notes,
},
None,
);

View File

@@ -49,7 +49,7 @@ pub use runtime::{
};
pub use series::{ChainSeries, ChainSeriesPoint};
pub use state::{ChainState, StateEntry};
pub use ws::{ClientMessage, ServerMessage, Window};
pub use ws::{ClientMessage, ServerMessage, Window, WormholeNote};
use serde::{Deserialize, Serialize};
use ts_rs::TS;

View File

@@ -127,6 +127,31 @@ pub enum ClientMessage {
Ping,
}
/// One note a block committed or released.
///
/// The reason this exists: the stage draws a mark per note and a mark is not
/// self-explanatory. A dot travelling inward could be anybody paying anybody,
/// and without a line of text beside it the animation is decoration. This is
/// what turns it back into a reading of the chain.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[ts(export, export_to = "WormholeNote.ts")]
pub struct WormholeNote {
/// What put it there. One of:
///
/// - `reward` — block initialisation paying a miner. The "sender" is the
/// runtime rather than anybody.
/// - `transfer` — an ordinary extrinsic, whose sender the chain recorded.
/// - `exit` — a settled batch releasing value, whose sender it did not.
///
/// Taken from the producing extrinsic, never from the recorded sender: that
/// is the same sentinel for a reward and for an exit both.
pub kind: String,
/// Who it credits, SS58.
pub to: String,
/// How much, smallest unit.
pub amount: BigUintDec,
}
/// Server to browser.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
@@ -217,6 +242,14 @@ pub enum ServerMessage {
amount_in: BigUintDec,
/// Value leaving, smallest unit.
amount_out: BigUintDec,
/// Individual notes, so the page can say what each mark stands for.
///
/// **A sample, capped — the counts above are complete.** A block has
/// carried hundreds of notes, and sending every one to draw a handful
/// of lines would put kilobytes on the wire per block for text nobody
/// reads. Anything rendering these has to say how many it is not
/// showing.
notes: Vec<WormholeNote>,
},
/// A chain's reachability changed — the node went away, or a chain that was
/// awaiting launch has started producing blocks.

View File

@@ -6,6 +6,7 @@ import type { ChainSummary } from "./ChainSummary";
import type { LeaderboardRow } from "./LeaderboardRow";
import type { RecentBlock } from "./RecentBlock";
import type { Window } from "./Window";
import type { WormholeNote } from "./WormholeNote";
/**
* Server to browser.
@@ -102,7 +103,17 @@ amount_in: BigUintDec,
/**
* Value leaving, smallest unit.
*/
amount_out: BigUintDec, } | { "type": "chain_status",
amount_out: BigUintDec,
/**
* Individual notes, so the page can say what each mark stands for.
*
* **A sample, capped — the counts above are complete.** A block has
* carried hundreds of notes, and sending every one to draw a handful
* of lines would put kilobytes on the wire per block for text nobody
* reads. Anything rendering these has to say how many it is not
* showing.
*/
notes: Array<WormholeNote>, } | { "type": "chain_status",
/**
* Which chain.
*/

View File

@@ -0,0 +1,32 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { BigUintDec } from "./BigUintDec";
/**
* One note a block committed or released.
*
* The reason this exists: the stage draws a mark per note and a mark is not
* self-explanatory. A dot travelling inward could be anybody paying anybody,
* and without a line of text beside it the animation is decoration. This is
* what turns it back into a reading of the chain.
*/
export type WormholeNote = {
/**
* What put it there. One of:
*
* - `reward` — block initialisation paying a miner. The "sender" is the
* runtime rather than anybody.
* - `transfer` — an ordinary extrinsic, whose sender the chain recorded.
* - `exit` — a settled batch releasing value, whose sender it did not.
*
* Taken from the producing extrinsic, never from the recorded sender: that
* is the same sentinel for a reward and for an exit both.
*/
kind: string,
/**
* Who it credits, SS58.
*/
to: string,
/**
* How much, smallest unit.
*/
amount: BigUintDec, };

View File

@@ -18,6 +18,7 @@ import type { ClientMessage } from './generated/ClientMessage'
import type { LeaderboardRow } from './generated/LeaderboardRow'
import type { RecentBlock } from './generated/RecentBlock'
import type { ServerMessage } from './generated/ServerMessage'
import type { WormholeNote } from './generated/WormholeNote'
import type { Window as WindowName } from './generated/Window'
/** How the socket is doing, for the status light in the header. */
@@ -78,6 +79,13 @@ export interface WormholePulse {
notesOut: number
amountIn: string
amountOut: string
/**
* A sample of the individual notes, for the console beside the stage.
*
* Capped by the server. `notesIn + notesOut` is the complete count, and
* anything rendering these has to say how many it is not showing.
*/
notes: WormholeNote[]
}
/** Blocks kept in the ticker. Matches the backend's replay length. */
@@ -282,6 +290,7 @@ export class Observer {
notesOut: message.notes_out,
amountIn: message.amount_in,
amountOut: message.amount_out,
notes: message.notes,
}
// A throwing subscriber must not take the socket's reader loop with
// it: the rest of the site depends on this loop, and an animation is

View File

@@ -29,7 +29,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { fetchWormhole } from '../api/rest'
import type { WormholePulse } from '../api/socket'
import { tokens } from '../lib/format'
import { shortAddress, tokens } from '../lib/format'
import { useObserverInstance } from '../lib/observer-context'
const HEIGHT = 210
@@ -65,9 +65,54 @@ const STOCK_MARKS = 45
const STOCK_SHELLS = 3
/** How often the standing population is re-read. It moves slowly; this is not a feed. */
const STOCK_REFRESH_MS = 120_000
/**
* Lines kept in the console.
*
* The stage's marks are not self-explanatory — a dot travelling inward could be
* anybody paying anybody — so each one gets a line saying what it was. Ten is
* about half a minute of mainnet, which is long enough to read and short enough
* that the oldest line is still recent.
*/
const CONSOLE_LINES = 12
/**
* Lines any one block may contribute.
*
* Without it a single busy block fills the console and the reader loses all
* sense of rate — one mainnet block has carried hundreds of notes, and twelve
* lines from it look identical to twelve blocks of one. Four keeps several
* blocks on screen, and the count of what is not shown sits on the block's own
* first line.
*/
const CONSOLE_LINES_PER_BLOCK = 4
/** Most marks any single block may spawn, so one big block cannot fill the pool. */
const PER_PULSE_CAP = 28
/**
* What each kind of arrival is, in words a reader has not had to look up.
*
* The glyph repeats the direction the mark travelled, so a line and the dot it
* describes can be matched without reading the label — the same
* position-over-colour reasoning the flow chart uses.
*/
const KINDS: Record<string, { glyph: string; label: string }> = {
reward: { glyph: '\u2193', label: 'mined' },
transfer: { glyph: '\u2193', label: 'sent' },
exit: { glyph: '\u2191', label: 'exit' },
}
interface ConsoleLine {
/** Unique per line, so React is not asked to key on a block that has several. */
id: string
height: number
kind: string
to: string
amount: string
/** Notes this block carried that got no line of their own. */
elided: number
}
interface Particle {
/** Set while in flight; a free slot is `null`. */
born: number
@@ -139,6 +184,8 @@ export function WormholeStage({
* and look authoritative doing it.
*/
const [outstanding, setOutstanding] = useState<number | null>(null)
/** Newest first, the way the block ticker reads. Oldest falls off the end. */
const [log, setLog] = useState<ConsoleLine[]>([])
useEffect(() => {
if (chain === null) return
@@ -291,7 +338,6 @@ export function WormholeStage({
useEffect(() => {
return observer.onWormhole((pulse: WormholePulse) => {
setLast(pulse)
if (reduced) return
const spawn = (count: number, out: boolean, total: string) => {
const draw = Math.min(count, PER_PULSE_CAP)
@@ -326,6 +372,30 @@ export function WormholeStage({
}
}
// The console is fed whether or not the marks are, so a viewer who has
// asked for less motion still gets the reading.
const total = pulse.notesIn + pulse.notesOut
const shown = pulse.notes.slice(0, CONSOLE_LINES_PER_BLOCK)
const elided = Math.max(0, total - shown.length)
setLog((previous) =>
[
...shown.map((n, i) => ({
id: `${pulse.height}-${i}`,
height: pulse.height,
kind: n.kind,
to: n.to,
amount: n.amount,
// On the block's *first* line, which is the one that survives: the
// list is newest-first, so a count hung on the last line would be
// the first thing trimmed by `CONSOLE_LINES` and the reader would
// never learn a block of 260 showed them four.
elided: i === 0 ? elided : 0,
})),
...previous,
].slice(0, CONSOLE_LINES),
)
if (reduced) return
spawn(pulse.notesIn, false, pulse.amountIn)
spawn(pulse.notesOut, true, pulse.amountOut)
start.current()
@@ -454,6 +524,45 @@ export function WormholeStage({
)}
{reduced && <span className="stage-idle">motion off</span>}
</div>
{/* What the marks were. The stage on its own is a dot travelling inward,
which could be anybody paying anybody; this is the half that makes it
a reading of the chain rather than an ornament. Newest at the top, the
way the block ticker reads — which also means no auto-scrolling to
fight with, and the line a reader is looking for never moves. */}
<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.
</li>
) : (
log.map((line, i) => {
const kind = KINDS[line.kind] ?? { glyph: '·', label: line.kind }
// A block's other lines leave the height column blank, so a run of
// notes reads as one block rather than as a column of repetition.
const repeat = i > 0 && log[i - 1]?.height === line.height
return (
<li key={line.id}>
<span className="stage-log-height">
{repeat ? '' : `#${line.height.toLocaleString('en-US')}`}
</span>
<span className={`stage-log-kind stage-log-${line.kind}`}>
{kind.glyph} {kind.label}
</span>
<span className="stage-log-amount">
{tokens(line.amount, decimals, 4)} {symbol}
</span>
<span className="stage-log-to">{shortAddress(line.to)}</span>
{line.elided > 0 && (
<span className="stage-idle">
+{line.elided.toLocaleString('en-US')} more in this block
</span>
)}
</li>
)
})
)}
</ol>
</div>
)
}

View File

@@ -1141,6 +1141,66 @@ tr.mine .share-bar > i {
color: var(--text-muted);
}
/* The console beside the stage: what each mark was.
*
* Monospace and columnar so the eye can run down one field — a log that
* reflows between lines is a log nobody scans. Older lines recede rather than
* vanishing at a hard edge, which is what makes the buffer read as a buffer. */
.stage-log {
list-style: none;
margin: 0;
padding: 2px 12px 10px;
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.75;
}
.stage-log li {
display: flex;
flex-wrap: wrap;
gap: 0 12px;
align-items: baseline;
color: var(--text-secondary);
white-space: nowrap;
}
/* The newest line is the one being read; the rest fade back in order. Applied
to the tail rather than to every line so the top of the list stays at full
strength as it grows. */
.stage-log li:nth-child(n + 5) {
opacity: 0.72;
}
.stage-log li:nth-child(n + 8) {
opacity: 0.45;
}
.stage-log-empty {
color: var(--text-muted);
}
.stage-log-height {
color: var(--text-muted);
min-width: 66px;
}
/* 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;
color: var(--data-bright);
}
.stage-log-amount {
min-width: 132px;
color: var(--text-primary);
}
.stage-log-to {
color: var(--text-muted);
}
/* A meter: one ratio against its limit.
*
* The track is the same hue at wash weight rather than a neutral grey, so the