EUI applications without a browser
Reference§03

A hundred and seven functions, and the vocabulary they speak.

An EUI component is a Soli function that returns a hash. There is no widget runtime, no component registry and no client release behind any of the names below — each one composes primitives, so an application can add its own without asking anyone.

Node§02

Everything is one hash

A view returns this shape; the server converts it to nodes, diffs it against the tree this session already has, and encodes the patch. Eight keys, all optional but the kind.

a node, whole
{
  "k": "box",
  "key": "post:412",
  "s": {"display": "row", "gap": 3,
        "bg": "surface.raised"},
  "p": {"id": 412},
  "on": {"click": "like"},
  "c": [ … ],
  "t": "Ada Lovelace",
  "intern": true
}
what each key is for
k
The kind. One of sixteen, and the set is closed
key
Identity. Keyed children are matched by key and moved, not rebuilt
s
Style, resolved once per session and then referenced by id
p
Props. They come back to the handler as the props of the node that fired
on
Handlers, by event name
c
Children
t
Text content, for text and input
intern
Put this text in the atom table instead of inline

The sixteen kinds

Implemented in the client. Adding one is a protocol version bump, which is a deliberately high price: it is what keeps the catalogue a library rather than a client release.

boxA styled rectangle that arranges children
textA run of text. Leaf
imageA raster picture, referenced by content hash
iconA vector glyph. Leaf
inputA single-line editable field
textareaA multi-line editable field
scrollA clipping viewport
listA virtualised child list
canvasA retained path list: lines, rectangles, areas, circles, arcs
spacerFlexible empty space. Leaf
dividerA hairline rule. Leaf
overlayA layer above the flow
slotA named insertion point
sizerAn invisible box that only constrains
audioA sound. Draws nothing, plays
videoA moving picture, decoded in the worker
Style§05

Thirty-seven keys, and no cascade

A style is a flat hash. There is nothing to select against, no inheritance to compute, and no specificity to lose an afternoon to. If two places should look alike, they call the same function. A key the server does not know is an error, not a shrug: the render fails, and says which key.

KeyValues
displayrow · column · stack · grid · none
wrapnowrap · wrap · wrap_reverse
justifystart · center · end · between · around · evenly
alignstart · center · end · stretch · baseline
selfas align, plus auto — this node's own cross-axis placement
grow · shrink0–255
gapa space index, 0–255
width · height · min_width · min_height · max_width · max_height · basisa length
pad · margin · borderedges
bg · fg · border_colora colour
radius · shadow · opacity · z0–255
fontsans · mono
sizea text-scale index
weightregular · medium · semibold · bold
text_alignstart · center · end · justify
clampmaximum lines, then an ellipsis
underline · striketrue · false
overflowvisible · clip · scroll
transitionnone · fast · base · slow
animationnone · spin
positionflow · absolute
cursordefault · pointer · text · grab · grabbing · resize_h · resize_v · wait · not_allowed
Lengths

120 pixels, "auto", "50%", "1fr", or "sp:4" for a space-scale index.

Edges

2 for all four, [y, x], or [t, r, b, l]. They are space indices, not pixels, so the viewer's density setting scales them.

Colours

A role, a literal "#RRGGBB", or "none". A literal is right for a brand mark or a chart series, and wrong for a surface.

The twenty-eight roles

The server never sends a colour. It sends a role, and the client resolves it against the viewer's mode — which is why the same view is correct in dark mode without the server ever learning the viewer went there, and why switching costs zero bytes.

surfacebase · raised · sunken · overlay
textdefault · muted · inverted · disabled
accentbase · hover · active · on
successbase · subtle · on
warningbase · subtle · on
dangerbase · subtle · on
infobase · subtle · on
bordersubtle · default · strong
focusring
Events§06

What comes back

A handler named as a string is a server event: the client sends it, the handler runs, the view re-renders, the diff comes back. The handler is given the event, the state, and the props of the node that fired — which is how one handler serves ten thousand rows.

click double_click long_press context_menu pointer_down pointer_up pointer_move pointer_enter pointer_leave drag_start drag_over drop key_down key_up text_input change focus blur submit scroll resize window ended time_update
server: a round trip
# The node carries the identity the handler will need.
checkbox(item["title"], item["done"],
         "toggle", {"id": item["id"]})

# The handler reads it back.
def todo(event_data)
  id = event_data["params"]["props"]["id"]
  …
