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

Loading...

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-query
import '@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 } = 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
off--color-text-disableda 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

EntryTokenWhat it is
raised--shadow-mda panel sitting on the page
floating--shadow-lgsomething over it — a menu, a dialog
lit--edge-highlightan inset hairline along the top edge
sheen--sheen-carda radial wash across the top of a card
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.

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

EntryTokenWhat it is
round--radius-mdthe radius of a control
rounder--radius-lgthe radius of a card
column--container-maxhow wide a grid gets
prose--container-prosehow wide a column of text gets

The engine's own surface names

EntryTokenWhat it is
panel--carda raised thing
edge--borderits outline, where one is drawn
ground--backgroundthe 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

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

A field's own names

EntryTokenWhat it is
live--color-input-border-focusa field mid-interaction
ghost--color-input-placeholderthe text before an entry
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.

card, sheet and focus

EntryProperties
cardbackgroundColor, boxShadow, backgroundImage, borderWidth
sheetthe same, drawn from --shadow-sheet-2
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: --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.