ui: separate mainnet from testnets in the chain nav
deploy / build (push) Successful in 1m1s
deploy / deploy-api (push) Has been skipped
deploy / deploy-web (push) Successful in 5s

`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
This commit is contained in:
2026-09-10 11:05:38 +03:00
co-authored by Claude Opus 5
parent 7d081c7746
commit adaf00e1f5
2 changed files with 71 additions and 30 deletions
+55 -29
View File
@@ -5,6 +5,18 @@
* 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
@@ -22,15 +34,16 @@ 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} has not produced a block yet.`
return `${chain.display_name}${kind} has not produced a block yet.`
case 'unreachable':
return `${chain.display_name}'s node is not answering; the last known standings are shown.`
return `${chain.display_name}${kind} — its node is not answering; the last known standings are shown.`
case 'syncing':
return `${chain.display_name}'s node is still importing history.`
return `${chain.display_name}${kind} — its node is still importing history.`
default:
return chain.display_name
return `${chain.display_name}${kind}`
}
}
@@ -47,33 +60,46 @@ export function ChainSwitcher({
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">
<span className="eyebrow chains-label">Chains</span>
<div className="chains-list">
{navigable.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 on the site.
return (
<Link
key={chain.genesis ?? chain.id}
to={href({ chain: chain.id, window: windowName })}
className={isActive ? 'chain-chip is-active' : '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>
{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>
)
}
+16 -1
View File
@@ -276,11 +276,19 @@ a {
.chains {
display: flex;
align-items: center;
gap: 14px;
gap: 14px 28px;
flex-wrap: wrap;
padding: 0 0 18px;
}
/* One labelled group per kind of network. The gap between groups is wider than
the gap inside one, so the split reads before either label does. */
.chain-group {
display: flex;
align-items: center;
gap: 14px;
}
.chains-label {
flex: none;
}
@@ -1386,3 +1394,10 @@ tr.mine .share-bar > i {
padding-left: 10px;
border-left: 1px solid var(--border);
}
/* A testnet. Dashed rather than a colour, and a second channel behind the group
heading rather than the only one — a single visual difference reads as
decoration to anyone who has not been told what it means. */
.chain-chip.is-testnet {
border-style: dashed;
}