paint is the vocabulary every Lux surface 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.deep is asking for the shadow this design system
calls a deep one, 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, well, sheet, pill, lift 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 drawn against each other.
There are two surfaces in this vocabulary and no third. card is the page's own
ground inside a hairline — transparent, no wash, no lit edge, no shadow — so what
separates it from the page is the line and the room around it. well is the
other half: a block set into a card, filled a shade above the page, with no line
at all. A card frames and a well recedes, and which one a thing takes is the
whole of what says whether it is a surface or something set into one.
An earlier reading had it the other way — a card filled and lit along its top
edge, a border called the cheapest way to say this is a box and the loudest.
That reading is not what the surfaces measure. Across six screens every card,
sheet, dialog and popover reports box-shadow: none and
background-image: none, and the line is the whole object; which is why the
hairline's own value is the number this material is most sensitive to.
sheet is card made opaque, for something floating over a page that must not
read through it. pill is a control standing on the page's own ground rather
than on a surface. lift is the answer a card that is itself a link gives the
pointer: the line goes up to the ink, because a card with no shadow cannot cast a
deeper one.
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/material'
<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/material'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/material'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 |
faint | --color-text-faint | a figure nobody typed, a step nobody reached |
--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 faint are not state colours at all. wrong is the ladder's own word for
text that is refused; faint is its third ink rung, thirty-eight hundredths of
the ink, which is where a dormant figure and an unreached step are set. Running
text is at three strengths here and no more — plain, quiet, faint — which
is why these sit apart from the status names above.
Depth
| Entry | Token | What it is |
|---|---|---|
deep | --shadow-2xl | the one object that hangs a shadow |
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.
One shadow, where there were five. A raised panel, a floating one, a lit top edge
and a radial wash are gone, because the surfaces draw none of them. deep is the
one object that does hang a shadow — a ticket on a page with nothing else on it,
where the depth is the whole of what says it sits above the paper rather than in
it.
Shape and measure
| Entry | Token | What it is |
|---|---|---|
round | --radius-md | a fee row, a control beside a heading |
rounder | --radius-lg | a picker |
roundest | --radius-xl | a sheet, and the action on it |
corner | --radius-2xl | the list of steps |
column | --container-max | how wide a grid gets |
prose | --container-prose | how wide a column of text gets |
Four rungs, and which one a thing takes says what kind of thing it is: a sheet
and the blocks inside it are drawn rounder than the controls on them, which is
what keeps a stack of blocks reading as one object. They resolve through
--radius-* rather than through the Lux ladder directly, because that is the
name the appearance panel and every other component on this stack read;
tokens.css writes the Lux rungs into it, so both the brand and the knob are
satisfied by one value.
The engine's own name for the page
| Entry | Token | What it is |
|---|---|---|
ground | --background | the page behind it |
Its two neighbours are gone. edge was --border, a second hairline beside
line, and the two disagreed by a rung — so a card drew whichever one its author
happened to reach for, and one notice in this package reached for the other. The
values agree now, and a gate measures that they still do. panel was --card, a
second raised surface beside surface; the two are nine values apart in the dark
theme and identical in the light one, so a surface built from both had a seam
nobody could see until they switched. One role, one name: a component riding the
engine's own classes still reads --card and --border through them, and a
component reaching for a value reaches for the Lux ladder.
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 |
Grounds a screen composes
| Entry | Value | What it is |
|---|---|---|
act | the ink at eight hundredths | under the one press a screen is built around |
veil | --color-veil | what a dialog is laid over |
haze | a radial of the ink at nineteen | a soft mark on the ground behind a screen |
stipple | the ink at a quarter | the field of dots a figure is set on |
Four of the five surfaces are written as a fraction of the ink rather than as a
colour, so each composes over whichever ground it lands on and the one number is
the one the reference states. haze is a gradient and not a blurred sprite: a
circle whose colour runs out before its edge does has no edge left to blur, so
there is nothing to fetch and nothing that can fail to load. veil is the
exception and is a declared token, because a scrim is a colour in its own right
and a colour written into a component is a colour a tenant cannot move.
A field's own names
| Entry | Token | What it is |
|---|---|---|
live | --color-input-border-focus | a field mid-interaction |
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.
The recipes
| Entry | Properties |
|---|---|
card | backgroundColor, borderWidth, borderColor, boxShadow, backgroundImage |
well | backgroundColor, borderWidth, boxShadow, backgroundImage |
sheet | the same set as card, opaque |
pill | borderRadius, backgroundColor, borderColor, borderWidth |
lift | borderColor |
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: several of these names are declared by both
@luxfi/ui/tokens.css and @hanzo/design, and which declaration a component
gets is decided by load order — paint names the token and cannot choose between
two declarations of it. @luxfi/ui/tokens.css is imported after
@hanzo/design/styles.css for that reason, and that ordering is what puts the
Lux corners, ramp, weights and hairline onto the knobs the rest of the stack
reads. 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.