end
local: no round trip
{"local": "self.style = @hover",
 "styles": {"hover": hover},
 "then": "like"}

Compiled to bytecode, verified by the client before its first run, metered by fuel. styles declares the records the chunk may point a node at, so hover and press switch between styles the session already holds. then sends a server event afterwards. Nothing a local handler does is trusted — authorisation is never local.

Catalogue§03

All 107

They live in one file — examples/counter-app/app/controllers/eui_builders.sl — and you are meant to copy it and change it. A widget is not a protocol feature, and none of these had to ask permission to exist. Each one below carries a call you can paste into a view.

Every entry carries a drawing of what it makes, next to a call you can paste into a view. The drawings are HTML: this page is a document and cannot run a client, so the shapes are the widget's and the mechanism is not. A function that returns a value rather than a tree shows the value.

Primitives and wrappers 22

The whole vocabulary the protocol can express, plus the helpers every view uses.

node(kind, style, children)node("box", {"gap": 2}, [text("Total", {})])
The bare hash. Everything else on this page is built on it
{"k": "box", "s": {"gap": 2}, "c": [ … ]}
column(style, children)column({"gap": 4, "pad": 6}, [header, body])
A box, laid out down
row(style, children)row({"gap": 2, "align": "center"}, [mark, label])
A box, laid out across
stack(style, children)stack({}, [backdrop, caption])
A box whose children are superimposed
text(content, style)text("Total", {"size": 4, "weight": "semibold"})
A run of text
Total
spacer()row({}, [label, spacer(), button("Save", "save")])
Empty space with grow: 1
Save
divider()column({"gap": 3}, [header, divider(), body])
A hairline rule
scroll(style, children)scroll({"height": 400}, rows)
A clipping viewport
list(style, item_height, children)list({"height": 400}, 22, rows)
A virtualised list: only the visible rows are laid out
list_window(style, item_height, count, heights, children, on_window)list_window({"grow": 1}, 128, count, heights, cards, "window")
A windowed list — the server sends only what is in view
input(value, on_change)input(state["typed"], "typed")
A bordered single-line field
Ada
button(label, on_click)button("Save", "save")
The primary button: accent roles, local hover and press
Save
image(src, width, height)image("public/images/chart.png", 320, 200)
A picture from a file in the application, sent as a hash
avatar(src, size)avatar("public/images/avatar.png", 32)
A round picture
canvas(width, height, paths)canvas(320, 120, [[3, "accent.base", 40, 60, 6]])
A drawing surface: polylines, rectangles, areas, circles, arcs
audio(src, props, on)audio("public/sounds/chime.wav", {"playing": true, "volume": 80}, {"ended": "sound_ended"})
A sound. Draws nothing
chime.wav · 1.6 s
video(src, props, style, on)video("public/video/pulse.gif", {"playing": on}, {"width": "100%"}, {"ended": "video_ended"})
A moving picture. GIF and animated WebP
▶ 0:00 / 1:26
keyed(key, n)keyed(invoice["id"], row({"gap": 2}, cells))
Gives a node its identity for the diff, and returns it
{"key": "412", "k": "box", … }
with_state(state, root)with_state({"count": count}, column({"pad": 6}, children))
Puts the state in the root's props, where local handlers read it
{"p": {"count": 3}, "k": "box", … }
bp(width)bp(state["viewport"]["width"])
Breakpoint name for a viewport width — xs sm md lg xl 2xl, Tailwind rungs
"md"
bp_min(width, name)bp_min(w, "md")
true when width is at least that breakpoint
true
bp_px(name)bp_px("md")
The pixel floor
768

Text 4

Four functions, because a heading is a size and a weight, not a tag.

h1(content)h1("Todo")
Size 5, bold
Todo
h2(content)h2("Invoices")
Size 4, semibold
Invoices
muted(content)muted(remaining.to_s + " left")
Size 1, text.muted
3 left
text_interned(content, style)text_interned(post["handle"], {"fg": "text.muted", "size": 1})
Text put in the session's atom table — for short strings that repeat
@adaone atom, sent once

Buttons 8

One engine, and the variants are arguments to it.

