ui · reference

read me first every example on this page is rendered by ui.css itself — the markup pane under each one is that example's own DOM, read back out.

foundation

Tokens

Every colour, size and duration in the library is a custom property on :root. Nothing else is configuration — retinting the library means overriding tokens, never rules.

Palette

the roles, in the theme you are reading
--ui-bg
--ui-surface
--ui-sunken
--ui-border
--ui-border-strong
--ui-fg
--ui-fg-muted
--ui-fg-faint
--ui-accent
--ui-accent-soft
--ui-ok
--ui-warn
--ui-warn-ink
--ui-danger
--ui-info

The token contract

Light is the ground truth: every token has its definition on bare :root, and the dark blocks only reassign. A token defined only inside a dark block is undefined for a light viewer.

grouptokenslight → dark
surfaces --ui-bg --ui-surface --ui-surface-raised --ui-sunken --ui-border --ui-border-strong #ffffff#08090a
ink --ui-fg --ui-fg-muted --ui-fg-faint --ui-fg-inverse #141c15#ece7db
accent --ui-accent -hover -active -fg -soft -soft-fg -ring sage #476349 → brass #cfa14d
status --ui-ok --ui-warn --ui-warn-ink --ui-danger --ui-info, each with a -soft five hues, and only five
type --ui-font-sans --ui-font-mono --ui-text-2xs…2xl --ui-weight-* --ui-leading-* --ui-tracking-* identical in both
metrics --ui-space-1…7 (4·8·12·16·24·32·48px), --ui-radius-sm|md|lg|full identical in both
depth --ui-shadow-sm|md|lg --ui-glow --ui-glow: none → a brass halo
motion --ui-fast (110ms) --ui-slow (240ms) --ui-ease identical in both

Per-component hooks

Set these inline or in a wrapper rule; they are the only knobs a component reads that the palette does not supply.

propertyread bydefault
--ui-container[data-container] max width100rem
--ui-grid-min[data-grid] column floor280px
--ui-icon-size.icon1.15em
--ui-meter-value.meter filla percentage
--ui-orb-size.orb34px
--ui-panel-accent.panel head rail and name--ui-accent

foundation

Token reference

Every --ui-* custom property the library defines, one row each, light beside dark. The rows are parsed out of ui.css when this page loads — the same stylesheet the page is wearing — so the reference cannot drift from the tokens it documents.

Swatches are painted from the parsed literal, not through var(): the light column stays light while you read this in dark. A dark cell shown dim is a token the dark blocks never reassign — the light value is the dark value.

tokenlightdark
reading ui.css

foundation

Theming

Light is white and green; dark is black and gold. Both are first-class, and a manual toggle wins over the system in both directions.

selectorwins over
:root— light is the ground truth; every token is defined here
@media (prefers-color-scheme: dark) :root:not([data-theme="light"])the system default
:root[data-theme="dark"]a light system
:root[data-theme="light"]a dark system

That is the whole contract. The toggle in this page's appbar is exactly it — three buttons writing (or removing) one attribute:

document.documentElement.dataset.theme = "dark";   // explicit
document.documentElement.removeAttribute("data-theme");  // back to the system

color-scheme is set alongside, so native form controls, scrollbars and Highlight follow.

Retheming

Override tokens, not rules. Anything under --ui-accent* retints the whole library:

