EUI applications without a browser
Controls§03.4

Twenty-four widgets answered the pointer with a cursor.

A catalogue can be complete and still not be good. The one in eui_builders.sl had a hundred and thirty functions and could not disable any of them — the word did not appear in two and a half thousand lines. control is the base every interactive widget is built on now: one options hash that carries the states, the size and the semantics, so a widget gains all three without its callers changing.

Auditbefore

What the catalogue actually did

Counted, not remembered, across the hundred and thirty functions of eui_builders.sl as it stood. The theming was excellent — two hardcoded colours in the whole file, both deliberate scrims. Everything the pointer and the keyboard touch was not.

6functions that answered pointer_enter
2that answered key_down
1use of focus.ring, in a grid cell
0occurrences of the word disabled
24that set cursor: pointer and stopped there
2hardcoded colours — the dialog and sheet scrims

The reference page opposite claimed every widget was “themeable through roles, keyboard-navigable, and carries documented accessibility semantics”. Only the first clause was true.

States§03.3

A state belongs to exactly one side

The eight states do not live in one place, and most of the mess in a widget catalogue comes from pretending they do. Hover is the client's and costs no network; selection is the server's and costs a render; focus belongs to neither, and a server that tries to style it gets it wrong.

Client, local

hover and active. Declared as styles on the node and swapped by a verified bytecode chunk on pointer_enter / leave / down / up. No frame is sent, so the feedback does not wait for the network — and on a style carrying transition: fast the colour eases over 100 ms along the standard curve.

Client, only

focus-visible. The ring is drawn by the client, for keyboard and server-driven focus and never for a pointer click. A widget must not declare a focus handler to style it: focus fires on click too, so the server would light the ring exactly when the client is taking care not to. What a widget owes focus instead is to stay reachable, and to have a radius the ring can trace.

Server

selected, checked, expanded, disabled, read_only and loading. These are arguments, not events: the server re-renders and the diff carries the change. They are folded into the resting style before the hover delta is taken, which is why hovering an already-selected row does not look broken.

Tonesfive

A tone is a resting colour set and two deltas

Deltas, not whole styles. Merged over whatever base a caller ended up with, they keep every geometry choice inside all three states — so a size, a selection, or a patch applied from outside cannot go missing under the pointer. That is the invariant restyle used to repair afterwards, made structural instead.

These five are real buttons. Hover them, hold them down, tab to them.

accent
rest
accent.base · accent.on
hover
accent.hover + border.strong
press
accent.active
neutral
rest
surface.sunken · border.default
hover
surface.raised + border.strong
press
surface.sunken
ghost
rest
none · accent.base
hover
surface.sunken
press
accent.active
danger
rest
danger.base · danger.on
hover
border.strong
press
surface.sunken · danger.base
quiet
rest
none · text.default
hover
surface.sunken
press
surface.sunken

Only accent has a hover and an active offset — the theme derives them in OKLCH from the adjusted base. The status roles have neither, so there is no danger.active to reach for: danger presses to a sunken surface and keeps its own colour in the label. That is a constraint of the palette, not an oversight in the widget, and danger.base carries a guaranteed 3:1 against a surface either way.

the border is reserved at rest
base = {
  "border": 1,
  "border_color": "none",
  "transition": "fast"
}
hover = base.merge(tone["hover"])

A border in EUI is part of the box — layout adds its widths to the frame. A border that appears on hover makes the button 2 px wider and shoves its whole row sideways under the pointer. Reserving the width and colouring it leaves nothing for layout to do: what changes is a colour, which the client animates on its own.

and the swap runs on the client
"pointer_enter": {
  "local": "self.style = @hover",
  "styles": {"hover": hover}
}

The node is keyed, so the chunk can name itself. The client verifies the chunk before it runs once — every jump on an instruction boundary, the stack depth proved along every path — and a chunk that fails verification is dropped, silently and safely, leaving the widget with its resting style.

APIone hash

Every interactive widget is control plus a body

The options hash is the signature. It is how twenty-odd widgets gain seven capabilities without a single call site changing: each keeps its old positional arguments and grows a trailing o = {}.

control(o) 14 keys