button_variant(label, on_click, bg, fg)button_variant("Publish", "publish", "accent.base", "accent.on")
The engine: a keyed box whose hover and press repoint it at declared styles, with no round trip
Publish
secondary_button(label, on_click)secondary_button("Clear done", "clear_done")
surface.sunken on text.default
Clear done
danger_button(label, on_click)danger_button("Delete", "destroy")
danger.base on danger.on
Delete
ghost_button(label, on_click)ghost_button("×", "remove")
No fill, accent text
×
icon_button(label, on_click, props)icon_button("›", "nav", {"delta": 1})
A 28×28 square carrying props back to the handler
loading_button(label, on_click, key)loading_button("Load 5 000 more", "more", "more")
Reveals its spinner and changes its label locally, then sends the event
Loading…
local_button(label, program, after)local_button("+", "state.count += 1; value.text = str(state.count)", "increment")
A button whose click runs a bytecode chunk first, then a server event
+no round trip
theme_toggle()theme_toggle()
Light and dark, decided on the client; the server is never told
☀/☾

Input and forms 9

None of them holds state. The handler does.

checkbox(label, checked, on_toggle, props)checkbox(it["title"], it["done"], "toggle", {"id": it["id"]})
The mark's fill says its state; props say which item it was
Write the spec
switch(label, on, on_toggle, props)switch("Notify me", state["notify"], "toggle_notify", {})
A track and a knob, placed by justify
Notify me
field(label, value, on_change)field("Name", state["name"], "name_changed")
A muted label over an input
NameAda
form(children, submit_label, on_submit)form([field("Name", "", "noop")], "Save", "save")
The children, then a right-aligned submit
AdaSave
sized_input(value, on_change, width)sized_input(state["time"], "time_changed", 80)
An input of a fixed width
09:30
select(options, value, open, on_toggle, on_pick)select(["EUR", "GBP"], state["ccy"], state["open"], "toggle_ccy", "pick_ccy")
Closed, it is its anchor; open, a dropdown. The server owns open
EUR  ▾
select_option(label, selected, on_pick)select_option("EUR", value == "EUR", "pick_ccy")
One row of that dropdown
EUR
dropdown(anchor, content, open)dropdown(anchor, options, state["open"])
A panel under its anchor; the anchor alone when closed
EUR  ▾GBPUSD
slider(value, min, max, on_set)slider(state["volume"], 0, 100, "set_volume")
A 240 px track. Click sets from the pointer x, arrows nudge

Calendar and pickers 12

One engine, three pickers. Months are "YYYY-MM", days are ISO strings — which compare correctly as strings, so no date arithmetic reaches the view.

calendar(month, selected, range_start, range_end, on_pick, on_nav)calendar(state["month"], [state["day"]], "", "", "pick", "nav")
Navigation, weekday header, a seven-column grid
date_picker(month, value, on_pick, on_nav)date_picker(state["month"], state["day"], "pick", "nav")
One day
2026-09-08
datetime_picker(month, date, time, on_pick, on_nav, on_time)datetime_picker(month, date, time, "pick", "nav", "set_time")
A day and an HH:MM field
09:30
date_range_picker(month, start, finish, on_pick, on_nav)date_range_picker(month, start, finish, "pick", "nav")
Two ends on one calendar
day_cell(iso, label, selected, in_range, on_pick)day_cell("2026-09-08", "8", true, false, "pick")
One day, carrying its ISO date
8
day_blank()day_blank()
The gap before the first of the month
weekday_header()weekday_header()
Mo to Su
Mo  Tu  We  Th  Fr  Sa  Su
month_label(month)month_label("2026-09")
The month, spelled for a person
"September 2026"
month_shift(month, delta)month_shift("2026-09", 1)
The next or previous month
"2026-10"
weekday_index(day)weekday_index(DateTime.parse("2026-09-01"))
Monday is 0
0
two_digits(n)two_digits(7)
A number padded to two digits
"07"
labelled(title, child)labelled("Due date", date_picker(month, day, "pick", "nav"))
A titled card, so a picker reads as one thing
Due date

Structure and overlays 12

