Skip to main contentSkip to navigation

An asset as a reader meets it — the mark a build serves for it, or its own letters where none is served, the symbol, and the name where there is room.

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.

Loading...

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-query
import '@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.

PropTypeRequiredDefaultDescription
symbolstringno—Drawn beside the mark; an em dash where absent, and where both this and name are absent the disc stands alone
namestringno—Drawn quieter, and only where it differs from symbol
addressstringno—The shade, and the letters where symbol is absent
chainstringno—The venue key the badge is filed under, where a screen states which chain it reads
sizeSizeno'row'A rung: unit 16 · pill 24 · row 32 · card 32 · list 36 · panel 36 · page 56
amountReactNodeno—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 once

All 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.