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.
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.
pointer_enterkey_downfocus.ring, in a grid cellcursor: pointer and stopped thereThe reference page opposite claimed every widget was “themeable through roles, keyboard-navigable, and carries documented accessibility semantics”. Only the first clause was true.
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.
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.
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.
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.
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.
- rest
- accent.base · accent.on
- hover
- accent.hover + border.strong
- press
- accent.active
- rest
- surface.sunken · border.default
- hover
- surface.raised + border.strong
- press
- surface.sunken
- rest
- none · accent.base
- hover
- surface.sunken
- press
- accent.active
- rest
- danger.base · danger.on
- hover
- border.strong
- press
- surface.sunken · danger.base
- 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.
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.
"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.
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.
controlthrows rather than let that happen - kind"box"
- Any of the sixteen.
boxunless a widget needs aninput - tone"accent"
- One of the five above.
quietby default - size"sm"
sm,mdorlg. 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 onlyfginherits
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.disabledandborder.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 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.
| size | text | pad | gap | min width | icon | mark |
|---|---|---|---|---|---|---|
| sm | 1 | 1 3 1 3 | 2 | 32 | 24 | 16 |
| md | 2 | 2 4 2 4 | 3 | 44 | 28 | 18 |
| lg | 3 | 3 5 3 5 | 3 | 56 | 36 | 22 |
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.
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.
"mixed"loadingHalf-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.
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.