Skip to main contentSkip to navigation

The names a Lux surface is drawn with — colour, depth, radius, measure — kept in one object, so a component asks for a shadow instead of carrying one.

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.

Loading...

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-query
import '@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 } = paint

Props

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

EntryTokenWhat it is for
plain--color-text-primarythe figure or name a reader came for
quiet--color-text-secondarylabels, units, and anything qualifying them
line--color-border-dividerthe hairline between rows
surface--color-bg-primarya panel's fill
body--color-bg-bodythe page behind it
solid--color-button-solid-bga solid fill, which inverts with the theme
ink--color-button-solid-texttype on that fill
subtle--color-button-subtle-bga fill that is barely one
link--color-link-primarysomewhere else on this venue

solidInk is a second name for the token ink already holds.

State

EntryTokenWhat it says
good--color-status-gooda yes
warn--color-status-warnan unanswered question
bad--color-status-bada no
wrong--color-text-errorinput that was refused
faint--color-text-fainta 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

EntryTokenWhat it is
deep--shadow-2xlthe one object that hangs a shadow
ring--ringthe focus colour
halo--ring-focusthe 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

EntryTokenWhat it is
round--radius-mda fee row, a control beside a heading
rounder--radius-lga picker
roundest--radius-xla sheet, and the action on it
corner--radius-2xlthe list of steps
column--container-maxhow wide a grid gets
prose--container-prosehow 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

EntryTokenWhat it is
ground--backgroundthe 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

EntryTokenWhat it is
pending--color-skeleton-startthe bar standing in for a figure that has not arrived
stats--color-stats-bgthe ground a figure is set on where it is quoted rather than read

Grounds a screen composes

EntryValueWhat it is
actthe ink at eight hundredthsunder the one press a screen is built around
veil--color-veilwhat a dialog is laid over
hazea radial of the ink at nineteena soft mark on the ground behind a screen
stipplethe ink at a quarterthe 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

EntryTokenWhat it is
live--color-input-border-focusa field mid-interaction
inputInk--color-input-fgwhat is typed
inputGround--color-input-bgthe field's fill
inputFrozen--color-input-bg-readonlythe same field, read-only
inputEdge--color-input-borderits outline
inputNear--color-input-border-hoverunder a pointer
inputBad--color-input-border-errorholding 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

EntryProperties
cardbackgroundColor, borderWidth, borderColor, boxShadow, backgroundImage
wellbackgroundColor, borderWidth, boxShadow, backgroundImage
sheetthe same set as card, opaque
pillborderRadius, backgroundColor, borderColor, borderWidth
liftborderColor
focusoutlineColor, 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.