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 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.
| group | tokens | light → 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.
| property | read by | default |
|---|---|---|
| --ui-container | [data-container] max width | 100rem |
| --ui-grid-min | [data-grid] column floor | 280px |
| --ui-icon-size | .icon | 1.15em |
| --ui-meter-value | .meter fill | a percentage |
| --ui-orb-size | .orb | 34px |
| --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.
| token | light | dark |
|---|---|---|
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.
| selector | wins 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.
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.
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.
| class | is |
|---|---|
| .h1 .h2 .h3 | the display scale, independent of h1…h6 |
| .eyebrow | small caps label above a heading |
| .lead | the opening sentence, one step up from body |
| .muted | --ui-fg-muted — still AA |
| .faint | --ui-fg-faint — decoration, never the only carrier |
| .mono | the mono stack at body size |
| .link | an accented, underlined anchor |
foundation
Layout
A handful of primitives, no grid system. Anything more specific than these belongs to the page, not the library.
| class | is |
|---|---|
| .row | wrapping flex row, centred cross-axis |
| .stack | vertical flow with one gap |
| .spacer | flex: 1 — pushes what follows to the end |
| .hr | a 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.
| attribute | is |
|---|---|
| [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.
| class | is |
|---|---|
| .btn | the base — always present |
| .btn-primary | filled accent; one per view |
| .btn-danger | filled danger, for a destructive commit |
| .btn-ghost | transparent until hover — for a row’s own actions |
| .btn-quiet | sunken fill, muted ink — the toolbar default |
| .btn-outline | + -accent -info |
| .btn-sm .btn-lg | sizes; combine with any variant |
| .btn-icon | square; needs an aria-label |
| .btn-group | joins buttons into one control |
| .is-on | latched. 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.
| class | is |
|---|---|
| .field | the label / control / hint stack |
| .label | the control's name |
| .hint | the sentence under it |
| .input | + -sm -lg -mono -num (tabular, right-aligned) -invalid |
| .select | + -sm; caret drawn, gutter reserved |
| .textarea | the 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.
aria-pressed carries itThe 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.
<b>| class | moods | use for |
|---|---|---|
| .pill | -accent -danger -info -ok -warn | a state; .is-active for the current one |
| .badge | -accent -danger -info -ok -warn | a count or a short tag |
| .chip | -danger -ok -warn | a labelled value; .chip-row groups them |
| .dot | -danger -info -ok -warn | a 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.
ears channel.
| class | is |
|---|---|
| .notice | neutral box; an icon and a <span> inside |
| + -danger -info -ok -warn | tint, 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.
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");
| class | is |
|---|---|
| .strip | the bar; display: flex |
| .strip-tag | the channel name at the left |
| .strip-msg | the sentence; truncates rather than wraps |
| + -accent -danger -info -ok -warn | the 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 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 + "%").
| class | is |
|---|---|
| .meter | the track; accent fill |
| + -warn -danger | the 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.
-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.
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
| class | is |
|---|---|
| .metric | the tile |
| .metric-value | the number, tabular |
| .metric-label | what it counts |
| .metric-delta-up / -down | better / worse, not larger / smaller |
| .readout | a <pre>; keeps its own newlines |
| .readout-key | the event name |
| .readout-t | the timestamp |
status
Orb
The microphone. One button, two states, and the muted state is legible without colour — a slash crosses it.
| class | is |
|---|---|
| .orb | the button; size from --ui-orb-size |
| .orb-ring | the halo that breathes while open |
| .orb-slash | drawn only under .is-muted |
| .is-muted | muted; 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.
Voice roster
28.stack on it takes the rhythm.hoverable
Lifts on hover. Use it only when the whole card is a target.
| class | is |
|---|---|
| .card | the surface |
| .card-head | a flex row; a .spacer pushes trailing controls |
| .card-body | the padded content |
| .card-foot | a sunken row for hints and actions |
| .card-hoverable | lifts 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.
ears
transcription 418.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:
Voices
click a row to audition 28| class | is |
|---|---|
| .panel | the frame; display: grid |
| .panel-head | the named bar, with the accent rail |
| .panel-name | the channel name, in the accent |
| .panel-sub | the qualifier beside it |
| .panel-body | the content |
| .panel-wide | grid-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.
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.
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.
| class | is |
|---|---|
| .list | the container |
| .listrow | a row: name, meta, actions |
| .is-current | fills the row and promotes the meta from faint to muted ink — faint loses AA against a tint |
| .is-busy | pulses; stops under prefers-reduced-motion |
| .is-warned | rail 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.
| voice | locale | wpm | warm | state |
|---|---|---|---|---|
| af_heart | en-US | 172 | 0.4 s | resident |
| am_michael | en-US | 164 | 1.9 s | cold |
| bf_emma | en-GB | 158 | 2.1 s | evicted |
| bm_george | en-GB | 151 | — | missing |
| name | on | is |
|---|---|---|
| [data-table] | table | the skin — an attribute, not a class; see layout primitives |
| .table-hover | table | row hover tint — only when a row is a target |
| .table-wrap | wrapper | scrolls the table sideways instead of the page |
| .table-mono | cell | the mono stack — ids, voices, hashes |
| .table-num | cell | tabular 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.
set the kitchen light to forty fourteen
Kitchen light at forty percent.
| class | is |
|---|---|
| .transcript | the scroll |
| .transcript-turn | + -self -agent |
| .transcript-who | speaker and time |
| .transcript-text | the words |
| .word-stable | settled — will not be revised |
| .word-tentative | still moving |
| .word-ghost | retracted — struck, kept visible |
details
Icon
A 28-glyph Phosphor subset, regular weight, vendored. Nothing is fetched at runtime.
currentColorInline 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.
Toggle with el.hidden = true, or press
⌘ ⇧ L.
nothing on the wire yet
| class | is |
|---|---|
| .code | inline code — a tinted mono run |
| .kbd | a key cap |
| .tip | with data-ui-tip="…"; a static hint on a static anchor |
| .empty | the 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
id → data-testid →
data-component → aria-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:
| attribute | on | resolves |
|---|---|---|
| data-page | <body> | docs — which of the two pages you are on |
| data-component | section root | the component family (button, panel, tokens, …); equal to the section's id |
| data-example | <figure> | the individual example, stable across edits |
| data-live | the rendered block | marks the DOM that is the documented markup |
| data-code-for | the markup pane | the data-example it was generated from |
| data-class | a variants-table row | the class or attribute selector that row documents |
| data-token | a swatch or hooks row | the 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.