card(style, children)card({"gap": 2}, [h2("Tree"), body])
Raised surface, subtle border, radius 3 — your style wins where it sets a key
Tree
tabs(names, active, on_select)tabs(["Overview", "Inputs", "Data"], tab, "tab")
A row of labels, the active one underlined
OverviewInputsData
dialog(title, body_children, actions)dialog("Delete this?", [text("It cannot be undone.", {})], [secondary_button("Cancel", "close"), danger_button("Delete", "destroy")])
An overlay: dimmed ground, centred panel, actions right
Delete this?Delete
sheet(side, children)sheet("right", [h2("Filters"), body])
A 320 px panel at an edge, over a dimmed ground
drawer(children)drawer([sidebar(links, active, "go")])
sheet("left", …)
popover(anchor, content, open)popover(button("Help", "toggle_help"), [tooltip("Ctrl+K")], state["help"])
A panel over its anchor
HelpCtrl+K
toolbar(children)toolbar([ghost_button("Undo", "undo"), ghost_button("Redo", "redo")])
A raised strip with a bottom rule
accordion(sections, open_id, on_toggle)accordion(sections, state["open"], "toggle_section")
Sections of id, title and body; the open one shows its body
▾ DeliveryShips in two days▸ Returns
stepper(steps, current)stepper(["Cart", "Address", "Payment"], 1)
Numbered dots: done filled, current ringed
menu(items, on_pick)menu(["Rename", "Duplicate", "Delete"], "pick")
A raised column; each item carries its own value
RenameDuplicateDelete
tooltip(content)tooltip("Ctrl+K")
Inverted text on the default ink
Ctrl+K
segmented(options, selected, on_select)segmented(["Day", "Week", "Month"], seg, "seg")
One sunken row, the selected option raised
DayWeekMonth

Navigation 5

navbar(brand, links, active, on_go)navbar("Needle", ["Home", "Search"], active, "go")
A brand and links, each carrying its path
NeedleHomeSearch
sidebar(links, active, on_go)sidebar(["Inbox", "Sent"], active, "go")
A 200 px rail, the active entry filled
InboxSentDrafts
breadcrumb(crumbs, on_go)breadcrumb([{"label": "app", "path": "/"}, {"label": "invoices", "path": "/invoices"}], "go")
Every crumb but the last is a link
app / invoices / 412
pagination(page, pages, on_page)pagination(page, 9, "page")
‹ and ›, each carrying the page it would reach
3 / 9
tree_view(nodes, open_ids, on_toggle, depth)tree_view(tree, state["open"], "toggle", 0)
Recursive, indented by depth
▾ app▾ views· home

Data 15

table_header(labels, widths)table_header(["Reference", "Client", "Amount"], widths)
A header row of fixed column widths
ReferenceClientAmount
table_row(key, values, widths)table_row(inv["id"], [inv["ref"], inv["client"], inv["total"]], widths)
A keyed body row — so re-sorting moves rows instead of rebuilding them
INV-412Ada84,20
data_grid(columns, rows, selected, editing, sort, on_select, on_sort, on_change, on_key)data_grid(columns, rows, selected, editing, sort, "select", "sort", "change", "key")
Header outside the scroll, virtualised rows, a selected cell, an input in the one being edited. A sort moves keyed rows; a commit is set_text
RefClientAmount ↑FA-1001Ada SARL137 €FA-1004Margaret SA112 €
grid_header(columns, sort, on_sort)grid_header(columns, {"col": "amount", "dir": "asc"}, "sort")
One labelled cell per column; the active sort is marked
RefClientAmount ↑
grid_row(record, columns, selected, editing, on_select, on_change, on_key)grid_row(row, columns, selected, {}, "select", "change", "key")
A keyed row of grid_cell
FA-1001Ada SARL137 €
grid_cell(row_id, col, value, selected, editing, open, on_select, on_change, on_key)grid_cell(row["id"], col, row["status"], true, true, true, "select", "change", "key")
A keyed box: text, an input, or a compact select when the column has options
Paid ▾
grid_col_editable(col)grid_col_editable({"id": "ref", "editable": false})
false only when the column sets editable: false
false
grid_col_align(col)grid_col_align({"id": "amount", "align": "end"})
start, center or end — default start
"end"
grid_sort_rows(rows, col, dir)grid_sort_rows(rows, "amount", "asc")
sort_by that column, reversed when dir is "desc"
[{id: "FA-1004", amount: "112 €"}, …]
stat(label, value, hint)stat("Nodes", "14", "primitives")
A card with one big number
Nodes14primitives
chip(label, on_remove, props)chip("invoices", "remove_filter", {"id": "invoices"})
A pill with an optional ×
invoices ×
badge(label, tone)badge("10 000 rows", "info")
A tone is a role family: "info" means info.subtle under info.base
10 000 rows
progress(fraction)progress(0.62)
A bar; the fraction is clamped to 0–1
skeleton(width, height)skeleton(320, 12)
A sunken placeholder
code_block(code)code_block(state["snippet"])
Monospace on a sunken ground
router_eui("counter", …)

