ESC
Type to search...
S
Soli Docs

Typography

Text is a leaf primitive; these are conventional sizes and roles on top of it.

text(content, style)

A run of text, wrapped to its box. line_clamp truncates.

text("Hello", {"size": 3, "weight": "semibold"})
Hello
h1(content) · h2(content)

Heading sizes from the theme scale.

column({"gap": 2}, [h1("Dashboard"), h2("This week")])
Dashboard
This week
muted(content)

Secondary text in the text.muted role.

muted("Last synced 3 minutes ago")
Last synced 3 minutes ago
code_block(code)

Monospaced text on a sunken surface.

code_block("soli serve . --port 3000")
soli serve . --port 3000
stat(label, value, hint)

A figure with its caption. The unit of a dashboard.

stat("Requests", "1.4M", "+12% this week")
Requests
1.4M
+12% this week
icon(name, style)

A named vector icon, stroked by the client from its own table. The name is a prop, not text: an icon is not a character, so it takes `fg` like a label but is never shaped, never falls back to a symbols face, and never reaches a screen reader as the glyph it happens to resemble. With no size of its o

"c": lit ? [icon(

Real use — eui_builders.sl (inside `checkbox`):610

h2(content)

No description in the source.

h2("Split panes"),

Real use — app/controllers/live_controller.sl:823

icon_box_px(size)

No description in the source.

box = icon_box_px(size)

Real use — eui_builders.sl (inside `icon_button`):2535

text_link(label, on_click, props = {})

A link is a button that goes somewhere rather than doing something, so it is text in the accent colour and not a box: a row of links must not read as a row of buttons, because what they promise is different. It declares the `link` role, which is what a screen reader announces it by — the colour is n

[text_link(part["sku"], "inv_pick", {"sku": part["sku"]}), muted(part["warehouse"])]

Real use — app/controllers/live_controller.sl:1824

code_viewer(code, opts)

A read-only code viewer with line numbers and scrolling. Displays code in a monospace font with a pinned gutter (line numbers stay visible while scrolling horizontally). Gutter/code lines stay pixel-aligned because both use the same font_size, which determines line-height. `opts` may include {"langu

out = out.concat([code_viewer(code, {

Real use — app/controllers/markdown_builders.sl:307

Media

Images, sound and video are referenced by BLAKE3 hash, never by path. The client asks for a hash it does not have; an asset is immutable, so it is fetched once and cached forever.

image(src, width, height)

A raster asset by content hash.

image(asset("logo.png"), 120, 40)
audio(src, props, on)

Draws nothing, plays. Control it with props and events.

audio(asset("chime.ogg"), {"autoplay": true}, {})
no visual — behaviour only
video(src, props, style, on)

Decoded in the sandboxed worker. GIF and animated WebP arrive through the same node.

video(asset("demo.webm"), {"loop": true}, {"radius": 2}, {})
media_scrubber(width, at, duration, on_seek, props)

A seek bar for an audio or video node.

media_scrubber(200, s["at"], s["len"], "seek", {})
1:123:04
text_interned(content, style)

A text whose content repeats across many nodes: interned as an atom, so the wire carries it once per session.

text_interned("☻", {"size": 1}),

Real use — app/controllers/chat_controller.sl:1754

post_avatar(post, size)

A face when there is one, a coloured initial when there is not: a real timeline brings pictures, the sample brings letters, and the card treats them the same.

post_avatar(post, 40),

Real use — eui_builders.sl (inside `post_card`):3801

post_action(glyph, count, on_click, props, active)

One action under a post: a glyph and a count, clickable, carrying the post id so one handler serves every post.

post_action("↩", post["replies"], "noop", {"id": post["id"]}, false),

Real use — eui_builders.sl (inside `post_card`):3782

media_clock(ms)

No description in the source.

muted(media_clock(state["video_at"] ?? 0) + " / 0:01")

Real use — app/controllers/live_controller.sl:346

media_button(on, event, props)

A play/pause button of the size these cards use.

media_button(playing, "video", {}),

Real use — app/controllers/live_controller.sl:344

post_media(post, play)

No description in the source.

picture = post_media(post, play)

Real use — eui_builders.sl (inside `post_card`):3778

post_card(post, liked, play, height)

No description in the source.

built = keyed(i, post_card(post, liked, play, feed_card_height(post)))

Real use — app/controllers/live_controller.sl:2875

Data display

Tables are composed rows, not a primitive. Past a few hundred, put them in a list so only the visible window lays out.

table_header(labels, widths) · table_row(key, values, widths)

A fixed-width table. widths is shared between the two so columns line up.

column({}, [
  table_header(["Name", "Plan"], [140, 80]),
  rows.map(fn(r) table_row(r["id"], [r["name"], r["plan"]], [140, 80]))
])
NamePlan
AlicePro
BobFree
data_grid(columns, rows, selected, editing, sort, on_select, on_sort, on_change, on_key)

A sortable, inline-editable grid: cell selection, keyboard movement and per-column editability. The largest thing in the catalogue, and still plain Soli.

data_grid(columns, rows, s["sel"], s["edit"], s["sort"],
          "select", "sort", "change", "key")
Name ▴QtyPrice
Widget12€9.00
Gadget7€14.50
avatar(src, size)

A circular image by content hash.

avatar(user["photo"], 40)
initial_avatar(letter, tone, size)

The fallback when there is no photo: a letter on a toned disc, no asset to fetch.

initial_avatar("A", "accent", 40)
A
grid_sort_rows(rows, col, dir)

No description in the source.

state["grid_rows"] = grid_sort_rows(state["grid_rows"], col, dir)

Real use — app/controllers/live_controller.sl:545

grid_col_editable(col)

No description in the source.

return grid_col_editable(col) if col["id"] == id

Real use — app/controllers/live_controller.sl:443

grid_col_align(col)

No description in the source.

align = grid_col_align(col)

Real use — eui_builders.sl (inside `grid_cell`):1402

grid_cell(row_id, col, value, selected, editing, open, on_select, on_change, on_key)

No description in the source.

grid_cell(

Real use — eui_builders.sl (inside `grid_row`):1516

grid_row(record, columns, selected, editing, on_select, on_change, on_key)

No description in the source.

body = rows.map(fn(r) { grid_row(r, columns, selected, editing, on_select, on_change, on_key) })

Real use — eui_builders.sl (inside `data_grid`):1599

grid_header(columns, sort, on_sort)

No description in the source.

[grid_header(columns, sort, on_sort), inner]

Real use — eui_builders.sl (inside `data_grid`):1617

Feedback and status

Small, composed pieces that tell the person what happened. Tones are role names — info, success, warning, danger — not colours.

badge(label, tone)

A small pill for counts and states.

row({"gap": 2}, [badge("Live", "success"), badge("3", "danger")])
Live3
chip(label, on_remove, props)

A removable tag. Omit on_remove for a static one.

chip("rust", "remove_tag", {"tag": "rust"})
rust
spinner() · spinner_sized(size)

An indeterminate wait, animated by the client's spin style key — no round trip per frame.

spinner_sized(20)
progress(fraction)

A determinate bar. fraction is 0.0 to 1.0.

progress(uploaded / total)
skeleton(width, height)

A placeholder block while data loads. Keeps the layout from jumping.

column({"gap": 2}, [skeleton(180, 12), skeleton(120, 12)])
toast(message, tone)

A transient message in an overlay, so it paints above the flow.

toast("Saved", "success")
Saved
banner(message, tone, action_label, on_action)

A persistent strip in the flow, optionally carrying one action.

banner("Your trial ends Friday", "warning", "Upgrade", "upgrade")
Your trial ends FridayUpgrade
empty_state(title, body, action_label, on_action)

What a list shows when it has nothing. Worth building once.

empty_state("No invoices", "They will appear here once billed.",
            "Create one", "new_invoice")
No invoices
They will appear here once billed.
Create one
tooltip(content)

A hint in an overlay. Pair it with a hover style key so it costs no round trip.

stack({}, [icon_button("?", "noop", {}), tooltip("Read-only")])
Read-only
spinner_sized(size)

No description in the source.

"c": chat_ready ? [text(chat_said, {"size": 0, "clamp": 1, "fg": "text.default"}), text_interned("→", {"size": 1, "fg": "accent.base"})] : [spinner_sized(14), text(chat_said, {"size": 0, "fg": "text.muted"})]

Real use — app/controllers/chat_controller.sl:2844

alert(title, message, on_close, opts)

An alert: one thing to say and nothing to decide, so one button. The handler fires on the button, not on the backdrop — a dialog that closes when the pointer slips is a dialog that loses what it was asking. `opts`: {"ok": "Got it"}

layers = layers.concat([alert(

Real use — app/controllers/live_controller.sl:2608

confirm(title, message, on_confirm, on_cancel, opts)

A confirm: a question with two answers. The affirmative sits last, where the eye ends up, and wears `danger` when it destroys something — the button should say what it will do before the sentence above it is read. `opts`: {"ok": "Delete", "cancel": "Keep", "danger": true}

layers = layers.concat([confirm(

Real use — app/controllers/live_controller.sl:2593

Charts and canvas

All four charts are one canvas node whose paths prop is a list of [kind, colour, numbers…]. The server resolves the colour before encoding, so the client never parses a string while painting.

canvas(width, height, paths)

The primitive underneath. Path kinds cover polylines, rectangles, areas, circles and arcs.

canvas(200, 60, [["line", "accent.base", 0,50, 40,20, 80,35]])
no visual — behaviour only
chart_line(values, w, h)

A polyline over a faint grid, scaled to the maximum.

chart_line([4, 9, 6, 12, 8, 15], 200, 60)
chart_area(values, w, h)

The same line, closed to the baseline and filled.

chart_area([4, 9, 6, 12, 8, 15], 200, 60)
chart_bar(values, w, h)

One rectangle per value, gap derived from the count.

chart_bar([4, 9, 6, 12, 8, 15], 200, 60)
chart_donut(parts, w, h)

Arcs from a list of [label, value] pairs, each in its own role.

chart_donut([["Pro", 60], ["Free", 30], ["Trial", 10]], 80, 80)
chart_max(values)

No description in the source.

top = chart_max(values)

Real use — eui_builders.sl (inside `chart_points`):3259

chart_points(values, w, h)

A series scaled into `w × h` with 4 px of breathing room, as [x, y] pairs.

points = chart_points(values, w, h)

Real use — eui_builders.sl (inside `chart_line`):3417

chart_grid(w, h)

Four hairlines, so a series has something to be read against.

drawing = canvas(w, h, chart_grid(w, h).concat([line]).concat(dots))

Real use — eui_builders.sl (inside `chart_line`):3426

flatten_points(points)

No description in the source.

line = [0, sounding ? skin["inst"] : skin["dim"], 1].concat(flatten_points(points))

Real use — app/controllers/tracker_controller.sl:1066

chart_role(i)

The five roles a chart spends, in order and never cycled. Past the fifth there is no sixth hue to reach for — a generated one is indistinguishable from one already here to a reader with a colour vision deficiency — so the tail goes to the de-emphasis ink and the chart is expected to name it "other",

swatch = {"k": "box", "s": {"width": 10, "height": 10, "radius": 4, "bg": chart_role(i)}}

Real use — eui_builders.sl (inside `chart_donut_legend_row`):3474

chart_wash_style(width, lit)

One column of the plot, behind the drawing. `lit` is the state under the pointer; the width is in the record because the handler has to declare the same box it is repointing, not a narrower one.

"styles": {"lit": chart_wash_style(width, true), "shown": chart_chip_style(true)}

Real use — eui_builders.sl (inside `chart_band`):3382

chart_chip_style(shown)

The tooltip: a chip that is always there and is transparent until the pointer is in its band. Fading one in costs no layout; mounting one would.

"styles": {"lit": chart_wash_style(width, true), "shown": chart_chip_style(true)}

Real use — eui_builders.sl (inside `chart_band`):3382

chart_quoted(s)

A string as the local language's source will read it back: a chunk's `set_text` takes a literal, and a literal wants its quotes.

says = "dv_" + id + ".text = " + chart_quoted(reading) + "; dl_" + id + ".text = " + chart_quoted(label)

Real use — eui_builders.sl (inside `chart_donut_legend_row`):3472

chart_spans(centres, w)

Where the bands meet: the midpoint between neighbouring marks, rounded once so the columns still add up to the plot's width — a band per mark, from the left edge of the plot to its right.

spans = chart_spans(points.map(fn(p) { p[0] }), w)

Real use — eui_builders.sl (inside `chart_line`):3427

chart_band(id, i, width, label)

One band: an invisible box over its share of the plot, carrying the chip and the two handlers that light the pair.

bands = range(0, count).map(fn(i) { chart_band(id, i, spans[i], labels[i]) })

Real use — eui_builders.sl (inside `chart_layers`):3401

chart_layers(id, spans, labels, w, h, drawing)

The three layers, stacked on the plot the drawing was scaled into. The strips are the plot itself — `w − 8` by `h − 8`, centred — so a band sits exactly over the marks `chart_points` placed.

chart_layers(id, spans, chart_labels(values), w, h, drawing)

Real use — eui_builders.sl (inside `chart_line`):3428

chart_labels(values)

No description in the source.

chart_layers(id, spans, chart_labels(values), w, h, drawing)

Real use — eui_builders.sl (inside `chart_line`):3428

chart_donut_legend_row(id, i, part, label, total)

A donut has no bands: an arc is not a box, and a quadrant is not an arc. Its legend is the thing under the pointer instead, and what it shows is the reading in the hole — one `set_text` for the number, one for the name, which is what a chunk is for (07 §1).

chart_donut_legend_row(id, j, parts[j], labels[j] ?? ("Part " + str(j + 1)), total)

Real use — eui_builders.sl (inside `chart_donut`):3523

Dev bar

dev_figure(label, value, tone)

No description in the source.

dev_figure("event", stats["event"].blank? ? "—" : stats["event"], "accent.base"),

Real use — eui_builders.sl (inside `dev_bar`):3180

dev_wire_tone(ops)

How heavy the last patch was, as a colour: a render that sends a few ops is what the design is for, and one that sends the tree is worth noticing.

dev_figure("ops", str(stats["ops"]), dev_wire_tone(stats["ops"])),

Real use — eui_builders.sl (inside `dev_bar`):3183

dev_bar(stats, shown = true)

No description in the source.

layers = layers.concat([dev_bar(eui_stats(), state["devbar"] ?? true)])

Real use — app/controllers/live_controller.sl:2615