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.
A monogram, because nothing carries a logo
The indexer returns id, symbol, name and decimals, measured against its own schema, and no image. A component that expected a URL would draw a broken frame for every token this venue actually has. A monogram is derived from what is present instead: it always resolves, and it never asks anything for a file.
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.
monogram('LUX') // 'LUX'
monogram('$LUX') // 'LUX' — punctuation takes a slot
monogram('w-eth') // 'WET'
monogram(undefined, '0xab12') // 'AB'
monogram(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, Face, monogram, 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
import { Coin, Face, monogram, shade } from '@luxfi/ui/read'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 token page, where the same object is drawn larger:
<Coin symbol={token.symbol} name={token.name} address={token.id} size="head" />Where the row already carries the name in a column of its own, the mark stands alone:
<Face symbol={token.symbol} address={token.id} />monogram and shade are exported beside the components, so a surface drawing
something the pair do not cover derives it from the same two functions rather
than from a second opinion about what a token's letters are.
Props
Coin is a stack of @hanzo/gui primitives carrying style
props — there is no className anywhere in it — and it composes a Face with
the text beside it, passing size through to both.
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
symbol | string | no | — | Drawn beside the mark; an em dash where absent |
name | string | no | — | Drawn quieter, and only where it differs from symbol |
address | string | no | — | The shade, and the letters where symbol is absent |
size | 'row' | 'head' | no | 'row' | The mark is 22px in a row, 34px at a head |
Face is the mark on its own, and takes symbol, address and size with the
same meanings.
monogram(symbol?: string, address?: string): string // one to three characters
shade(address?: string): number // a step, 0 to 11Both are pure. The same arguments give the same answer, in a test as on a page.
What it does not do
Coin fetches nothing — no image, no icon set, no request of any kind — so a
venue that does publish token art has to draw it somewhere else. It does not
identify: a monogram collides by design, and a shade is one of twelve, so two
tokens can share both and a counterfeit will sometimes wear the mark of the token
it imitates. Anything that turns on telling two tokens apart has to use the
address. It does not verify either: a token that names itself LUX is drawn as
LUX, and neither the letters nor the shade is a claim that the asset is what it
says. The band the shade moves within is fixed and dark, so the mark does not
follow a light theme: the letters stay light on a dark ground wherever it is
drawn. The name elides to one line rather than wrapping, and gives way before the
symbol when the row is narrow. The mark 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 Face drawn without a Coin beside it announces nothing at
all. And it is not a control: no press handler, no link, no focus behaviour, no
state.