paint is the vocabulary the rest of @luxfi/ui/read draws with. It is a plain
object: no component, no hook, nothing to mount. Every entry is a token name
written as a var(--…) string, which a gui style prop and a DOM style
attribute take alike, so one object serves a gui box and a td.
It exists for the reason its source records. 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 — and the depth the design system publishes was in none of them, which is how a card ended up as a one-pixel outline on flat black: correct, and flat.
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. That is the whole
of the discipline here: the module holds names, the token layers hold values, and
a component holds neither.
Where the names resolve
Colour comes from @luxfi/ui/tokens.css, the canonical Lux ladder: achromatic,
with error and success the only hues it permits. Elevation, radius and the
container widths come from @hanzo/design, which publishes them alongside
spacing and motion; see gui.hanzo.ai for that layer.
No entry carries a fallback. A custom property that was never declared is guaranteed-invalid, so the declaration referring to it is invalid at computed-value time and the property takes its inherited or initial value instead. Nothing is logged. A surface missing the token layer inherits whatever is around it rather than falling back to a Lux default, which is a failure that looks like a design decision.
It takes no reading
The components in this package take a reading rather than a value:
type Rows<R> =
| { at: 'reading' }
| { at: 'read'; rows: R[] }
| { at: 'failed'; why: string }paint is not one of them — it is the value they are drawn with. Each arm of
that type still has a colour, though, and the names are here. Table fills its
placeholder bars with pending while a read is out; Answer rules an alert down
its left edge in bad when one failed. A read that came back with no rows gets
neither, because we asked and the answer was nothing, and a fact about the venue
is not a fault to be painted like one.
Recipes
card, sheet and focus are style objects rather than single values, because
what each names is a combination, and splitting one is how two panels on the same
screen end up lit from different directions.
card is a surface the way this interface draws one: a fill a shade lighter than
the page, a hairline of light along its top edge, a shadow, and borderWidth: 0.
A border is the cheapest way to say this is a box and the loudest — four hard
lines competing with the type inside them — so a screen of these reads as depth
rather than as a grid. sheet is the same object floating over the page instead
of sitting on it, drawn from --shadow-sheet-2, with the edge highlight already
composed in; that deeper shadow is what tells a reader a dialog is in front, and
a hairline cannot.
focus is the ring a keyboard leaves on a surface drawn from a bare card. The
source records twenty-one copies of that recipe across fifteen files, and they
disagreed: ten drew the ring in the text colour, eight in --ring.
--ring is the design system's answer — it publishes the token and holds it to a
contrast ratio — so a ring in the text colour is a second opinion about a
settled question. It is stated only on controls drawn from a bare surface; a
primitive that carries its own ring keeps it.
On a gui component a recipe is spread as props. On a DOM element it goes into
style.
import { Card } from '@luxfi/ui/gui'
import { paint } from '@luxfi/ui/read'
<Card {...paint.card} focusVisibleStyle={paint.focus} /><td style={{ ...paint.card, borderRadius: paint.round }} />Installation
npm install @luxfi/ui @hanzo/gui @hanzo/ui @hanzo/design @tanstack/react-queryimport '@luxfi/ui/tokens.css'
import '@hanzo/design/styles.css'
import { paint } from '@luxfi/ui/read'paint names tokens and does not ship them. The two sheets are what define the
names: colour in @luxfi/ui/tokens.css, elevation, shape and measure in
@hanzo/design. Nothing here needs a provider — the entries are strings.
Usage
import { paint } from '@luxfi/ui/read'import { SizableText } from '@luxfi/ui/gui'
<SizableText color={paint.plain}>{price}</SizableText>
<SizableText color={paint.quiet}>{unit}</SizableText>Destructuring at the top of a module is the usual spelling in this package, and renaming on the way out is how a file says what a name is doing in it:
const { plain, quiet, line, bad, body: ground } = paintProps
paint is a value, not a component: it takes nothing and renders nothing. What
it holds is below, in the grouping the source keeps.
Colour
| Entry | Token | What it is for |
|---|---|---|
plain | --color-text-primary | the figure or name a reader came for |
quiet | --color-text-secondary | labels, units, and anything qualifying them |
line | --color-border-divider | the hairline between rows |
surface | --color-bg-primary | a panel's fill |
body | --color-bg-body | the page behind it |
solid | --color-button-solid-bg | a solid fill, which inverts with the theme |
ink | --color-button-solid-text | type on that fill |
subtle | --color-button-subtle-bg | a fill that is barely one |
link | --color-link-primary | somewhere else on this venue |
solidInk is a second name for the token ink already holds.
State
| Entry | Token | What it says |
|---|---|---|
good | --color-status-good | a yes |
warn | --color-status-warn | an unanswered question |
bad | --color-status-bad | a no |
wrong | --color-text-error | input that was refused |
off | --color-text-disabled | a control that cannot be used |
--color-status-good and --color-status-bad resolve to --lux-state-success
and --lux-state-error, the pair the brand permits outside the achromatic
ladder. --color-status-warn resolves to --lux-state-notice, which the token
file defines as a neutral rung: attention is not a hue in this palette. wrong
and off are not state colours at all — they are the ladder's own words for text
that is wrong and text that is off, which is why they sit apart from the status
names above.
Depth
| Entry | Token | What it is |
|---|---|---|
raised | --shadow-md | a panel sitting on the page |
floating | --shadow-lg | something over it — a menu, a dialog |
lit | --edge-highlight | an inset hairline along the top edge |
sheen | --sheen-card | a radial wash across the top of a card |
ring | --ring | the focus colour |
halo | --ring-focus | the shadow composed from it |
ring and halo are two roles under two names on purpose. A box-shadow is not a
colour, and the one name for both was reaching outlineColor in three places,
where it did nothing and the outline fell back silently to the text colour.
The elevation values in @hanzo/design are published as a pair. On black the
inset highlight does most of the work and the drop is heavy; on white the
highlight is zeroed — a white line on a white card is nothing — and the drop
carries all of it. Both halves flip underneath the name, so a component that
asked for lit does not ask again.
Shape and measure
| Entry | Token | What it is |
|---|---|---|
round | --radius-md | the radius of a control |
rounder | --radius-lg | the radius of a card |
column | --container-max | how wide a grid gets |
prose | --container-prose | how wide a column of text gets |
The engine's own surface names
| Entry | Token | What it is |
|---|---|---|
panel | --card | a raised thing |
edge | --border | its outline, where one is drawn |
ground | --background | the page behind it |
These are what the theme row publishes; the ladder above is @luxfi/ui's. Both
are live, and a component reaches for whichever its neighbours use. They do not
resolve alike: in the dark theme surface is the n-900 rung and panel the
n-950 one, so the two ladders inside a single surface put a seam in it. In the
light theme both are white and the mistake is invisible until someone switches.
Figures
| Entry | Token | What it is |
|---|---|---|
pending | --color-skeleton-start | the bar standing in for a figure that has not arrived |
stats | --color-stats-bg | the ground a figure is set on where it is quoted rather than read |
A field's own names
| Entry | Token | What it is |
|---|---|---|
live | --color-input-border-focus | a field mid-interaction |
ghost | --color-input-placeholder | the text before an entry |
inputInk | --color-input-fg | what is typed |
inputGround | --color-input-bg | the field's fill |
inputFrozen | --color-input-bg-readonly | the same field, read-only |
inputEdge | --color-input-border | its outline |
inputNear | --color-input-border-hover | under a pointer |
inputBad | --color-input-border-error | holding something refused |
The engine paints an input from --color-input-* rather than from the surface
ladder, so these are separate values for the same roles and a caller must not
reach across: an input drawn with surface and line is a near-miss of a
control the rest of the page draws correctly.
card, sheet and focus
| Entry | Properties |
|---|---|
card | backgroundColor, boxShadow, backgroundImage, borderWidth |
sheet | the same, drawn from --shadow-sheet-2 |
focus | outlineColor, outlineStyle, outlineWidth, outlineOffset |
What it does not do
It ships no CSS, and nothing here checks that a stylesheet is mounted. It
arbitrates nothing either: --shadow-md is declared by both
@luxfi/ui/tokens.css and @hanzo/design, with different alphas, and which one
a component gets is decided by load order — paint names the token and cannot
choose between two declarations of it. It holds no name to a
contrast ratio; the token layers state their own intentions and paint records
none of them, so a caller pairing quiet with subtle gets whatever those two
rungs happen to give. It does not enforce its own use: a component may still
write a literal, and one does — Coin computes hsl(0 0% N%) per address,
because a per-address lightness is not a name any system publishes, and derived
colour stays outside. Type scale, spacing and motion are not here at all; a
component asks the engine for those. And it holds no state and reads nothing —
not the theme, not the DOM, not a media query. The theme flips underneath it,
which is the point: the names do not change when the ground does.