Feedback 5

toast(message, tone)toast("Saved", "success")
A raised, toned strip
Saved
banner(message, tone, action_label, on_action)banner("Served by Soli, drawn by EUI.", "info", "Open sheet", "sheet")
Full width, an accent edge, an action at the end
Served by Soli, drawn by EUI.
spinner()spinner()
An 18 px arc the client spins — no frames on the wire
spinner_sized(size)spinner_sized(14)
The same, sized
empty_state(title, body, action_label, on_action)empty_state("No invoices yet", "They will appear here.", "New invoice", "new")
A mark, a title, a line, one button
No invoices yetNew invoice

Charts 8

A chart is a canvas and a list of numbers. No chart library, no SVG, no client change.

chart_line(values, w, h)chart_line([3, 7, 4, 9, 6], 320, 120)
Grid, polyline, a dot per point
chart_area(values, w, h)chart_area([3, 7, 4, 9, 6], 320, 120)
Grid, filled area, line on top
chart_bar(values, w, h)chart_bar([3, 7, 4, 9, 6], 320, 120)
Grid and bars
chart_donut(parts, w, h)chart_donut([5, 3, 2], 140, 140)
One arc per part, in the four base roles
chart_points(values, w, h)chart_points([3, 7, 4], 320, 120)
The series scaled into w × h as [x, y] pairs
[[4, 4], [82, 44], [160, 24]]
chart_grid(w, h)chart_grid(320, 120)
Four hairlines to read a series against
[[0, "border.subtle", 1, 4, 4, 316, 4], … ]
chart_max(values)chart_max([3, 7, 4])
The top of the scale, never 0
7
flatten_points(points)flatten_points([[0, 10], [8, 4]])
[[x, y], …] into [x, y, …]
[0, 10, 8, 4]

Media 3

The client seeks when position changes, which is how a scrubber works without a seek opcode.

media_button(on, event, props)media_button(playing, "play", {"id": post["id"]})
28 px play and pause
play
media_scrubber(width, at, duration, on_seek, props)media_scrubber(300, at, duration, "seek", {"id": id, "w": 300})
A fixed-width bar, so the click's x maps straight onto the position
media_clock(ms)media_clock(67000)
Milliseconds as minutes and seconds
"1:07"

Feed 4

The pieces that make a hundred thousand cards work — virtualisation is a composition question, not a client feature.

post_card(post, liked, play, height)post_card(post, liked.includes?(i), play, 128)
One card of a fixed height, which is what lets the list virtualise
A♥ 12  ↩ 3
post_media(post, play)post_media(post, {"sound": false, "video": true})
The card's picture, moving picture, or sound with controls
▶ 0:00 / 1:26
post_action(glyph, count, on_click, props, active)post_action("♥", post["likes"], "like", {"id": post["id"]}, liked)
One action under a post, carrying the post id
♥  12
initial_avatar(letter, tone, size)initial_avatar("A", "accent.base", 40)
A letter in a coloured disc
A
Patterns§04

Six things worth knowing

State lives in the handler

No widget holds any. select is given open, accordion is given open_id, tabs is given active. That is why they are functions rather than objects, and why any of them can appear twice on a page without a name collision.

Props carry identity

A click arrives with the props of the node that fired, so one handler serves every row of a table. The client cannot invent them: the server keeps the tree it sent, and reads the props off its own copy.

Keys are for the diff

keyed(id, node) makes a child matched by key instead of by position — the difference between one move and a rebuilt subtree. Reversing fifty keyed rows costs 201 bytes on the wire. data_grid is that contract as a widget: a sort moves rows, a cell commit is set_text, and the header sits outside the list because version 1 has no sticky.

Interning is for repetition

text_interned puts a short string in the session's atom table, so the wire carries it once. That table is append-only, so a unique value would be a permanent entry: intern a glyph or a handle, never a cell value.

A window, not a list

list_window is given how many rows exist, every row's height, and only the rows in view — each carrying its absolute index. The client draws placeholders for what it has not received yet. That is how a hundred thousand cards scroll on a budget of tens.

Responsive is a view branch

There are no sm: style prefixes. Store params["viewport"] on connect and viewport, then bp_min(width, "md") chooses a different tree. The client never runs a media query.