A venue's lists name their assets in bare text: the picker, the market table, the
pool rows, the token page. Someone scanning forty rows for the one they hold is
reading forty strings, and reading is slower than recognising. Coin is the
shorter path to the row they came for — a mark, the symbol, and the name where
there is room. It is one component because the four screens that drew a token
had four spellings of it and had drifted: one showed the name first, one
truncated at a different width.
Every state of it, beside the rest of the family, is on the Marks page.
A mark where there is one, letters where there is not
An asset that ships a mark wears it, and nothing is drawn here for it — a mark is a brand, and a brand redrawn from memory is a different brand. Which files a build serves is the build's own fact, so an app states it once at its root and every asset below resolves against it:
import { Marks } from '@luxfi/ui/read'
<Marks value={{
token: (symbol) => served.has(symbol) ? `/tokens/${symbol}.svg` : null,
chain: (key) => chains.has(key) ? `/chains/${key}.png` : null,
}}>
{children}
</Marks>Null is the ordinary answer rather than the exception. An indexer returns id, symbol, name and decimals, measured against its own schema, and no image, so most of a chain's tokens have no mark at all. A component that expected a URL would draw a broken frame for most of them. The letters are derived from what is present instead: they always resolve, and they never ask anything for a file. A build that states nothing draws letters everywhere.
The letters
The symbol, stripped of punctuation, upper-cased, at most three characters. Two
is not enough on this chain — almost every asset is L plus a name, so LUX and
LUSD both reduce to LU, and a mark that collides misleads faster than no mark
would. Three separates the family.
It does not separate everything: USDC and USDT still meet, and are meant to. The mark is an aid to scanning, not an identifier. The symbol is in the row beside it and the shade comes from the address, so a pair sharing letters still differ on the page.
letters('LUX') // 'LUX'
letters('$LUX') // 'LUX' — punctuation takes a slot
letters('w-eth') // 'WET'
letters(undefined, '0xab12') // 'AB'
letters(undefined, undefined) // '??'A token carrying no symbol at all is not rare: an indexer records a pool before
it has ingested the tokens in it. The letters then come from the first two hex
digits after 0x, which is what the address column shows anyway, and two such
tokens still differ from each other. With neither symbol nor address the mark
reads ?? rather than drawing an empty circle.
The shade
shade hashes the lowercased address with FNV-1a and returns a step from 0 to
11. A step rather than a percentage, because the two lightnesses a mark needs —
its ground and its letters — are derived from that one number and so cannot
drift apart: the ground is 16 + step × 2.5%, the letters 62 + step × 2.5%.
FNV-1a rather than a sum of character codes. The input is hex, so its alphabet is sixteen characters wide, and a plain sum lands every address in the same narrow band: the first version put six real tokens between 180 and 344 of 360, two of them seventeen apart. Mixing on every character spreads them.
The hash reads the address lowercased, because addresses arrive checksummed from a chain and lowercased from a wallet, and one token wearing two marks is worse than one wearing none. Same token, same shade, on every screen and across reloads — that stability is the only property that makes a mark recognisable.
Lightness, not hue
@luxfi/ui/tokens.css states the rule plainly: one achromatic ladder plus
exactly two hues, reserved for error and success. A wall of tinted discs breaks
it twice — it spends colour on identity, and it gives the reader a second thing
that looks like state and is not. So the mark varies in lightness and the page
stays monochrome. The twelve steps cross a band that stays clear of the page
behind the mark and of the letters in front of it: low enough to read as an
object, wide enough that two marks side by side are two objects.
The text beside the mark takes the two colours every surface here uses —
paint.plain for the symbol, paint.quiet for the name — from
paint, which holds this surface's colours in the
design system's own names.
It takes no reading
Most of @luxfi/ui/read takes a reading rather than a value, because a component
that has flattened there is nothing and we could not ask into an empty list
cannot tell them apart afterwards.
type Rows<R> =
| { at: 'reading' }
| { at: 'read'; rows: R[] }
| { at: 'failed'; why: string }Coin takes fields, not a reading. It is drawn inside a row that a reading has
already delivered — Table draws cells only for { at: 'read' } — so the
question has been answered before Coin is called. Absence here is a different
thing: a token that states no symbol is a fact in a record that did arrive, not
an unanswered request, which is why the fallback draws letters from the address
rather than a placeholder.
Installation
npm install @luxfi/ui @hanzo/gui @hanzo/ui @hanzo/design @tanstack/react-queryimport '@luxfi/ui/tokens.css'
import '@hanzo/design/styles.css'
import { AppProvider } from '@luxfi/ui/provider'
import { Coin, Coins, Marks, letters, shade } from '@luxfi/ui/read'AppProvider goes once at the root of the app and mounts the gui engine and a
query client. The two sheets are where the names resolve: colour from
@luxfi/ui/tokens.css, spacing, elevation and measure from @hanzo/design.
Usage
In a row, where the name has room beside the symbol:
<Coin symbol={token.symbol} name={token.name} address={token.id} />At the head of a screen about one asset, where the same object is drawn larger and the name is read before the ticker:
<Coin symbol={token.symbol} name={token.name} address={token.id} size="page" />With no symbol and no name, this is the disc alone — which is what a market row whose token the indexer has not named yet, and a page about one address, each want:
<Coin address={token.id} />Both sides of a pool are one object, because a reader recognises the row by the marks before reading either ticker:
<Coins of={[base, quote]} say={`${base.symbol} / ${quote.symbol}`} />Props
Coin is a stack of @hanzo/gui primitives carrying style
props — there is no className anywhere in it.
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
symbol | string | no | — | Drawn beside the mark; an em dash where absent, and where both this and name are absent the disc stands alone |
name | string | no | — | Drawn quieter, and only where it differs from symbol |
address | string | no | — | The shade, and the letters where symbol is absent |
chain | string | no | — | The venue key the badge is filed under, where a screen states which chain it reads |
size | Size | no | 'row' | A rung: unit 16 · pill 24 · row 32 · card 32 · list 36 · panel 36 · page 56 |
amount | ReactNode | no | — | What the row states about the asset, drawn at the row's end |
card and page read the name before the ticker, because nothing on a product
card or at the head of a screen about one asset is being scanned. Every other
rung leads with the ticker and sets the name under it, out of the column's way.
Coins takes of — the assets, as { symbol?, address?, chain? } — with an
optional say for how the pool names itself, and the same size.
letters(symbol?: string, address?: string): string // one to three characters
shade(address?: string): number // a step, 0 to 11
identify(of: Of, marks?: Marks): Identity // the whole answer at onceAll three are pure. The same arguments give the same answer, in a test as on a
page — identify takes the served set as an argument, and useIdentity is the
same call reading it from the tree.
What it does not do
Coin resolves a mark and never fetches one. Marks answers synchronously from
what the build already has, so a name it has no file for comes back null and the
letters draw instead — where asking the network would leave a broken frame in
place of something readable. A file the build does name and the browser cannot
fetch comes to the same thing: the letters take its place, and the chain badge
that did not arrive is simply not drawn.
It does not identify. Letters collide by design, and a shade is one of twelve, so two assets can share both and a counterfeit will sometimes wear the mark of the asset it imitates. Anything that turns on telling two apart has to use the address. It does not verify either: an asset that names itself LUX is drawn as LUX, and neither the letters nor the shade is a claim that it is what it says.
The band the shade moves within is fixed and dark, so the letters do not follow a
light theme: they stay light on a dark ground wherever the disc is drawn. The
name elides to one line rather than wrapping, and gives way before the symbol
when the row is narrow. The disc is aria-hidden, because the name it
abbreviates is already in the row and a screen reader should not read it twice —
which also means a disc drawn with no symbol and no name announces nothing at
all. And it is not a control: no press handler, no link, no focus behaviour, no
state.