key"cb:" + props["id"]
Required, and unique. Nodes are named by key and the key map is flat, so a duplicate silently restyles someone else’s widget. control throws rather than let that happen
kind"box"
Any of the sixteen. box unless a widget needs an input
tone"accent"
One of the five above. quiet by default
size"sm"
sm, md or lg. Geometry only — it never touches colour
shape{"width": 28, "radius": 1, "pad": 0}
Extra resting style. The caller wins, and whatever it sets is inside every state
on{"click": "toggle"}
The handler map. The four pointer handlers are added around it
props{"id": 412}
The identity the handler reads back as params["props"]
a11y{"role": "check_box", "checked": true}
What this control is. Merged into the props, so it costs no wire change
selected / checked / expanded"selected": is_active
Server state, folded into the resting colours before the deltas are taken
disabled / read_only / loading"disabled": true
The terminal states. They replace the resting style and take the handlers with them
c[mark, text(label, {})]
Children. A label takes its size from control_text_size, because only fg inherits
Disabled§06.1

One deletion, right three times and wrong once

Disabling a control does not add a state. It removes the handler map, and that single act is correct almost everywhere:

  • No click reaches the server. Dispatch walks up from the hit node looking for a handler and finds nothing on the path, so no event is ever encoded.
  • It leaves the Tab order. The client’s focus order is exactly the nodes holding a click, key or editable handler — so a handler-less node drops out for free, with nothing to keep in sync.
  • The cursor and the colours follow. not_allowed, text.disabled and border.subtle, all from one patch — and the disabled roles carry their own contrast guarantee in every mode.
  • And it disappears from the accessibility tree. The client infers a role from the node’s kind and the presence of a click handler. With the handler gone there is no button to infer, and a disabled control decays into an unnamed group: a screen reader reads its label as loose text, with no hint that it is a control at all, let alone an unavailable one.

Which is the whole argument for the semantics below. disabled has to be something the widget says, not something it stops doing.

Rest, then disabled. The second one is not a colour scheme — it is the absence of the handlers, and the cursor says so before anything is clicked.

  

Size§05.2

Size is the application’s. Density is the viewer’s.

These are two axes and conflating them scales everything twice. pad, gap and margin are space indices, and the client already multiplies each one by the viewer’s density before it lands in a frame. A server that resolved them to pixels first would have them scaled again. So the size table stays in indices, and a data-dense application asks for size: "sm" — it does not get to choose a density, because that choice is not the application’s to make.

sizetextpadgapmin widthiconmark
sm11 3 1 32322416
md22 4 2 43442818
lg33 5 3 53563622

There is deliberately no height in that table. Height is the text line plus the padding, which the client scales on both axes for free; a height pinned in pixels clips its own label the moment the viewer raises their font scale. Fixed boxes are for the places where a fixed box is the point — an icon button, a day cell, a row height — and those go through control_px, which mirrors the density factors: compact 0.8, cozy 1.0, comfortable 1.25.

Semantics§03.6

What a widget says about itself

A node’s props are already a generic bag the client reads by name. So a widget can declare what it is at no cost on the wire, and a client that does not know a prop ignores it. Every migrated builder emits these now.

rolecheck_box, switch, tab, tab_list, button, option…
labelthe name, when the visible text is not it
checkedtrue, false, or "mixed"
selectedfor a tab, an option, a row
expandedfor a disclosure or a dropdown’s anchor
disabledsaid, rather than merely done
busyset by loading
read_onlyeditable in principle, not now
pos_in_set / set_sizeplace in a set, for a virtualised one especially
orientationhorizontal or vertical, for a set

Half-built, and worth saying so. The builders emit these today; the client does not read them yet. Its mapping is still kind-based — anything with a click handler is a button named by the text inside it — so a checkbox, a switch, a tab and a menu item all still reach AT-SPI, UIA and AX as “Button”. Teaching the client to prefer a declared role over an inferred one is the other half, and it is a change to one file and one paragraph of the specification, not to the protocol.

Statustoday

Four of twenty-four

checkbox, switch, tabs and icon_button are migrated. Each kept its positional arguments, so nothing that called them changed; each gained hover, press, disabled, a size and its semantics. The gallery renders to the same 762 quads over 902 nodes it did before, in light and in dark — the point was to add states, not to restyle anything.

Twenty builders still answer the pointer with a cursor and nothing else: menu, select_option, navbar, sidebar, breadcrumb, segmented, accordion, tree_view, day_cell, pagination and the rest. They are the next pass, and the base they are moving onto is the one above.