Files
observer/web/src/components/ChainSwitcher.tsx
T
grenadeandClaude Opus 5 adaf00e1f5
deploy / build (push) Successful in 1m1s
deploy / deploy-api (push) Has been skipped
deploy / deploy-web (push) Successful in 5s
ui: separate mainnet from testnets in the chain nav
`Quantus` and `Heisenberg` sat side by side, identically styled. One is the real
network. Somebody reading a balance, a reward or a transfer had no way to tell
from the chrome whether the number meant anything, and Planck makes it two
testnets against one mainnet.

Two labelled groups rather than a styling difference alone, because a lone
visual difference reads as decoration to anyone who has not been told what it
means:

    MAINNET  [ Quantus 329 ]    TESTNETS  [ Planck 110 ] [ Heisenberg 4 ]

The dashed border on a testnet chip is a second channel behind the heading
rather than the only one, and every chip's tooltip now says outright that a
testnet's balances and rewards are not real.

No backend work: `ChainInfo.mainnet` has been on the wire since the registry was
written and the frontend simply ignored it. It stays operator-asserted and is
never inferred from the name — a chain publishing itself as "Quantus Staging
Mainnet" must not collect the heading by having the word in its title.

A chain list with no mainnet in it renders one `TESTNETS` group rather than an
empty `MAINNET` heading.

Closes #5

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-10 11:05:38 +03:00

106 lines
4.4 KiB
TypeScript

/**
* The chain nav.
*
* Only the chains this observer holds an RPC endpoint for, ordered by node
* count descending — the busiest is the one most people came for, and the order
* is a real fact about the network rather than a config ordering.
*
* **Mainnet and testnets are separated and labelled.** Side by side and
* identically styled they had equal weight, and somebody reading a balance, a
* reward or a transfer had no way to tell from the chrome whether the number
* meant anything. Two headings say which is which without a legend and without
* the reader having to know a convention; the dashed border on a testnet chip
* is a second channel, because one visual difference alone reads as decoration
* to anybody who has not been told what it means.
*
* `mainnet` is operator-asserted in the `[[chains]]` entry and never inferred
* from the name — a chain publishing itself as "Quantus Staging Mainnet" must
* not collect the heading by having the word in its title.
*
* The rest are **not advertised**. They were listed as disabled chips for a
* while, on the reasoning that they are part of the network and hiding them
* misrepresents its shape. In practice that put five dead chips above the
* standings on every page, four of which had never had anything behind them,
* and the one live chain was the smallest thing in the row. Nothing is
* blocked: `/:chain/...` still resolves for any chain the backend knows, and
* the API still reports every one of them with its `tracking`. This is only
* about what the page offers unprompted.
*/
import { Link } from 'react-router-dom'
import type { ChainInfo } from '../api/generated/ChainInfo'
import type { Window as WindowName } from '../api/generated/Window'
import { href } from '../lib/routes'
function reason(chain: ChainInfo): string {
const kind = chain.mainnet ? '' : ' (a testnet — its balances and rewards are not real)'
switch (chain.status) {
case 'awaiting':
return `${chain.display_name}${kind} has not produced a block yet.`
case 'unreachable':
return `${chain.display_name}${kind} — its node is not answering; the last known standings are shown.`
case 'syncing':
return `${chain.display_name}${kind} — its node is still importing history.`
default:
return `${chain.display_name}${kind}`
}
}
export function ChainSwitcher({
chains,
active,
window: windowName,
}: {
chains: ChainInfo[]
active: string | null
/** Carried across the switch: changing chain is not a reason to change span. */
window: WindowName
}) {
const navigable = chains.filter((chain) => chain.tracking === 'full')
if (navigable.length === 0) return null
const groups: { label: string; chains: ChainInfo[] }[] = [
{ label: 'Mainnet', chains: navigable.filter((c) => c.mainnet) },
{ label: 'Testnets', chains: navigable.filter((c) => !c.mainnet) },
].filter((g) => g.chains.length > 0)
return (
<nav className="chains" aria-label="Chain">
{groups.map((group) => (
<div key={group.label} className="chain-group">
<span className="eyebrow chains-label">{group.label}</span>
<div className="chains-list">
{group.chains.map((chain) => {
const isActive = chain.id === active
// A link, not a button: a chain is a page with an address, and
// the chip is how most people reach it — so it has to survive a
// middle click and a right-click-copy like any other link.
return (
<Link
key={chain.genesis ?? chain.id}
to={href({ chain: chain.id, window: windowName })}
className={
['chain-chip', isActive ? 'is-active' : '', chain.mainnet ? '' : 'is-testnet']
.filter(Boolean)
.join(' ') || 'chain-chip'
}
aria-current={isActive ? 'page' : undefined}
title={reason(chain)}
>
<span className="chain-name">{chain.display_name}</span>
{chain.node_count !== null && (
<span className="chain-nodes" title={`${chain.node_count} nodes on telemetry`}>
{chain.node_count}
</span>
)}
</Link>
)
})}
</div>
</div>
))}
</nav>
)
}