A figure on a chain surface rarely describes itself. Which generation a position
belongs to, whether a range is still in play, that a number is this interface's
arithmetic rather than something the chain said — each qualifies the thing next
to it and is not the thing. Mark is that qualifier: one or two words in a rule,
set inline beside what they are about.
A word, not a colour
Every mark has to survive being read aloud in sequence with what it sits beside. That rules out an icon, and it rules out colour carrying the meaning on its own: a green ring around a number says nothing to a reader who cannot see it, and nothing to one who can but has not been given the legend. So the statement is the text — in range, out of range, derived — and the colour only agrees with it.
An outline, not a fill
The rule is one pixel and the fill is nothing. A row of filled chips reads as a
row of buttons, and someone who tries to press the one saying out of range has
been told something false about what the screen does. Being inline matters for
the same reason: a figure and the word derived after it are one statement, and
a flex box between them breaks the line and makes the qualifier look like the
next fact. textAlign is stated as left because the other half of these sit
inside controls that centre their own text.
It takes no reading
The rest 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 }Mark is the member of the set that does not. It takes children and a colour,
and whoever places it has already read whatever the word reports. Nothing in the
package draws one on your behalf: Table writes its own failure through
Answer, and Tally paints its own change, so a mark appears where a caller
puts it and says what the caller wrote.
That is also where the discipline lands. <Mark paint={paint.good}> beside a
figure that is still reading is a claim the reading has not made, and no type
will catch it.
The colours a mark may take
Paint is a closed union, not string. The source gives the reason twice over.
gui's style props accept a theme token type that a literal var(--…) satisfies
and a widened string does not. And the set is itself the point — a yes, a no,
an unanswered question, and two neutral weights. A further colour would be a
distinction with no legend.
| Value | Token | What it says |
|---|---|---|
paint.quiet | --color-text-secondary | the default: qualifying, not judging |
paint.plain | --color-text-primary | the same, at the weight of the text it follows |
paint.good | --color-status-good | a yes |
paint.warn | --color-status-warn | an unanswered question |
paint.bad | --color-status-bad | a no |
Only two of those are hues. --color-status-warn resolves to
--lux-state-notice, which tokens.css defines as the loudest neutral rather
than a colour, because attention is not a hue in this palette. The other two
resolve to --lux-state-error and --lux-state-success, the only hues the
brand permits outside the achromatic ladder, both held to AA as text on the card
fill.
Because the union is over literal token strings, a colour that arrives as a
string will not type-check, and neither will another entry from paint —
paint.link and paint.wrong are real tokens and are not things a mark may be.
Where the colours come from
paint is the whole vocabulary this surface is painted with, in the design
system's own names, and the five above are a slice of it. It exists because the
same handful of colour names had been written out in five files, so a change of
mind about a hairline was five edits and a chance for one of them to disagree.
It carries more than colour: elevation (raised, floating, lit, sheen),
radius and measure (round, column, prose), the focus ring, and a field's
own separate names, which the engine paints from --color-input-* rather than
from the surface ladder. Some entries are groups of style props rather than
single values, and spread onto a component.
import { Card } from '@luxfi/ui/gui'
import { paint } from '@luxfi/ui/read'
<Card {...paint.card} focusVisibleStyle={paint.focus} />Names, not values. A component asking for paint.raised is asking for the
shadow this design system calls a raised thing, and it moves when the system
moves. A component carrying 0 4px 8px rgba(…) is a second design system with
one user.
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 { Mark, paint, type Paint } 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 { Mark, paint, type Paint } from '@luxfi/ui/read'<Mark>epoch 41</Mark><Mark paint={position.inRange ? paint.good : paint.bad}>
{position.inRange ? 'in range' : 'out of range'}
</Mark>Beside a figure, the mark and the figure are one sentence.
import { SizableText, XStack } from '@luxfi/ui/gui'
<XStack gap="$2" alignItems="baseline">
<SizableText size="$7" fontWeight="600">{fees}</SizableText>
<Mark>derived</Mark>
</XStack>Props
| Prop | Type | Required | Default |
|---|---|---|---|
paint | Paint | no | paint.quiet |
children | ReactNode | yes | — |
Paint is exported alongside the component, so a caller storing a colour can
name its type: const colour: Paint = paint.good.
What it does not do
Mark holds no state, reads nothing, and validates nothing. It will not check
that the word agrees with the colour — <Mark paint={paint.good}>refused</Mark>
renders exactly as written — and it does not know whether the reading behind the
word arrived. It takes no press handler and adds no focus behaviour, so it never
enters the tab order and is not a control to be made into one; a mark that needs
to be pressed is a button with a mark in it. It has no icon, no dot, no dismiss,
and no variants beyond the colour, and nothing truncates: a long phrase pushes
the line it sits on rather than eliding. The line box is a fixed 18 pixels while
the font size is a token, so at a much larger type setting the text stops
tracking the rule around it. And it announces nothing of its own to a screen
reader — it is a span, read in sequence with its neighbour, which is the reason
the meaning has to be in the words.