diff --git a/CLAUDE.md b/CLAUDE.md index b48809f..e0f8b27 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -750,6 +750,38 @@ real numbers, and why the readout borrows the tile's note line instead of floating a tooltip: five overlays in a row of 180px tiles is not a hover layer, it is a pile. +**The live stage is driven by blocks, never by a timer.** `ServerMessage::Wormhole` +carries what one block did to the pool — notes committed, notes an exit +released, and both amounts — broadcast from `index_events` after +`ServerMessage::Block` rather than folded into it, because the counts need two +more RPC round trips and the whole site's ticker must not wait for one panel. A +block that touched no notes sends nothing: an empty pulse every twelve seconds +would be noise, and **a still stage means a still chain**, which is information. + +Three rules keep `WormholeStage` honest, and each cost a rewrite: + +- **Marks are capped; the caption is not.** A block committing 900 notes draws + `PER_PULSE_CAP` of them and says nine hundred in words. A picture nobody can + count is decoration, so the number lives in the text where it is exact. +- **It renders outside React.** Pulses go through `Observer.onWormhole`, a + separate subscriber set from the state listeners, because a pulse is an event + and not a fact about the page — putting it in `ObserverState` would re-render + the leaderboard, the ticker and the whole tree once per block for one + animation. The marks are pooled SVG nodes mutated from one `rAF` loop that + **starts on a pulse and ends itself** when nothing is in flight. +- **`prefers-reduced-motion` stops the motion, not the information.** No marks + spawn, the caption still updates with real numbers and the `aria-label` still + describes the last block. Verified by forcing the hook rather than assuming. + +Direction is the same encoding as the chart — inward against outward — for the +same measured reason, and the mouth's `ry` is capped so the outer ring fits the +stage and reused as the squash on every note's path: a mark has to travel in the +plane of the thing it is falling into or it reads as passing in front of it. + +On `/:chain/wormhole` the stage replaces the headline stat row. Hashrate, +difficulty, block time, miners and height are on every other page and say +nothing about the pool. + **A diverging chart with one hue puts direction in the geometry.** In and out are opposite directions, which is the textbook diverging case — two hues either side of a neutral midpoint. This site cannot have them, so `FlowChart` carries diff --git a/crates/blackbeard-api/src/ingest.rs b/crates/blackbeard-api/src/ingest.rs index 406c0ad..8e575f3 100644 --- a/crates/blackbeard-api/src/ingest.rs +++ b/crates/blackbeard-api/src/ingest.rs @@ -996,7 +996,7 @@ async fn index_events( }) .collect(); - if let Some(body) = body { + let exits = if let Some(body) = body { index_extrinsics( chain, store, @@ -1006,8 +1006,15 @@ async fn index_events( at, &runtime, ) - .await; - } + .await + } else { + // No body means no way to tell an exit from an arrival. Reporting every + // transfer as an arrival would draw a pool that only ever fills, so the + // pulse is skipped entirely for this block. + std::collections::HashSet::new() + }; + + broadcast_wormhole_pulse(chain, height, &decoded, &exits); let keep: Vec = decoded .into_iter() @@ -1038,6 +1045,68 @@ async fn index_events( } } +/// Tell the page what this block did to the wormhole, if it did anything. +/// +/// Direction is a fact about the producing extrinsic, exactly as it is in +/// `exit_cohorts` and the daily flow query — a settled batch releases value, +/// anything else commits it. The recorded sender cannot say: it is the same +/// sentinel whichever way the value is going. +/// +/// 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. +fn broadcast_wormhole_pulse( + chain: &Arc, + height: u64, + decoded: &[blackbeard_core::runtime::DecodedEvent], + exits: &std::collections::HashSet, +) { + let (mut notes_in, mut notes_out) = (0u32, 0u32); + let (mut amount_in, mut amount_out) = (0u128, 0u128); + + for event in decoded { + if event.pallet != "Wormhole" || event.variant != "NativeTransferred" { + continue; + } + // Saturating: an amount this decoder cannot read as a u128 must not + // wrap a total into something small and plausible. It contributes its + // count and no value, which understates rather than invents. + let amount = event + .fields + .get("amount") + .and_then(|v| match v { + serde_json::Value::String(s) => s.parse::().ok(), + serde_json::Value::Number(n) => n.as_u64().map(u128::from), + _ => None, + }) + .unwrap_or(0); + + if event.extrinsic_index.is_some_and(|i| exits.contains(&i)) { + 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); + } + } + + if notes_in == 0 && notes_out == 0 { + return; + } + + chain.broadcast( + &ServerMessage::Wormhole { + chain: chain.id(), + height, + notes_in, + notes_out, + amount_in: blackbeard_entities::BigUintDec(amount_in.to_string()), + amount_out: blackbeard_entities::BigUintDec(amount_out.to_string()), + }, + None, + ); +} + /// Store a block's extrinsics, decoded against the runtime that produced it. /// /// Best effort, like the events beside it: an extrinsic that will not decode @@ -1057,29 +1126,39 @@ async fn index_extrinsics( outcomes: &std::collections::HashMap, at: Option>, runtime: &Arc, -) { +) -> std::collections::HashSet { let mut keep = Vec::with_capacity(raw.len()); + // Which extrinsics settled a wormhole exit. Collected here because this is + // the one place the body is already decoded — a second pass to find them + // would re-run `decode_extrinsic` over Dilithium signatures at 5.3 KiB + // apiece, which is most of the cost of reading a block. + let mut exits = std::collections::HashSet::new(); for (index, hex_str) in raw.iter().enumerate() { let Ok(bytes) = hex::decode(hex_str.trim_start_matches("0x")) else { continue; }; let index = index as u32; match runtime.decode_extrinsic(index, &bytes) { - Ok(x) => keep.push(blackbeard_data::store::ExtrinsicRecord { - chain: chain.id(), - height, - extrinsic_index: index, - pallet: x.pallet, - call: x.call, - signer: x.signer, - signature: x.signature, - signature_bytes: x.signature_bytes, - args: x.args, - extra: x.extra, - success: outcomes.get(&index).copied(), - accounts: x.accounts, - at, - }), + Ok(x) => { + if x.pallet == "Wormhole" && is_exit_settlement(&x.call) { + exits.insert(index); + } + keep.push(blackbeard_data::store::ExtrinsicRecord { + chain: chain.id(), + height, + extrinsic_index: index, + pallet: x.pallet, + call: x.call, + signer: x.signer, + signature: x.signature, + signature_bytes: x.signature_bytes, + args: x.args, + extra: x.extra, + success: outcomes.get(&index).copied(), + accounts: x.accounts, + at, + }) + } Err(e) => { tracing::debug!( chain = %chain.id(), height, index, error = %e, @@ -1090,11 +1169,22 @@ async fn index_extrinsics( } if keep.is_empty() { - return; + return exits; } if let Err(e) = store.record_extrinsics(&keep).await { tracing::warn!(chain = %chain.id(), height, error = %e, "extrinsics not persisted"); } + exits +} + +/// Whether a `Wormhole` call is one that settles an exit. +/// +/// Both batch verifiers release value from the pool; nothing else in the pallet +/// does. Matched on the name rather than a call index because an index is a +/// position in a runtime's dispatch table and a runtime upgrade may renumber +/// it, while these two names are the pallet's public surface. +fn is_exit_settlement(call: &str) -> bool { + call == "verify_public_batch" || call == "verify_private_batch" } /// Read every block's events from the tip back to genesis, and keep up. diff --git a/crates/blackbeard-entities/src/ws.rs b/crates/blackbeard-entities/src/ws.rs index c0f330c..26d20f0 100644 --- a/crates/blackbeard-entities/src/ws.rs +++ b/crates/blackbeard-entities/src/ws.rs @@ -14,7 +14,7 @@ use serde::{Deserialize, Serialize}; use ts_rs::TS; -use crate::{ChainId, ChainInfo, ChainSummary, LeaderboardRow, RecentBlock}; +use crate::{BigUintDec, ChainId, ChainInfo, ChainSummary, LeaderboardRow, RecentBlock}; /// How far back a leaderboard looks. /// @@ -190,6 +190,34 @@ pub enum ServerMessage { #[ts(type = "number | null")] window_seconds: Option, }, + /// What one block did to the wormhole, as soon as its events are decoded. + /// + /// Sent after [`ServerMessage::Block`] rather than folded into it: the + /// counts come from `index_events`, which runs a couple of RPC round trips + /// later, and delaying the block broadcast to wait for them would make the + /// whole site's ticker lag for one panel's benefit. + /// + /// A block with no wormhole activity sends nothing at all. Silence is the + /// common case on a quiet chain and an empty pulse every twelve seconds + /// would be pure noise on the wire. + Wormhole { + /// Which chain. + chain: ChainId, + /// The block these came from. + #[ts(type = "number")] + height: u64, + /// Notes created — value arriving in the pool. + #[ts(type = "number")] + notes_in: u32, + /// Notes whose value an exit released, settled by + /// `verify_public_batch` or `verify_private_batch`. + #[ts(type = "number")] + notes_out: u32, + /// Value arriving, smallest unit. + amount_in: BigUintDec, + /// Value leaving, smallest unit. + amount_out: BigUintDec, + }, /// A chain's reachability changed — the node went away, or a chain that was /// awaiting launch has started producing blocks. ChainStatus { diff --git a/web/src/App.tsx b/web/src/App.tsx index 6f3c1ca..1a75572 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -33,6 +33,7 @@ import { SectionNav } from './components/SectionNav' import { ThemeToggle } from './components/ThemeToggle' import { StateIndex } from './components/StateIndex' import { StatBar } from './components/StatBar' +import { WormholeStage } from './components/WormholeStage' import { measuredSpan, seconds, windowSpan } from './lib/format' import { href, isStandings, parse, WINDOWS } from './lib/routes' import { useObserver, usePinnedMiners, useWatch } from './lib/store' @@ -187,7 +188,15 @@ export default function App() { )} - + {/* The wormhole page trades the headline row for the live stage. Hashrate, + difficulty, block time, miners and height are on every other page and + say nothing about the pool; the stage is the one thing that can only + be shown here, and it wants the width. */} + {route.index === 'wormhole' ? ( + + ) : ( + + )} {route.block && chain && ( , /** * What the window actually spanned, by the chain's own clock. */ -window_seconds: number | null, } | { "type": "chain_status", +window_seconds: number | null, } | { "type": "wormhole", +/** + * Which chain. + */ +chain: ChainId, +/** + * The block these came from. + */ +height: number, +/** + * Notes created — value arriving in the pool. + */ +notes_in: number, +/** + * Notes whose value an exit released, settled by + * `verify_public_batch` or `verify_private_batch`. + */ +notes_out: number, +/** + * Value arriving, smallest unit. + */ +amount_in: BigUintDec, +/** + * Value leaving, smallest unit. + */ +amount_out: BigUintDec, } | { "type": "chain_status", /** * Which chain. */ diff --git a/web/src/api/socket.ts b/web/src/api/socket.ts index 5bb138f..a13b25e 100644 --- a/web/src/api/socket.ts +++ b/web/src/api/socket.ts @@ -61,6 +61,25 @@ const INITIAL: ObserverState = { ready: false, } +/** + * What one block did to the wormhole. + * + * Deliberately **not** part of `ObserverState`. A pulse is an event, not a + * fact about the page: it happens, it is drawn, and a second later it means + * nothing. Putting it in the state object would re-render every subscriber — + * the leaderboard, the ticker, the whole tree — once per block for the benefit + * of one animation, and the animation would still have to diff to notice it + * had fired twice with the same numbers. + */ +export interface WormholePulse { + chain: string + height: number + notesIn: number + notesOut: number + amountIn: string + amountOut: string +} + /** Blocks kept in the ticker. Matches the backend's replay length. */ const TICKER_LIMIT = 40 @@ -98,12 +117,28 @@ function socketUrl(): string { export class Observer { private state: ObserverState = INITIAL private listeners = new Set<() => void>() + /** Pulse subscribers, kept apart from `listeners` — see `WormholePulse`. */ + private pulseListeners = new Set<(pulse: WormholePulse) => void>() private socket: WebSocket | null = null private reconnectDelay = RECONNECT_MIN_MS private reconnectTimer: number | null = null private pingTimer: number | null = null private closed = false + /** + * Hear every wormhole pulse for the subscribed chain. + * + * Separate from `subscribe` because this is a stream of events rather than a + * store — there is no "current pulse" to read, and a component that misses + * one has missed a block's animation and nothing else. + */ + onWormhole = (listener: (pulse: WormholePulse) => void): (() => void) => { + this.pulseListeners.add(listener) + return () => { + this.pulseListeners.delete(listener) + } + } + /** `useSyncExternalStore` subscribe. */ subscribe = (listener: () => void): (() => void) => { this.listeners.add(listener) @@ -238,6 +273,29 @@ export class Observer { break } + case 'wormhole': { + if (message.chain !== this.state.chain) return + const pulse: WormholePulse = { + chain: message.chain, + height: message.height, + notesIn: message.notes_in, + notesOut: message.notes_out, + amountIn: message.amount_in, + amountOut: message.amount_out, + } + // 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 + // the least important thing on the page. + for (const listener of this.pulseListeners) { + try { + listener(pulse) + } catch (e) { + console.warn('[observer] wormhole subscriber threw', e) + } + } + break + } + case 'chain_status': this.patch({ chains: this.state.chains.map((c) => (c.id === message.chain ? message.info : c)), diff --git a/web/src/components/WormholeStage.tsx b/web/src/components/WormholeStage.tsx new file mode 100644 index 0000000..69a1b78 --- /dev/null +++ b/web/src/components/WormholeStage.tsx @@ -0,0 +1,331 @@ +/** + * The wormhole, drawn as it actually happens. + * + * Every block this observer decodes reports how many notes it committed and + * how many an exit released (`ServerMessage::Wormhole`). A note arriving falls + * inward and is swallowed; a note leaving climbs out and escapes. Nothing here + * is on a timer or a loop — **a still stage means a still chain**, and that is + * information rather than a bug. + * + * Three things keep it honest: + * + * - **Particles are capped and the caption is not.** A block committing 900 + * notes draws `MAX_PARTICLES` of them and says nine hundred in words. Drawing + * one mark per note would melt the tab and still be uncountable, and a + * picture nobody can count is decoration — so the number lives in the text, + * where it is exact. + * - **Direction is the same encoding the chart uses**: inward against outward, + * never two hues. This site has one data colour and the two-shade + * alternative fails CVD separation, so a note's direction is its travel. + * - **It is never the only way to read this.** The chart and the tables below + * carry every figure; this is the same events with the latency removed. + * + * Rendered by mutating pooled SVG nodes from one `requestAnimationFrame` loop + * rather than through React state. Sixty state updates a second across a tree + * this size is the cost React is bad at, and none of it is worth a re-render. + */ + +import { useEffect, useRef, useState } from 'react' + +import type { WormholePulse } from '../api/socket' +import { tokens } from '../lib/format' +import { useObserverInstance } from '../lib/observer-context' + +const HEIGHT = 210 +const FALLBACK_WIDTH = 720 +/** + * Most marks alive at once. + * + * A ceiling on work, not a claim about the chain: mainnet commits a few + * thousand notes on a busy day and a single block has carried hundreds. The + * caption always says the real count. + */ +const MAX_PARTICLES = 96 +/** How long one note takes to fall in or climb out. */ +const FLIGHT_MS = 1500 +/** Most marks any single block may spawn, so one big block cannot fill the pool. */ +const PER_PULSE_CAP = 28 + +interface Particle { + /** Set while in flight; a free slot is `null`. */ + born: number + out: boolean + /** Where it crosses the rim, in radians. */ + angle: number + /** Start (or end) distance from the mouth, in px. */ + reach: number + /** 0.6–1 — a larger note draws a larger mark. */ + scale: number +} + +function useMeasuredWidth(): [React.RefObject, number] { + const ref = useRef(null) + const [width, setWidth] = useState(FALLBACK_WIDTH) + useEffect(() => { + const node = ref.current + if (!node) return + const observe = new ResizeObserver(([entry]) => { + if (entry) setWidth(Math.max(280, entry.contentRect.width)) + }) + observe.observe(node) + return () => observe.disconnect() + }, []) + return [ref, width] +} + +/** `true` when the viewer has asked for less motion, and whenever that changes. */ +function useReducedMotion(): boolean { + const [reduced, setReduced] = useState(false) + useEffect(() => { + // `matchMedia` is absent in some embedded viewers; absent means no stated + // preference, which is the same as not having asked for less motion. + if (typeof window.matchMedia !== 'function') return + const query = window.matchMedia('(prefers-reduced-motion: reduce)') + setReduced(query.matches) + const onChange = (e: MediaQueryListEvent) => setReduced(e.matches) + query.addEventListener('change', onChange) + return () => query.removeEventListener('change', onChange) + }, []) + return reduced +} + +/** Eased 0→1. Slow at the rim, quick at the mouth: a fall, not a slide. */ +function ease(t: number): number { + return t * t * (3 - 2 * t) +} + +export function WormholeStage({ decimals, symbol }: { decimals: number; symbol: string }) { + const observer = useObserverInstance() + const [wrap, width] = useMeasuredWidth() + const reduced = useReducedMotion() + const [last, setLast] = useState(null) + + const pool = useRef<(SVGCircleElement | null)[]>([]) + const particles = useRef( + Array.from({ length: MAX_PARTICLES }, () => ({ + born: 0, + out: false, + angle: 0, + reach: 0, + scale: 1, + })), + ) + /** Which slots are in flight. A slot is reusable the moment its note lands. */ + const live = useRef>(new Set()) + /** Whether a frame is already scheduled, so a burst of pulses starts one loop. */ + const running = useRef(false) + const mounted = useRef(true) + const geometry = useRef({ + cx: FALLBACK_WIDTH / 2, + cy: HEIGHT / 2, + rim: 150, + rx: 100, + squash: 0.42, + }) + + // The mouth scales with the panel. Fixed radii left a 1200px-wide stage mostly + // empty with a small ellipse adrift in it, and pushed the rim so far out that + // notes spent most of their flight off-canvas — visible only for the last + // moment before they landed, which is the half that reads as nothing + // happening. + const rx = Math.min(width * 0.3, 300) + // How flat the mouth is. Capped so the outermost ring fits the stage rather + // than being cut off top and bottom, and reused as the squash on every note's + // path — the marks have to travel in the plane of the thing they are falling + // into, or they read as passing in front of it. + const ry = Math.min(rx * 0.42, HEIGHT / 2 - 12) + const geo = { + cx: width / 2, + cy: HEIGHT / 2, + // Just past the mouth rather than past the panel: a note has to be on + // screen for its whole travel or the motion is not the thing being read. + rim: rx * 1.5, + rx, + squash: ry / rx, + } + geometry.current = geo + + useEffect(() => { + mounted.current = true + // Captured rather than read in the cleanup: the `Set` is created once by + // `useRef` and never replaced, so this is the same object either way — but + // holding it locally is what makes that promise visible, here and to the + // exhaustive-deps rule. + const inFlight = live.current + return () => { + mounted.current = false + inFlight.clear() + running.current = false + } + }, []) + + /** + * Advance every mark in flight by one frame. + * + * Held in a ref and reassigned each render so the loop always sees the + * current geometry without being torn down and restarted on every resize. + */ + const step = useRef<() => void>(() => {}) + step.current = () => { + const now = performance.now() + const { cx: x0, cy: y0, squash } = geometry.current + for (const slot of Array.from(live.current)) { + const p = particles.current[slot]! + const node = pool.current[slot] + const age = now - p.born + // Staggered: a pulse's notes are dealt out over a few frames rather than + // arriving as one ring, which reads as a burst instead of a shockwave. + if (age < 0) continue + if (age > FLIGHT_MS) { + live.current.delete(slot) + node?.setAttribute('opacity', '0') + continue + } + const t = ease(age / FLIGHT_MS) + // In falls from the rim to the mouth; out climbs the other way. + const distance = p.out ? p.reach * t : p.reach * (1 - t) + // A little swirl, so it reads as a throat rather than a drain pipe. + const angle = p.angle + (p.out ? -1 : 1) * t * 0.9 + if (!node) continue + node.setAttribute('cx', String(x0 + Math.cos(angle) * distance)) + // Flattened vertically to match the ellipses: the mouth is seen at an + // angle, so a note's path has to lie in the same plane. + node.setAttribute('cy', String(y0 + Math.sin(angle) * distance * squash)) + node.setAttribute('r', String(1.6 + p.scale * 2.2 * (p.out ? t : 1 - t))) + // Fades at the far end either way: arriving notes vanish into the mouth, + // leaving ones dissolve into the rest of the chain. + node.setAttribute('opacity', String(p.out ? 1 - t * t : Math.min(1, (1 - t) * 2.4))) + } + } + + /** + * Run frames until nothing is in flight, then stop completely. + * + * A still chain schedules no frames at all — the loop is started by a pulse + * and ends itself. A page left open on a quiet chain costs nothing, which an + * always-on `requestAnimationFrame` would not. + */ + const start = useRef<() => void>(() => {}) + start.current = () => { + if (running.current || live.current.size === 0) return + running.current = true + const frame = () => { + if (!mounted.current) { + running.current = false + return + } + step.current() + if (live.current.size > 0) { + requestAnimationFrame(frame) + } else { + running.current = false + } + } + requestAnimationFrame(frame) + } + + // Subscribe to pulses. Deliberately outside React state: `setLast` is the + // only thing that re-renders, and it carries the caption, not the motion. + 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) + if (draw === 0) return + // Mean value of the notes this pulse stands in for, as a fraction of a + // whole token, so a block moving thousands draws heavier marks than one + // moving change. Crude on purpose: it is a size, not a figure. + let mean = 0 + try { + mean = Number(BigInt(total) / BigInt(count)) / 10 ** decimals + } catch { + mean = 0 + } + const weight = Math.min(1, Math.max(0, Math.log10(Math.max(mean, 0.01)) + 2) / 4) + for (let i = 0; i < draw; i += 1) { + let slot = -1 + for (let s = 0; s < MAX_PARTICLES; s += 1) { + if (!live.current.has(s)) { + slot = s + break + } + } + // Pool exhausted: this note is not drawn. The caption still counts it. + if (slot === -1) return + live.current.add(slot) + const p = particles.current[slot]! + p.born = performance.now() + i * 45 + p.out = out + p.angle = Math.random() * Math.PI * 2 + p.reach = geometry.current.rim * (0.75 + Math.random() * 0.35) + p.scale = 0.6 + weight * 0.4 + } + } + + spawn(pulse.notesIn, false, pulse.amountIn) + spawn(pulse.notesOut, true, pulse.amountOut) + start.current() + }) + }, [observer, reduced, decimals]) + + const { cx, cy } = geo + + return ( +
+ + {/* The mouth. Three ellipses of the one hue at descending wash weight — + a throat seen edge-on, not a second series. */} + + + + + + {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}`} + + {reduced && motion off} + + )} +
+
+ ) +} diff --git a/web/src/index.css b/web/src/index.css index 2ea31a5..fd07379 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1032,6 +1032,75 @@ tr.mine .share-bar > i { stroke-width: 2; } +/* The live wormhole stage. + * + * Every value is one hue at a different wash weight — a throat seen edge-on, + * not a set of series. Direction is carried by which way a note travels, the + * same encoding the flow chart uses and for the same measured reason. */ +.stage-wrap { + position: relative; + background: var(--surface-1); + border: var(--rule); + box-shadow: var(--shadow); + margin-bottom: 26px; + /* Matches the stat row it stands in for, so swapping one for the other does + not move the page underneath. */ + padding: 6px 0 2px; +} + +.stage { + display: block; +} + +.stage-rim { + fill: none; + stroke: var(--data); +} + +.stage-rim-3 { + stroke-width: 1; + opacity: 0.18; +} + +.stage-rim-2 { + stroke-width: 1; + opacity: 0.3; +} + +.stage-rim-1 { + stroke-width: 1.5; + opacity: 0.5; +} + +.stage-core { + fill: var(--data-wash); + stroke: var(--data); + stroke-width: 1.5; +} + +.stage-note { + fill: var(--data-bright); +} + +.stage-caption { + display: flex; + flex-wrap: wrap; + gap: 4px 18px; + align-items: baseline; + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-secondary); + padding: 0 4px 4px; +} + +.stage-caption strong { + color: var(--data-bright); +} + +.stage-idle { + 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