:root { --ui-accent: #7c3aed; --ui-accent-soft: #f1eafe; --ui-accent-soft-fg: #5b21b6; }

Bring your own hues, in our shape

The library ships five semantic hues and that is the whole vocabulary, on purpose: once --ui-danger means "series 4", nothing on the page can mean danger any more. Declare your own tokens under your own prefix and feed them to the --ui-* hooks.

three panels, three hues that are not the library's
ears
--plot-ears
mouth
--plot-mouth
wire
--plot-wire

Two rules keep it honest: never redefine a --ui-* token to mean something non-semantic, and check your hues against --ui-bg in both themes — the library's contrast measurements say nothing about colours it has never seen.

Two rules that bite

On <html>, <body> stays transparent. CSS propagates the root element's background to the canvas, which paints the viewport correctly but leaves the computed background of both elements at rgba(0, 0, 0, 0). Anything reading body's background — a nested scroll container, a screenshot tool, an embedded widget — sees nothing. Put .ui on <body> instead, or set body { background: var(--ui-bg); } alongside it.

[hidden] is guarded in the base layer:where([hidden]:not([hidden="until-found"])) { display: none !important; } — because components set display and a component class beats the UA's rule. Without the guard, el.hidden = true silently does nothing on a pill, a panel or a strip.

foundation

Type

Class-based, not element-based: the heading level is the document's business, the size is the design's.

the whole scale

eyebrow

Heading one

Heading two

Heading three

A lead paragraph carries the sentence a section opens on.

Body copy is the default — no class needed inside .ui.

Muted copy steps back one notch.

Faint copy steps back two, and must not carry meaning alone.

mono 00:12.401 · seq 1041

A link inside a sentence.

classis
.h1 .h2 .h3the display scale, independent of h1…h6
.eyebrowsmall caps label above a heading
.leadthe opening sentence, one step up from body
.muted--ui-fg-muted — still AA
.faint--ui-fg-faint — decoration, never the only carrier
.monothe mono stack at body size
.linkan accented, underlined anchor

foundation

Layout

A handful of primitives, no grid system. Anything more specific than these belongs to the page, not the library.

row, stack, data-grid, spacer, rule
a row wraps a spacer pushed this

grid cell
grid cell
grid cell
classis
.rowwrapping flex row, centred cross-axis
.stackvertical flow with one gap
.spacerflex: 1 — pushes what follows to the end
.hra one-token rule

Layout primitives (attributes)

Three names — grid, container, table — are also names Tailwind claims as utilities. Rather than rename them or prefix the library, they are carried as data attributes: Tailwind never emits an attribute selector, so the collision is impossible by construction, the names survive, and specificity is unchanged (0,1,0 either way). The rule: a layout primitive Tailwind also claims is an attribute; a component is a class.

attributeis
[data-container]centred, max --ui-container, gutters
[data-grid]auto-fit columns with a --ui-grid-min floor
[data-table]the table skin — documented under Table

controls

Button

One base class and a modifier for the job. Sizes are orthogonal to variants, and a latched button is .is-on — never a second variant class.

variants
outline
sizes, icon-only, disabled
group, latched — click one
classis
.btnthe base — always present
.btn-primaryfilled accent; one per view
.btn-dangerfilled danger, for a destructive commit
.btn-ghosttransparent until hover — for a row’s own actions
.btn-quietsunken fill, muted ink — the toolbar default
.btn-outline+ -accent -info
.btn-sm .btn-lgsizes; combine with any variant
.btn-iconsquare; needs an aria-label
.btn-groupjoins buttons into one control
.is-onlatched. Not aria-pressed as well — one fact, one carrier

controls

Inputs & selects

A field is a label, a control and an optional hint. Validity is aria-invalid, because the attribute already exists.

On iOS only, every control here is floored at 16px — Safari zooms the viewport in on a focused field under that and never zooms back out — so the compact sizes below stay compact everywhere the zoom cannot happen.

field, hint, invalid
Shown in the ledger and nowhere else.
Expected a number.
sizes and the mono/numeric inputs
select and textarea
classis
.fieldthe label / control / hint stack
.labelthe control's name
.hintthe sentence under it
.input+ -sm -lg -mono -num (tabular, right-aligned) -invalid
.select+ -sm; caret drawn, gutter reserved
.textareathe same skin, resizable
[aria-invalid]the danger ring — prefer it to .input-invalid

controls

Toggle & segmented

A toggle wraps a real checkbox, so it is focusable, form-serialisable and keyboard-operable with no script at all.

toggle
segmented — exclusive choice, aria-pressed carries it

The appbar's theme control is a segmented group — its three buttons are the entire theming API.

status

Pill, badge, chip, dot

Four shapes for four jobs. A pill is a state, a badge is a count or a tag, a chip is a labelled value, a dot is a light.

pill — a state, with or without a dot
neutral accent connected 3 gaps wire down fixture active
badge — a count or a tag
12 new pass deg 4 v2
chip — a label and its value, the value in <b>
wpm 172 eot p50 412ms drift +38ms drops 2
dot — a light, pulsing while live
idle live degraded down replay
classmoodsuse for
.pill-accent -danger -info -ok -warna state; .is-active for the current one
.badge-accent -danger -info -ok -warna count or a short tag
.chip-danger -ok -warna labelled value; .chip-row groups them
.dot-danger -info -ok -warna light; .dot-pulse while live

.dot-pulse stops under prefers-reduced-motion, so the dot must never be the only carrier of "live" — pair it with a word, as every example above does.

status

Notice

Boxed, rounded, wraps, lives inside a layout. A notice is content; the bar across the top of the window is a strip.

the four moods, each with its glyph
Replaying a fixture. Nothing you click reaches the live loop.
Contract satisfied — every sequence number accounted for.
3 gaps in the ears channel.
Wire closed after 1 attempt. The loop is not running.
classis
.noticeneutral box; an icon and a <span> inside
+ -danger -info -ok -warntint, border and rail

status

Strip

Chrome: full-bleed, one line, small mono. It belongs directly under .appbar as a sibling — outside [data-container], so it spans the viewport.

every mood (boxed here only so the page can show them together)
wirewaiting for the stream…
wireconnected · 8 channels
sourcereplaying fixture kitchen-evening.jsonl
modebridge · Claude has the floor
gaps3 missing sequence numbers on ears
wireclosed after 1 attempt — the loop is not running

A strip does not survive el.className = "strip-warn". Wholesale assignment drops .strip itself, and a mood modifier alone styles nothing, so the bar collapses to unstyled text. Toggle the mood class:

strip.classList.remove("strip-ok", "strip-warn");
strip.classList.add("strip-danger");
classis
.stripthe bar; display: flex
.strip-tagthe channel name at the left
.strip-msgthe sentence; truncates rather than wraps
+ -accent -danger -info -ok -warnthe mood

status

Meter

One track, one fill, one custom property. The value is CSS, not a width attribute, so a page can drive it from a rule as easily as from script.

the first one is live

The single child <span> is the fill and is required — the track cannot paint itself and animate at once. Set the value with el.style.setProperty("--ui-meter-value", pct + "%").

classis
.meterthe track; accent fill
+ -warn -dangerthe fill's mood

status

Metric & readout

A metric is one number a human reads at a glance. A readout is a log a human reads a line at a time — mono, pre-wrapped, with keys and timestamps tinted.

metric — value, label, and a delta that says which way is good
172 words / minute
−38ms first-audio
+2 dropped frames

-delta-up means better, not larger: latency falling by 38 ms is an up-delta. The direction is the judgement, which is the only thing a colour can usefully carry.

readout — a wire log
00:12.401 ears.partial  "set the kitchen light to"
00:12.688 eot.head      p=0.41 head=prosody
00:13.002 ears.final    "set the kitchen light to forty"
00:13.117 policy.speak  allow reason=turn-yielded
classis
.metricthe tile
.metric-valuethe number, tabular
.metric-labelwhat it counts
.metric-delta-up / -downbetter / worse, not larger / smaller
.readouta <pre>; keeps its own newlines
.readout-keythe event name
.readout-tthe timestamp

status

Orb

The microphone. One button, two states, and the muted state is legible without colour — a slash crosses it.

click it — open ⇄ muted
classis
.orbthe button; size from --ui-orb-size
.orb-ringthe halo that breathes while open
.orb-slashdrawn only under .is-muted
.is-mutedmuted; mirror it into aria-pressed and the label

surfaces

Card

A box with an optional head and foot. Content the page owns — as opposed to a panel, which frames a channel.

head, body, foot — and the hoverable variant

Voice roster

28
The body takes the padding; a .stack on it takes the rhythm.
28 advertised · 4 shown

hoverable

Lifts on hover. Use it only when the whole card is a target.

classis
.cardthe surface
.card-heada flex row; a .spacer pushes trailing controls
.card-bodythe padded content
.card-foota sunken row for hints and actions
.card-hoverablelifts on hover

surfaces

Panel

The instrument frame: a named head with a coloured rail, and a body. Its hue is --ui-panel-accent, which is where a page's own channel colours enter the library.

name, sub, trailing controls

ears

transcription 418
The body is a grid; give it .stack when its children need rhythm.

.panel-name is styling, not structure. When the head is the document's heading, use a heading class instead and let the element carry the outline — the panel does not care which:

the head as a real heading, and the wide variant

Voices

click a row to audition 28
.panel-wide spans every column of an enclosing [data-grid].
classis
.panelthe frame; display: grid
.panel-headthe named bar, with the accent rail
.panel-namethe channel name, in the accent
.panel-subthe qualifier beside it
.panel-bodythe content
.panel-widegrid-column: 1 / -1 — works on a card too

surfaces

Appbar

The window's top edge: title, nav, and whatever status the page owes at all times. This page is wearing one.

title · nav · spacer · status
ra · the loop live 1041

The current page is both .is-active and aria-current="page" — that is the one place the library asks for both, because the class styles a pill the attribute cannot reach on its own.

collections

List & listrow

Sized for the inside of a panel. The left rail carries selection, so a current row never changes size, and actions stay hidden until the row is reachable.

click a row · hover for its actions
voices5

Use a <button> (or set role="option" with aria-selected) so the row is reachable by keyboard. Actions are revealed on hover, focus, focus-within and .is-current, which keeps a long list from reading as a wall of buttons.

classis
.listthe container
.listrowa row: name, meta, actions
.is-currentfills the row and promotes the meta from faint to muted ink — faint loses AA against a tint
.is-busypulses; stops under prefers-reduced-motion
.is-warnedrail and name only — it deliberately does not fill, because two tints in one list read as one muddled emphasis and selection owns the tint

collections

Table

A real <table>, styled. Column behaviour is per-cell: mono, numeric, or allowed to wrap.

hover rows, mono keys, numeric columns
voicelocalewpmwarmstate
af_hearten-US1720.4 sresident
am_michaelen-US1641.9 scold
bf_emmaen-GB1582.1 sevicted
bm_georgeen-GB151missing
nameonis
[data-table]tablethe skin — an attribute, not a class; see layout primitives
.table-hovertablerow hover tint — only when a row is a target
.table-wrapwrapperscrolls the table sideways instead of the page
.table-monocellthe mono stack — ids, voices, hashes
.table-numcelltabular figures, right-aligned

collections

Transcript

Turns, and words with a confidence state. A streaming recogniser revises what it already said, so a word carries whether it is settled, still moving, or withdrawn.

two turns, three word states
owner · 00:12.4

set the kitchen light to forty fourteen

ra · 00:13.9

Kitchen light at forty percent.

classis
.transcriptthe scroll
.transcript-turn+ -self -agent
.transcript-whospeaker and time
.transcript-textthe words
.word-stablesettled — will not be revised
.word-tentativestill moving
.word-ghostretracted — struck, kept visible

details

Icon

A 28-glyph Phosphor subset, regular weight, vendored. Nothing is fetched at runtime.

sizes, and inheritance of currentColor

Inline the sprite once per document. An external <use href="file.svg#id"> does not resolve over file:// and costs a request over HTTP — which is one reason this repo ships a server.

<body>
  <!-- paste icons/sprite.svg here -->
  <svg class="icon" aria-hidden="true"><use href="#ph-microphone"></use></svg>

The subset

To add one, drop its file into icons/svg/ from @phosphor-icons/core@2.x/assets/regular/ and append a matching <symbol> to the sprite. MIT © 2023 Phosphor Icons — full text in icons/LICENSE.

details

Code, kbd, tip, empty

The small print of an interface.

inline code, keys, a tooltip, an empty state

Toggle with el.hidden = true, or press L.

?
ledroom ledger

nothing on the wire yet

classis
.codeinline code — a tinted mono run
.kbda key cap
.tipwith data-ui-tip="…"; a static hint on a static anchor
.emptythe sentence that stands in for content that has not arrived

the page itself

Wrapping (visor)

This page is served, not opened, so it can be developed through visor — a local reverse proxy that puts a rail beside the app and lets a comment thread be anchored to any element on it.

./serve.py # http://127.0.0.1:8080/docs/
visor 8080 # wrap it — the rail appears beside this page
visor -- ./serve.py # or let visor own the port and spawn the server

visor asks the wrapped app for almost nothing: it forwards every request that is not its own /__visor/* route, and splices its script tags in before </head>. What it does need is a page whose elements can be named. Its threads grow a CSS selector one ancestor at a time, preferring iddata-testiddata-componentaria-label → stable classes → position, and skipping anything that looks like build output. A page of hashed class names gives it nothing but position, and a thread anchored to div:nth-child(7) > div:nth-child(2) is a thread that dies at the next edit.

So this page publishes a small semantic DOM contract, in the shape ra's workbench uses. Walk up from any click target:

attributeonresolves
data-page<body>docs — which of the two pages you are on
data-componentsection rootthe component family (button, panel, tokens, …); equal to the section's id
data-example<figure>the individual example, stable across edits
data-livethe rendered blockmarks the DOM that is the documented markup
data-code-forthe markup panethe data-example it was generated from
data-classa variants-table rowthe class or attribute selector that row documents
data-tokena swatch or hooks rowthe token it documents

Every section id is also its component name, so a selector visor derives — #button [data-example="button-group"] — reads as the sentence a reviewer would have typed anyway, and survives everything except renaming the component.

window.__uiDocs.resolve(element) is the reference reader: it performs exactly that walk and returns { component, section, example, class?, token?, markup? }. A wrapper may reimplement it — but if the two disagree, the attributes are right.

Three things the server does on visor's behalf, none of them optional: it speaks HTTP/1.1 with keep-alive (an iframe reloading a page's whole subresource set over connection-per-request is visibly slow); it threads its handler, so the stylesheet, the script and the sprite are not serialised behind each other; and it sends no Content-Security-Policy at all — a blocking CSP fails silently under visor, with no console error and no network request, just chrome that never appears.