03 — Primitives and the catalogue
Status: normative for §1–§3 (what the client implements); §4 is the catalogue contract for servers.
1. The seventeen primitives
The protocol can express exactly these node kinds. Every widget a person would name — button, dialog, table, date picker — is composed from them on the server. Adding a kind is a protocol version bump, because it means shipping a new client, and that price is deliberate.
| kind | Semantics | Children | Text | Focusable |
|---|---|---|---|---|
box | A styled rectangle that arranges children by display | yes | — | — |
text | A run of text, wrapped to its box, clamped by line_clamp | — | yes | — |
image | A raster asset, referenced by BLAKE3 hash in the src prop | — | — | — |
icon | A vector glyph from the client's icon set, named by the name prop | — | — | — |
input | A single-line editable field; text is its value | — | yes | yes |
textarea | A multi-line editable field | — | yes | yes |
scroll | A clipping viewport with scroll offsets | yes | — | — |
list | A scroll whose rows are virtualised by item_height | yes | — | — |
canvas | A retained path list in the paths prop | — | — | — |
spacer | Flexible empty space; inert | — | — | — |
divider | A hairline rule; inert | — | — | — |
overlay | A layer above the normal flow, painted after its siblings | yes | — | — |
slot | A named insertion point; lays out as a column | yes | — | — |
sizer | An invisible box that only imposes constraints | yes | — | — |
audio | A sound, referenced by BLAKE3 hash in the src prop. Draws nothing | — | — | — |
video | A moving picture, referenced by BLAKE3 hash in the src prop | — | — | — |
scene | A 3D picture, drawn by a program the server names (§1.2) | — | — | yes |
An inert kind MUST carry no text, props or handlers; a leaf kind MUST have
no children. Both are decoder errors (02-wire-format.md
§4.1).
An image's bytes are PNG, JPEG or WebP, told apart by their first bytes
and never by a name or a header the server sent. A client MAY be built without
the JPEG or the WebP decoder — they are counted in
10-budgets.md §1 — and then such an asset fails to decode,
with the format in the reason, and the node draws nothing. Anything else is a
decode failure in every build.
1.3 Islands
Any node may carry an island prop — an absolute path on the same origin
— and then its content comes from a session of its own rather than from the
tree it sits in (01 §2.7). slot is the natural carrier, being "a named
insertion point" already, but the prop is not tied to a kind.
The node's own children are what shows until that session speaks, and they came from whatever rendered the page. So an island degrades in three directions at once and needs no special handling in any of them: an older client ignores a prop it does not know and draws the children; a session that cannot be opened leaves the children; and a page served from a cache shows the children until the region connects. In each case the page is out of date rather than broken, which is the difference between a stale number and a hole.
A client MUST refuse an island naming another origin, and opens at most
MAX_ISLANDS of them for one page (10 §1).
2. Painting
Everything paints as a rounded rectangle. For a node with style s:
-
If
s.bgis not none, fill the border box with it, corners rounded bys.radius, ats.opacity. Abgnaming a gradient (02 §5.3, version 6) fills the same box with the gradient, and everything below — the border, the shadow, the blur, the clip — is unchanged by it. Ifs.bluris non-zero, the fill goes over the node's blurred backdrop (§2.1) rather than over what the target holds — so one node is both the frost and the tint — and the border box is filled even whens.bgis none: clear glass is still glass. -
If any
s.border_widthis non-zero ands.border_coloris not none, stroke the inside of the border box with it. -
textpaints its glyphs ins.fg, or the nearest ancestor'sfg, ortext.default;imagepaints its texture;iconpaints the paths itsnameselects, infg, as strokes of the same rounded capsule §1.1's polylines are made of, scaled to the largest square its content box holds and centred in it;dividerpaints a 1 px line inbgorborder.default. A client MUST paint nothing for anameit does not know, and MUST NOT refuse the batch: an icon set grows without a protocol version, so an older client leaves a gap of the right size where a newer one draws. Aniconwith no width or height of its own takes a square from the font size in force, so an icon set beside a label needs no measurement from the server. -
If
s.text_decorationis non-zero,text,inputandtextareadraw a rule in the same colour the glyphs took, one per line of the shaped run and no wider than the glyphs on that line — so a decoration on text that wrapped underlines each line to its own end rather than drawing one bar the width of the box. Bit 0 puts it below the baseline, bit 1 through the middle of the x-height, and both together draw both. It is one device pixel at 1× and grows with the scale, exactly as the caret does. The offsets are the client's: the shaper hands back a baseline and an advance, not an underline position, and a server that wanted a rule somewhere precise would be asking for a layout it cannot see. -
Children paint in order;
stackchildren in ascendingz. Anoverlaypaints in the top layer: after every other node in the tree, overlays among themselves in tree order, clipped by the window and by no ancestor — so a dialog inside a card and a popover inside a scroller are both whole. Hit-testing asks the top layer first, and in the same order.A press outside an open overlay dismisses it, and the client does that, because the client owns the hand. An overlay carrying a
blurhandler hearsblurwhen a press lands outside it; one carrying none hears nothing and is not dismissible, so an application opts in per panel and says for itself what closing means. Nothing is added to the wire:bluralready means "this stopped being the thing being used".Outside means outside the overlay's parent, not outside the overlay. A panel and the control that raised it are siblings under one box — which is what a
stackwith an absolute overlay in it is — so a press on the control is a press on the widget. Were the rule the overlay alone, a select would shut on the press and its own click would open it again, and no select could ever be closed by clicking it. -
scrollandlistclip their children to their border box, and one whose content is taller than its box wears a vertical scrollbar along its right edge: a thumb intext.mutedat 45 % opacity, as long as view ÷ content of the track and never under 24 px, painted after the children. The strip is the client's: pressing the thumb drags it, pressing the track pages by the view's height, and neither reaches the application except as thescrollevent the resulting offset produces. While the pointer is on the strip, or the thumb is being dragged, the thumb fills the strip intext.defaultat 70 %. -
A node whose
overflowisclipclips its children to its border box exactly as ascrolldoes, and wears no scrollbar. Hit-testing honours every clip painting does: a point is on a node only where that node can be painted. A node whose border box lies wholly outside the clip it is painted under is not painted, nor is anything under it — however far a child spills out of its parent — and none of them can be hit. Enforced by the layout's hit test (hit_inineui-layout), which culls by the test the painter culls by; see 09 §4.
Rectangles are snapped to device pixels before painting; glyph positions are snapped horizontally to whole device pixels and vertically to the line's baseline. Anti-aliasing is by signed distance to the edge, so the same pipeline draws boxes, hairlines and glyphs.
A non-zero s.shadow paints, before the background, each of the scale's
layers (05 §2) in order, and each layer is a black rounded rectangle: the
border box grown by the layer's spread on every side — shrunk, when the
spread is negative — with its corner radius moved by the same amount and
never below zero, offset by the layer's y, and grown again by its blur,
with coverage falling from opaque one blur inside the spread edge to nothing
at the grown edge, at the layer's opacity. A layer of opacity zero paints
nothing. canvas paths (§1.1) are part of version 1.
2.1 The backdrop
A node's backdrop is the frame as it stood when the first blurred node of that frame was about to be painted — including that node's own shadow, which is painted before its background. Every blurred node in a frame shares that one backdrop, so two frosted panels that overlap both show what is under the pair rather than one showing through the other. One snapshot is taken however many nodes ask, and a frame in which none does is the single pass it always was.
s.blur is the standard deviation of a Gaussian, in device-independent
pixels, as CSS blur() is. A client MAY approximate the Gaussian — as it
already approximates a shadow's (§2) — and a reference client does, by
convolving a reduced copy: the reduction is chosen so that the kernel's
width in samples stays about the same whatever radius was asked for, which
is what keeps a scrim over the whole window affordable. A radius beyond
about 85 device pixels MAY blur no further.
The backdrop is what the client itself painted. Nothing behind the window is read, and nothing is reported back: a blur is no more a readback than a shadow is (00 §"What EUI deliberately refuses").
5. Transitions
A style record's transition byte (02 §3, offset 60) names a motion scale
index plus one; 0 is none. When a node's style changes — by SetStyle, or
by a local handler's set_style — and the new record's transition is
non-zero, the client animates bg, fg, border_color and opacity from
the old record's resolved values to the new over that duration, along the
theme's easing curve, plus blur (§2.1) — nothing else, because layout
never runs per frame. The colours between are the renderer's: the quads a
transitioning node paints carry both ends and the clock, and the vertex
stage eases between them from the list's own age, so a frame owed to a
transition alone is the previous draw list drawn again, as a spin's is.
Only a transition of the blur is painted frame by frame, because the
backdrop is sized from it. A change of bg to or from a gradient (02 §5.3) does not ease: a
node whose bg is a gradient at either end of the change takes the new
record's colours at once — background, border and foreground — and eases
only its opacity and its blur. A gradient is three colours and a direction, and the half-way
point between one and a flat colour — or between two with their stops in
different places — is not a gradient either end describes; a client that
invented one would be drawing a picture neither record names. An entrance
fades a gradient up by its opacity alone, which is the same thing seen from
nothing. A node that is mounted or replaced appears at once
unless it asks otherwise, which is enter below. The server is never
told; a transition is the client's rendering of a state change it already
knows about, and a client MAY skip it (reduced motion) without any
difference on the wire.
A record's animation byte of 2 is enter: a node grafted wearing it
— by Mount, Replace or InsertChild — arrives from nothing and
reaches its own record over its transition duration, or motion.base
when it names none, along the decelerate curve of 05 §2 rather than
the theme's standard one. From nothing means transparent and unblurred: bg,
fg and border_color fade up from no colour at all, opacity from 0,
and blur from 0, so a dialog's scrim darkens and widens its Gaussian
together and the page is handed out of play rather than shown already
gone.
An entrance dims everything painted for the node, as spin turns
everything painted for one: a panel arriving at a third of its opacity
shows its own text at a third too. opacity is otherwise a property of a
single node's own painting, and this is the one place it descends —
without it a dialog's words would be at full strength before the card
under them had arrived, which reads as text appearing on its own.
It has to be asked for, and that is the whole of why it is a separate
byte rather than an extension of transition: a button carrying a
transition for its hover would otherwise fade in every time a resync
rebuilt the tree, which is the flash the sentence above exists to
prevent. A client MAY skip an entrance for the same reason it may skip a
transition, and the server hears nothing either way.
5.1 Leaving
A record's animation bit 4 is exit, the mirror of enter: a node
released while wearing it — by RemoveChild, Replace, Mount, or a
parent's release carrying it down — keeps its painting for its
transition duration, or motion.base when it names none, along the
accelerate curve of 05 §2 rather than the theme's standard one. Then it
is let go.
animation is a bit set and not an enumeration for exactly this reason. A
node has to say how it will leave while it is still there to say it: the op
that removes a node is the only op there is, and a SetStyle aimed at one on
its way out would be a style change on something already gone. So a page
names its arrival and its departure in the one record it is grafted with, and
enter | exit is the ordinary spelling of a page.
What is kept is the painting and not the tree. The node is gone: it
cannot be named by an op, cannot be reached by an event, cannot hold focus,
cannot be a waker or a locator, is not laid out again, is not hit-tested —
a click during a page's departure lands on whatever is arriving underneath,
which is what a person expects — and is not in the accessibility tree. None
of that is a rule a client has to enforce at each door; it is what is left
when only the quads are kept.
Three bounds hold it down. A client MUST keep at most one released painting per session, and a second supersedes the first, which is let go at once — the same shape as one contact at a time (06 §5) and one drag at a time (06 §6), and what makes the memory a constant rather than a function of how fast a person can tap. A client MUST let a painting go rather than draw it wrong when what it was made against has moved: the glyph atlas it sampled, the size of the frame, the viewer's palette. And a subtree that painted an overlay (§1) painted part of itself in the top layer, outside the span it otherwise occupies; a client MAY refuse to keep such a page at all, and then it simply goes, which is the same licence the paragraph below gives.
The server is never told — not that a departure began, and not that it ended. There is no op, no event and no acknowledgement, exactly as for the transition above, and a client MAY skip the whole thing (reduced motion) with no difference on the wire.
5.2 Which way
A record's motion_kind byte (02 §3, offset 63) says which way its entrance
arrives and its exit leaves:
| value | Name | The geometry |
|---|---|---|
| 0 | fade | no movement: the entrance as §5 has always described it |
| 1 | leading | from, or to, beyond the leading edge of the parent's content box |
| 2 | trailing | likewise the trailing edge |
| 3 | top | |
| 4 | bottom | |
| 5 | scale | from, or to, 92 % about the node's own centre |
| 6 | paired | it flies between its own box and that of the node carrying the same key on the other side of the change (§5.3) |
A direction and never a duration. The duration is transition, and keeping
the two apart is what stops a page from carrying a timing of its own — 05 §2
having already said that motion specified per node is motion nobody gets
right twice.
A node that is leaving is not told where to go. Its direction is the
mirror of the direction the node arriving beside it came from, under
leading ↔ trailing, top ↔ bottom, and fade, scale and paired each
their own. Two nodes are a pair when one is released and one is grafted under
the same parent with no paint between them; a release with no partner uses
its own record's direction. So a push and a pop are the same sentence read in
the two directions, a server interns two records for a page rather than four,
and the question "which way is back" is never asked on the wire at all.
The one leaving travels a third of the distance the one arriving does,
and fades to nothing while it goes. Both are prose here and not fields.
A third, because the page underneath is not being replaced, it is being
uncovered, and something sliding out as fast as the thing covering it reads
as two slides rather than as a stack with a depth to it. Fading, because a
page leaving at full opacity is a second page competing with the one
arriving — and on the accelerate curve §5 already gives an exit it stays
nearly solid for the first half of the move and only then lets go, which
reads as a departure rather than as a dissolve. A client that moved both the
same distance, or that slid a page out without fading it, would be
conforming and wrong.
As with an entrance, the movement descends to everything painted for the node. That is the second place anything descends, and the justification already given for opacity carries over without change: a page whose header slid while its rows did not would read as a tear rather than as a page.
5.3 Pairing
motion_kind 6 paired, on a node carrying a key, on both sides of a
change: the node arriving flies out of the box the node leaving under the
same key had, rather than going the way its page goes.
The arriving one, and not both. A shared element is one thing seen twice, and drawing it twice at once is the thing itself coming apart: the one that leaves is simply gone, and what a person follows is the one that is still there. This also costs nothing to say — the page's own departure (§5.1) is one kept painting and the element rides out inside it, or the page is not animating and there is nothing to ride.
Both ends are a rectangle, and the rectangle it starts on is its partner's. A client MUST put the arriving node's centre on the centre its partner's box had, at its partner's width, and take it from there to where the layout put it. One scale and not two, because a pair is a thing travelling and not a thing being stretched, and the two boxes of a shared element are near enough in shape that one number reads correctly — but the point the two boxes are matched at has to be said, because a client that matched their corners instead would put the element out by half the difference in their sizes, which is every pair there is.
Nothing is laid out per frame to do it. The arriving node was laid out for this frame, and the leaving node's box is the one it was last painted at — both rectangles are known before the first frame of the movement, so the pair is one interpolation resolved once, which is the same bargain §5 strikes for a colour and 04 §7 strikes for a scroll.
A key pairs wherever it sits. The node that left is very often not the node the change names: a page swap removes the page, and the thumbnail the panel grows out of was somewhere inside it. A client MUST therefore recognise a departure by the key having gone, and not only by the released node being the one that wore it — a rule that costs it nothing, since a node that was never painted has no box to fly from and could not have paired anyway.
Two nodes are a pair for one change and no longer, exactly as §5.2's push and pop are: a box is a partner's until a frame is painted without it.
A paired node whose partner is missing is not an error and MUST NOT
refuse the batch: it falls back to the motion of the page it is on. A
panel is built and torn down as it opens, so a name that does not resolve is
the ordinary case and not a broken one. It is also, for the same reason, the
one thing here nothing can diagnose for an author — a key spelt two ways is a
page where nothing moves and nothing complains — so a client SHOULD have some
way of saying which pairs it resolved and which it did not.
A client MUST bound the number of pairs it resolves in one change — 10 §"Going somewhere" names eight — and a client that cannot resolve a pair paints the node where the layout put it, the same answer it gives when it runs out of room for a scroll in flight.
A record's animation bit 1 is spin: a
node wearing it turns about its own centre, one revolution every 1.2 s,
for as long as it is on screen, and everything painted for it — its box,
its text, its canvas paths — turns with it. It is what a spinner is made
of: a canvas arc that spins. The client wakes for frames only while a
spinning node is painted, and those frames are cheap by construction: the
angle is the vertex stage's, from a clock the window hands it, so a frame
owed to a spin alone is the previous draw list drawn again — nothing is
laid out, nothing is painted, nothing is uploaded — at thirty a second,
which is 12° a frame. The same holds of a list with nothing moving in it
at all: it is the frame until something reaches the client, however often
a window asks to draw it. A client MAY hold a spin still (reduced motion).
Bits 8 and 16 are pulse and bounce, and are version 6 (a server
strips them from a record it sends a session negotiated below 6). They are
Tailwind's animate-pulse and animate-bounce, stated as numbers rather
than as a stylesheet:
- pulse: everything painted for the node is drawn at its opacity times
a factor that falls from 1 to 0.5 over the first second of every two and
rises back over the second, each half along
cubic-bezier(0.4, 0, 0.6, 1). At phasepof the 2 s period, withBthat curve, the factor is1 − 0.5·B(2p)whilep < ½and0.5 + 0.5·B(2p − 1)after. - bounce: everything painted for the node is drawn raised by a quarter
of the node's border-box height
hand let fall back, once a second. At phasepof the 1 s period the painting standsh/4 · (1 − B₁(2p))above where it was laid out whilep < ½, withB₁ = cubic-bezier(0.8, 0, 1, 1), andh/4 · B₂(2p − 1)after, withB₂ = cubic-bezier(0, 0, 0.2, 1)— so it falls accelerating, touches down at the half, and rises decelerating.
Both are painting and nothing else. The node is laid out, hit-tested and
announced where it stands, so a bouncing arrow is pressed where it rests and
not where it happens to be drawn — the same bargain spin strikes. Both run
from the same clock as spin, so every pulsing node pulses in step and every
bouncing node bounces in step, and a frame owed to them alone is the previous
draw list drawn again: the factor and the lift are the vertex stage's, from
the clock the window hands it. The client wakes for frames only while such a
node is painted.
The bits combine with each other and with the rest: a spinner that pulses, an arrow that bounces as it arrives. A pulse inside a pulse is drawn at the one factor, not its square; a bounce inside a bounce adds the two lifts, which is what CSS does with two translations in step. A client MAY hold either still (reduced motion): a still pulse is drawn at full opacity and a still bounce where it was laid out.
1.1 canvas paths
A canvas carries its drawing in the paths prop: a List of paths, each
a List beginning with an Int kind and a colour, followed by numbers.
Coordinates are logical px from the node's content box; the drawing is
clipped to the border box. The colour is a Color (a server resolves role
names and #RRGGBB[AA] before encoding); a client MUST also accept an Int
role id and, for hand-written trees, a Str role name or hex literal.
| kind | Path | Meaning |
|---|---|---|
0 | [0, colour, width, x0, y0, x1, y1, …] | A polyline stroked width px wide, round caps and joins |
1 | [1, colour, x, y, w, h, radius] | A filled rectangle |
2 | [2, colour, base_y, x0, y0, x1, y1, …] | The area between a polyline and the horizontal base_y |
3 | [3, colour, cx, cy, r] | A filled circle |
4 | [4, colour, width, cx, cy, r, a0, a1] | An arc of radius r from angle a0 to a1, radians, 0 along +x, increasing clockwise on screen |
Paths paint in order. A path with an unknown kind, a colour that does not resolve, or too few numbers is skipped, never an error: a chart with a bad series still shows its grid. The reference client draws every kind with its rounded-rectangle pipeline — a segment is a rotated capsule, an area a strip per device column, an arc a fan of chords at most 6° apart — so a canvas adds no shader, no tessellator and no allocation beyond its quads.
1.2 scene
A scene is the one node whose picture the client does not compute from the
tree. The server names a WGSL module and a mesh, both as assets; the client
renders them into a target of its own, with a depth buffer of its own, and
draws that target as a single textured quad in the node's box.
Everything a node ordinarily gets, a scene gets from that quad: the corner
radius of its style, its opacity, the scissor of any scroll it sits in, and
the transform of any page transition it is on (§5). Its module knows about
none of them, and is not trusted to apply any of them.
| Prop | Value | Meaning |
|---|---|---|
shader | asset | The WGSL module, in its "EUIS" container (11 §1). Absent is the client's own |
mesh | asset | The geometry, in its "EUIM" container. Absent is the client's own |
uniforms | list of ≤ 8 numbers | The author's half of the uniform block (11 §3), zero-padded |
playing | bool | The clock runs. Absent is false — the same word audio and video use, and the same meaning |
fps | int | Frames a second while it animates, 1 to 60. A server may slow a scene down, never speed the client up |
msaa | int | 4 asks for four samples, 1 for one. Absent is 4: a scene is a picture of edges, and the author who has to ask for them is the author who ships without them |
A scene has no intrinsic size: the server sizes it, as it does a
spacer. It names two assets, and neither of them is a width.
A client that cannot multisample this format MUST draw the scene once rather than refuse it. A slightly harder silhouette is better than no picture, and the alternative is a validation error on a machine the author never had.
The scene capability guards the module, not the kind. A scene that
names a shader MUST NOT be drawn unless the capability was granted (08 §3):
without it the client does not fetch the module, and the node paints its own
background like any node with no content. A scene that names none draws — it
is the client's own program over the client's own shape, so there is nothing
third-party to consent to, and its mesh is fetched either way because
vertices are data and not a program.
An application that uses a scene at all SHOULD still request the
capability, whether or not it names a module: the request is what raises the
manifest's protocol_min (01 §2.1), and an older client cannot decode the
kind however the scene is drawn. Requesting and granting are separate acts,
and only the second is about running somebody's code.
No readback, and no picking. A scene's target is not readable: 08 §8's "no canvas readback" is kept by construction here, not by rule. It follows that a press on a scene reports a position in the node's box and never an object, a triangle or a depth — picking is readback under another name, and a server that sent the geometry can do it for itself.
A scene that is not playing is a still picture: it is drawn once and wakes
nothing, and the zero-wakeup line of 10 §1 holds with one on screen. A scene
that is playing is a window that asked to be woken, exactly as a wake
prop is, and costs what it asked for. What a client MUST NOT do is wake the
part of itself that reads the server's bytes: an animating scene is the same
draw list redrawn with a later clock.
3. Focus and keyboard
-
inputandtextareatake focus on primary click and onTaborder, which is document order. A node that handleskey_downorkey_uptakes focus on primary click too — the nearest one on the path, after any editable ancestor — so a grid, a canvas or a pattern editor is typed into after a click rather than after being found withTab. -
A focused node draws a 2 px ring in
focus.ring, outside its border box, when focus arrived from the keyboard; a client MAY suppress the ring after a pointer click. -
Enterin aninputemitssubmit;Escapeblurs.TabandShift+Tabmove focus; the client, not the server, owns that order. -
A node with a
clickhandler is activatable: it takes focus onTabandEnterorSpaceemitsclickat its centre — unless that same node handleskey_down, in which case both keys are reported and nothing is activated: it asked for the keyboard, andSpacein a tracker starts the song. A node handlingkey_downorkey_uptakes focus onTabtoo, so an editor is reachable without a pointer. -
The scrolling keys belong to the client while nothing that wants keys has focus — no editable node, and no node on the focused path handling
key_downorkey_up. An application that asked for the arrows gets them: a pattern editor, a grid, a game. Otherwise:ArrowDown/ArrowUpland the scroller on its next/previous row — alist's rows, or the scroller's children. A plain 40 px step where there are none, where there is only one — a page whose content is a single column has one row top, at 0, and an arrow that honoured it would beHome— or where the nearest is more than a viewport away, so that an arrow never travels further thanPageUpwould.PageDown/PageUpmove one viewport,Home/Endthe whole way. From rest the view eases in and out overmotion.slow; a press that arrives while it is moving keeps the momentum and eases out to the new target overmotion.base, like a wheel notch, so a held key is one glide rather than a series of departures. The scroller is the one under the pointer that can still move in that direction, else its ancestor that can, else the focused node's, else the first in document order that can. A nestedlistthat does not overflow must not swallow the page's wheel. Nothing is reported but thescrollthe landing produces. -
A node carrying
drag(§3.4) takes focus onTab, so a thing that can be moved can be reached without a pointer.Spacepicks it up; the arrows then move it along the container's axis,Spaceputs it down,Escapeputs it back. While the grab is live the client owns those keys, and only those and only on that node — not grabbed, nothing is claimed at all and the arrows are still the scroller's.Spaceis claimed only on a node with noclickhandler. A row that is both activatable and movable keepsEnter/Spacefor the thing it stands for and reaches the grab through adrag_handlechild, which is its own focus stop. Akeysprop (§3.1) naming" ", an arrow orEscapetakes it back: the server wins when it asks explicitly. The grab reports what a pointer drag reports and nothing else —drag_start,drag_over,drop(06-events.md§6) — so a server needs no second path for the keyboard. -
The pointer takes the shape of what it is over: the nearest ancestor's
cursorstyle when one names a shape, else a text beam over an editable node, elsegrabover a node resolvingdrag(§3.4), else a hand over anything with aclickhandler, else the arrow — and the arrow on a scrollbar. While a drag is live the shape isgrabbing, over everything.What it is over is a node, and a batch does not move the pointer. A client re-finds that node by its id after every batch, including an island's, and the shape does not change because the tree was rebuilt under a hand that never moved. Re-finding it is not a gesture: no
pointer_enter, nopointer_leaveand nopointer_moveis reported for it. When the node has genuinely left the tree the last shape stands until the next frame settles the hover, which is where that frame'spointer_leaveandpointer_enterbelong. A client that instead re-reads whatever now occupies the node's former place answers for a node the pointer was never on — the arrow over a button, once per update, on every page that updates. -
An
inputtaller than its line — stretched by a row, or given a control height — centres its line vertically, caret and selection with it; atextareastarts at the top.text_align(start,center,end) shifts the run inside the content box; a run that overflows still starts at the left and scrolls.justifyis painted asstart. -
In an editable node the client owns the caret and the selection. A click places the caret at the nearest glyph edge and a drag selects;
ArrowLeft/ArrowRightmove by character, by word withCtrl(⌘on macOS), extending the selection withShift;Home/Endreach the line's ends, the text's withCtrl;Backspace/Deleteremove the selection or one character;Ctrl+Aselects all;Ctrl+C/Ctrl+Xput the selection on the system clipboard;Ctrl+Vinserts it;Enterin atextareainserts a line. Typing, a paste and a committed composition all replace the selection, and each reaches the application as onetext_input. The caret is drawn in the text colour one device pixel wide, the selection inaccent.baseat 30 % opacity, and a field scrolls its text to keep the caret in view. The caret blinks at 530 ms, up first: anything that moves it — a keystroke, a click, an arrow — starts the period again, so it is never absent under a hand that is typing. After ten seconds with nothing moving it the blink stops and the caret stays up, which is what keeps 10 §1's idle budget true of a window left open on a form. The selection does not blink. A paste is the person's act on their own clipboard;clipboard.read(08 §7) governs reads the application would initiate. -
An
inputcarryingsecret: trueis a password field. The client paints one mark per character (•), measures that string, and copies nothing from a selection —Ctrl+Cis consumed and the clipboard is left alone;Ctrl+Xdeletes and still copies nothing. The value on the wire, and in the node's text, is what was typed. AtextareaMUST ignore the prop: a secret that wraps is not a password. An assistive technology is handed a password field whose value is empty, never the text. -
An
inputortextareacarryingplaceholder, a string, shows it while the node's text is empty — and only then: the first character typed takes it away and emptying the field brings it back, with no round trip. It is drawn where the value would be, shaped, wrapped and aligned as the value would be, intext.mutedwhatever the node'sfg, and a focused empty field draws its caret, in the node's own colour, exactly where it would with no placeholder at all. It is not the value: it is never in the node's text, never in achangeor atext_input, cannot be selected, copied or moved through by the caret, and asecretfield paints it as text rather than as marks, because it is not what was typed. An empty field is measured as though it held its placeholder, so a field sized by its content does not clip its own hint; a field with a value is measured by the value alone. An assistive technology is handed it as the field's placeholder, never as its value (§6). Aplaceholderthat is not a string, and one on any other kind, is ignored. The client paints it; the server sends it as it sends any prop. 09 §8 names the vectors.
3.1 What a node may claim of the keyboard
Four props, read by the client, for the things a server cannot do because it does not own them. §3.2 to §3.4 are the rest of that family.
| Prop | Value | Means |
|---|---|---|
modal | boolean | while this node is laid out, Tab order is its subtree alone |
autofocus | boolean | focus starts here when the surface holding it arrives |
keys | list of key names | the keys this node wants; it is sent no others |
typing | boolean | this node takes typed text, though it is not a field |
-
modal. A client MUST restrict its focus order to the subtree of the innermost laid-out node carryingmodal. Innermost, so a dialog opened over a dialog traps inside the second. Without this,Tabwalks out of an open dialog into the page behind it, and no server can prevent it. -
autofocus. On applying a batch that did not carry an explicitFocusop, a client SHOULD focus the first laid-out node carryingautofocus— but only when focus is not already where it belongs: inside the modal if there is one, or anywhere at all if there is not. A batch arriving while someone is tabbing through an open dialog MUST NOT pull them back to its first field. -
typing. A node carryingtypingtakes typed text while it has focus, and a client MUST treat it as it treats a field for the one purpose of making typing possible: where a client would welcome an input method for aninput, it welcomes one for this, and where raising a soft keyboard is what that amounts to — a phone, where there is no other keyboard — it raises it.Nothing else about the node changes. The client owns no caret here, no selection, and no buffer; it inserts nothing and reports no
text_inputfor typing. What the person types arrives as thekey_downthe node already asked for, which is the whole point: an editor, a pattern grid or a game wants the gutter, the highlighting and the selection to be one thing, and that thing is the application's (§3). Such a view is built from a box and a handler, and on a desktop it works because a keyboard is already there to be typed on.A paste is the exception, and MUST be reported: a node carrying
typingand holding focus is sent the pasted text as atext_input, and the client inserts nothing on its own. A paste is the person's act on their own clipboard (§3.1 above), the client has no buffer of this node's to put it in, and dropping it would leave a terminal — the thingtypingmost exists for — unable to receive text a person deliberately handed it. Nothing else about the node changes: no caret appears, and the text is reported exactly as it was pasted, newlines included.It is opt-in and MUST NOT be inferred from a node merely holding a
key_downhandler. A page that listens for one shortcut at its root would otherwise raise a phone's keyboard on any focus at all, over the view it was reading, with no way to decline; and a client that guessed would leave the application no way to say which of its boxes is the one being typed into.A client with no soft keyboard and no input method to welcome does nothing for this prop, which is correct: there, the keyboard was never in the way.
-
keys. A node holding akey_downorkey_uphandler and carryingkeysis sent only the keys it names, and only those are withheld from the client's own meaning. So atabmay takeArrowLeftandArrowRightand still be activated byEnter, and a dialog may listen forEscapewithout hearing every letter typed into the field inside it. A node with a handler and nokeysprop hears everything — but only for itself: a dialog that merely listens must not swallow theEnterthat presses a button inside it.Inside an editable node, "withheld from the client's own meaning" needs saying in three parts, because the obvious reading makes a field nothing can be typed into. The rule is that a client keeps a key it has a use for and yields one it does not:
- Editing the text —
Backspace,Delete, the arrows,Home/End,Ctrl+A/C/X,Enterin atextarea, and every printable character — is never withheld. No prop may take it away. A key the client used this way is not reported, because a client that both acted on a key and passed it on leaves the application unable to tell that apart from a key it could not use, which is the same as not reporting it at all. - Acting on the field as a whole —
Enterin aninput— is withheld on a claim, and what is withheld is thesubmit, never thechange. A server that claimedEnterto take a highlighted suggestion instead of the text still needs to know what was typed, and needs it before the key that acts on it. This is the same rule as a claim on a button, which withholds the click the key stands for: the key is reported, and the client's interpretation of it is not applied. - A key in a position where it does nothing —
Backspacewith nothing before the caret,ArrowLeftat 0,Ctrl+Cwith no selection — is never the client's, claim or no claim, and is reported ifkeysasked for it. There was no meaning to withhold. This is what lets a list of tokens above a field takeBackspaceas "remove the last" without the field losing the ability to delete a character.
A printable character is never withheld and cannot be: text does not reach the client as a key at all, but as input with no key name on it, so there is nothing to hold back. A separator typed into a field is in the field before any handler could have been told, and a server that wants one takes it out of the value instead.
- Editing the text —
Escape follows from the third: it reaches a key_down handler on the path
that asked for it, and focus is left alone so the surface can put it back. If
nothing on the path asked, Escape drops focus, which is what it has always
done. The editing rules above do not touch it — a panel that closes on
Escape hears it with the caret still in the field below.
Tab moves focus, and one kind of node takes it instead: a focused node
carrying typing is sent Tab and Shift+Tab as keys, and the client
does not move focus for either. Nothing else may ask. A terminal, a code
editor and a form-filling canvas all mean the tab character by Tab, and a
node that has already declared it takes typed text without being a field
(§3.1) is exactly the node that means it; every other surface keeps the
client's order, because a surface that could take Tab on a whim could
strand someone in it.
Ctrl+Shift+Tab is the way out, and nothing may claim it. A client MUST
move focus backwards on it, whatever typing and keys say, and MUST NOT
report it. That is what keeps the paragraph above honest: a node may hold
both tabs, and there is still one chord that leaves. It is chosen because a
terminal has no byte for it — the sequence a tty receives for Tab is
0x09 and for Shift+Tab is ESC [ Z, and neither has a control-shifted
form — so nothing is taken from the surface that wanted the other two.
3.2 What a node may claim of the filesystem
Two more props, for the two things a server cannot do at all: reach a file on the person's machine, and put one there.
| Prop | Value | Means |
|---|---|---|
pick | Str accept, or List[Str accept, Int flags, Int max] | activating this node opens the platform's open dialog |
drop | as pick | a file let go over this node arrives as if it had been picked |
save | Str name | activating this node opens the platform's save dialog |
accept is a comma-separated list of extensions without dots ("csv,pdf"),
empty for any file. flags bit 0 allows more than one file; bit 1 asks
for the camera and bit 2 for a recording, rather than for something
the person already has. max is
the largest one file may be, in bytes, capped by
10-budgets.md §5 and defaulting to it. name is the name
to suggest, and a client MUST reduce it to its last path segment.
Bits 1 and 2 change which capability the sheet needs and nothing else: a
picture taken now, or a recording just made, is a file like any other —
reported by the same file_pick and carried by the same Upload frames.
Bit 1 needs camera, bit 2 needs microphone, and a node asking
for either MUST NOT be opened on fs.pick alone: taking a photograph,
making a recording and reading a folder are three different powers, and
none implies another. Bits 1 and 2 together are a contradiction; a client
MUST resolve it as the camera, so that two clients resolve it alike.
The recording is a finished one, and that is the whole reason it fits
here: it has an end, so it is a file, and files already have a transport
(01-transport.md §6). Listening to a microphone as it
runs is a stream, has no transport in this protocol, and is not this
(08-security.md §9.1).
A client with no capture on its platform opens nothing, exactly as it would
for a capability that was not granted. Bit 0 with either is one file:
neither phone photographs or records several things in one sheet, and a
multiple a client cannot honour is a promise to the server it would then
break.
A client opens a dialog when all of this holds, and never otherwise:
- the person activated the node — a primary click, or
Enter/Spacewith focus on it. A tree that merely arrives opens nothing, and neither does a batch, a timer, or a local handler; - the node carries the prop and a server handler for the event that
answers it —
file_pickorfile_save(06-events.md§1). A local chunk cannot be given a file or asked for one: both ends of a transfer are the server's; - the matching capability —
fs.pickorfs.save— was granted (01-transport.md§2.1). Without it there is no dialog and no diagnostic the application can see.
A node may carry a click handler as well; the click is dispatched as
usual, so an application may show that something is happening. At most one
dialog per node is open at a time.
What is picked. Each file chosen gives one file_pick event carrying an
upload id, the file's name — never its path — and its size; the bytes
follow as Upload frames against that id
(01-transport.md §6). A file past max is announced
and then aborted, so the application can say why rather than leave the
person watching nothing happen. A dismissed dialog is not an event: nothing
happened.
What is dropped. drop is the same prop as pick and the same
arrival: a file let go over the node gives one file_pick carrying an
upload id, the file's name — never its path — and its size, and the
bytes follow as Upload frames. Nothing downstream can tell a drop from a
pick, and nothing should: they are one act with two gestures, and an
application that handles the dialog handles the drop for free.
Three of the prop's parts do not survive the change of gesture. accept
filters what a dialog will offer and filters nothing at all here — no
platform lets a window refuse a file before it is let go — so a node that
cares must look at the name it is given and say why. flags bit 0 is moot:
a hand lets go of as many files as it is holding, and each is its own
file_pick. Bits 1 and 2 are meaningless — a drop is never a camera — and
a client MUST ignore them rather than treat the node as a capture. max is
enforced exactly as it is for a pick: the event arrives, an Abort follows
on that id, and the application says why.
A client opens nothing and reads nothing unless all of this holds:
- a file was let go over the node — the position is the pointer's, and a platform that reports a drop without one reports it where the pointer last was;
- the node carries
dropand a server handler forfile_pick, the same pair a dialog needs and for the same reason; - the
fs.pickcapability was granted. A drop is reading a file the person already has, which is what that grant is; it needs no second one. Without it there is no transfer and no diagnostic the application can see, exactly as for a dialog.
Showing that it would. A node carrying drop may also carry a handler
for file_drag (06-events.md §1), which reports true
when a file comes over it and false when the file leaves, the drag ends,
or it goes anywhere else. It is sent only when the node under the file
changes, not once a frame: a box that flickers under a held file is worse
than one that does not light at all. It is a report and nothing more —
nothing is read, and a client that never sends it is still conformant.
What is saved. The person choosing a place gives one file_save event
carrying the name they chose. That event is the request: the bytes are
owed, and arrive as Blob frames addressed to the node. Nothing is written
until the first chunk arrives, so a save the server never answers leaves
nothing behind, and an aborted one leaves nothing either — half an export is
worse than none, because it looks like a whole one until it is opened.
3.3 What a node may claim of a reader
One prop, for a thing that is neither a file nor a fact about the machine: a tag somebody holds against it.
| Prop | Value | Means |
|---|---|---|
nfc | Str prompt | activating this node starts a scan for one tag |
prompt is what to tell the person the scan is for. A platform that raises
a sheet of its own shows it; one that listens without a sheet has nowhere
to put it, and the application should say it in the tree as well.
The three conditions of §3.2 hold word for word: the person activated
the node, the node carries the prop and a server handler for
nfc_tag, and nfc was granted. A tree that merely arrives scans nothing,
and neither does a batch, a timer, or a local handler. This is not a
platform's rule reflected into the protocol — one of the two phones would
happily listen for as long as its screen is on — it is the protocol's rule
imposed on both, because a reader nobody started is the whole of what makes
one dangerous.
What is read. One tag gives one nfc_tag event
(06-events.md §1) and ends the scan: a reader that
delivers twice is answered once. A scan that reads nothing before it ends
is not an event — the person held their phone up and thought better of it,
and an application learns that only if it is told, which it is not.
A client MUST NOT ship a general NDEF model. Records are reduced to
(kind, payload) pairs, where kind is text, uri, mime:… or raw,
and payload is UTF-8 for the first three and lower-case hex for the last.
Parsing a format a stranger wrote, in a process that on both phones has no
worker to be confined to (08-security.md §10), is
exactly the surface this protocol spends its effort avoiding.
This is why a save costs a round trip rather than riding on an asset: the bytes are generated when the person asks for them, they are nobody else's, and no one who is not on this session can fetch them.
3.4 What a node may claim of the pointer
Five props and a family, for the two gestures a server cannot resolve because it does not own the hand: picking something up and putting it somewhere else, and running a value along a line.
| Prop | Value | Means |
|---|---|---|
drag | boolean, or Str group | this node can be picked up; the string names its group |
accepts | boolean, Str group, or List[Str] | this node takes items of those groups |
drag_handle | boolean | a press here grabs at once, without the slop or the hold |
drag_axis | "x", "y" or "both" | which way a slot moves, and which arrows move it |
drag_only | boolean | this node hears pointer_move only while a button is down |
track | "x" or "y" | this node is a value on a line, running that way |
There is no "reorder me". Reordering is the case where the item's own
parent is the target, so a container that accepts what its children drag
reorders itself and takes the same thing from elsewhere, and the server tells
the two apart because it has both ends and a model. One prop, two behaviours,
and no way for them to disagree.
The prop says what a node is; the handler says who hears. A row is
draggable and a list is what hears the drop, and those are two different nodes
resolved by two different walks: the props by the walk in
06-events.md §6, the handler by §2's nearest-handler rule as
always. Matching is the client's affordance and not authorisation — it decides
which containers light up and which shape the pointer takes, and §4 there still
requires the server to re-derive everything it is told.
drag: true is the empty group and matches accepts: true alone. A named
group matches a container naming it, or naming it among several.
A draggable node MUST carry a key. It is how the client holds on to what
is in the hand: a move between containers is a removal and an insertion
(02-wire-format.md §5), so the node's id changes under
the gesture and only the key survives it. Keyed children are also what make the
server's reorder a MoveChild rather than a rebuild, so this costs nothing that
was not already owed.
drag_only is the older of the five and belongs here rather than in §3.1,
though it is not the keyboard's. A split bar's container must hear
pointer_move while it is being dragged and must not hear it the rest of the
time, and the obvious answer — give it the handler only while the drag runs —
loses events: one already in flight names a handler the server has since
removed, and §4 of 06-events.md ends the session for it. The
prop lets the handler stay and the moves stop.
The pointer's shape follows from drag without any style: grab over a node
resolving drag, and grabbing over everything while a drag is live. Both
already exist in the cursor scale, and an application that wants a different
shape still says so in the style, which wins as it does for everything else.
The track. A slider is the widget a server is told a hundred times what it
needed to be told five times. The hand moves continuously along a line; the
value moves in steps. So a node carrying track says which way its line runs,
and the client resolves the whole of the hand along it — which handle a press
takes, where it goes, which step the pointer is in — and reports only what a
person would call a change: a change event (06-events.md
§1) carrying the new value, and nothing at all for the samples in between. It
is §6 of 06-events.md's argument one widget further:
the client owns the hand, the server owns the value.
Four props say what the line means, read from the node carrying track:
| Prop | Value | Means |
|---|---|---|
track_min | integer | the value at the start of the line; default 0 |
track_max | integer | the value at its end; default 100 |
track_step | integer above zero | the quantum the value lands on; default 1 |
track_value | integer, or List of two | where the handle is, or both handles |
and one names the parts the client places, on any descendant of the track:
track_part | Means |
|---|---|
"groove" | the line itself, stretched along the whole track |
"fill" | the part of the line the value covers |
"thumb" | a handle: one, or two in document order |
A track with two thumb parts is a range: track_value then carries two
numbers, and the fill runs between the handles rather than from the start.
A track with no parts at all still resolves a value and draws nothing.
The geometry is the client's and is fixed here, because a widget whose
thumb sits in a different place on two clients is not one widget. Along the
axis the handles' centres travel the track's extent less one thumb's, so a
handle at either end is inside the line rather than half outside it.
track_min is at the left of an "x" track and at the bottom of a "y"
one. The value under a pointer is the value at the centre of the handle it
holds, quantised to the nearest whole track_step from track_min and
clamped to the ends; the same arithmetic run backwards places the handle, so
the value read at a handle is the value that put it there. The server
sends track_value and nothing about geometry — no fill width, no baked-in
track width — because the only width that was ever true is the one the client
laid out.
A press on a handle takes that handle and holds it at the offset it was grabbed at, so nothing jumps to centre itself under the finger. A press anywhere else takes the handle nearer the value pressed, moves it there at once, and reports it. Two handles on the same value are told apart by which side the press is on, and a press on neither side takes the second — so a pair closed at the minimum can still be opened. A gesture keeps the handle its press chose, however far it travels: a handle that changed identity under a finger that never left it would be a different thing in the hand. A handle dragged into the other stops against it; they may meet and they never swap.
A press that lands on a track does not also arm a drag
(06-events.md §6.1): the track is the more specific claim,
and a card with a slider on it is moved by its handle or by its margin. A
track_part node's own descendants are not carried with it — a part draws
its own box, and a handle with a label inside it is outside what this version
places locally.
No handler appears or disappears for the gesture. A track declares
change once and keeps it, and declares no pointer_move at all — which is
what drag_only above exists to work around, and which this makes unnecessary
for the case drag_only was invented for. The warning above applies in full: a
track that gained a handler on its press would lose the events already in
flight when it lost it again.
A track is reached without a pointer. Each thumb is focusable (§3), so
Tab steps through a range's two handles and the arrows always mean the one
the ring is on — there is no modifier to say which, and no state remembering
which moved last. The arrows along the axis move the focused handle one
track_step, the page keys ten, Home and End go to the ends, and none of
those keys is reported: only the change they make
(06-events.md §3). On a touch screen a contact landing on a
track takes the stroke, so the view beneath it does not scroll (§5 there) —
that is the one thing a pointer_move handler used to be declared for.
Accessibility follows without being said twice. value_now, value_min
and value_max (§6.1) fall back to track_value, track_min and track_max,
and while a hand is on the track the value exposed is the one the client is
drawing rather than the one the last batch carried. Five props on the track and
one per part, against the min, max and width they replace, leave
MAX_PROPS (02-wire-format.md §6) further from its
ceiling than before.
3.5 What a node may claim of the browser
One prop, for the one thing the client will start another program to do.
| Prop | Value | Means |
|---|---|---|
open | Str an https: address | activating this node hands it to the platform's opener |
The three conditions of §3.2, and for the same reason: the node carries the
prop, the net.open capability was granted, and the person activated it.
There is no op that opens an address and no event that reports one, so a tree
that merely arrives opens nothing, and an application learns nothing by
trying. A node carrying open is a focus stop, so the keyboard and an
assistive technology can follow a link the pointer can.
The scheme is the whole of the danger, and the check is a comparison. A
platform opener is a URI dispatcher, not a browser: handed file:, smb:
or a scheme some other application registered for itself, it runs that
instead, with a string the server chose. So exactly one scheme is accepted —
https://, lower case, literally — and an address carrying credentials,
control characters or whitespace is refused before it reaches the platform.
A client MUST tell the person which host it is about to open, and MUST NOT
report back whether it opened, when, or whether it failed: an answer is a
probe for whether there is a browser here at all, with a clock beside it
(08-security.md §8).
4. The catalogue contract
The catalogue is a server-side library; the client knows nothing of it. A catalogue implementation MUST:
- compose only from §1;
- express every colour as a role, with literals only for marks and data series;
- give every activatable widget a
clickhandler and every editable one achangehandler, so that §3 applies; - give every repeated child in a list a
key; - put the identity a handler needs in the node's
props, never in the event name.
A catalogue that offers styles written some other way — class strings, a
theme object — is a translation into the style vocabulary of 02 and the roles
of 05, and nothing more: it adds no key, no value and no wire construct. Such
a translation MUST refuse what it cannot express, naming the input and the
reason, rather than drop it — a page that silently loses a class looks almost
right, which is the costly way to be wrong — and everything it does emit MUST
be accepted by the encoder. Enforced for the reference catalogue's tw() by
examples/demo-app/tests/tw_spec.sl (soli test), which runs one of every
accepted class through eui_render and pins the refusals.
The reference catalogue ships with examples/demo-app as
app/controllers/eui_builders.sl and its _forms, _charts, _feed,
_markdown and _tw companions, and soli new <app> --eui writes those same files
into a new application beside a component that uses them. Its
families: actions (button variants,
split button, segmented control, toggle group, menu, context menu, command palette,
popconfirm, toolbar), input (field, password field, currency field, checkbox, switch, select,
combobox, multi select, slider, range slider, rating, date and time, file drop, form),
structure (card, panel, sheet,
dialog, drawer, popover, tooltip, tabs, accordion, split pane, stepper, shortcut sheet),
navigation (navbar, sidebar, breadcrumb, pagination, tree), data (table,
expandable row, tree table, grid, multi-select list, list item, chart, stat, code block,
diff, filter builder), markdown (document, document rows for a windowed
list, block editor and its model), feedback (toast,
banner, progress, spinner, skeleton, empty state, timeline, avatar, avatar group, badge, chip),
style (tw: Tailwind-style class strings, with hover:, active:, focus: and
disabled: as local states, sm: … 2xl: resolved on the server against the
viewport width the view passes, and space-* and divide-* carried to the
children by node()).
Its look is Tailwind UI's, drawn from roles and scale indices alone: 14 px
(size 1) for labels and body text, white fields and secondary buttons inside a
border.default hairline, cards at radius 2 with shadow 1, overlays a step up
at radius 3 and shadow 3. The look is the catalogue's choice and not a
requirement: another catalogue over the same primitives MAY look like anything.
7. Sound
An audio node is a sound the application put in the tree. It lays out as
a zero-sized leaf and paints nothing: what it does is play. Its props say
what, and what it should be doing.
| Prop | Value | Meaning |
|---|---|---|
src | asset | The sound's BLAKE3 hash, fetched like an image's bytes |
playing | bool | Play, or hold where it is. Absent is false |
volume | int 0..=100 | The application's own gain. Absent is 100 |
loop | bool | Start again at the end instead of stopping |
position | int ms | Where to play from. The client seeks when this value changes, not on every render, so a server that re-sends the same number does not stutter the sound |
Decoding runs where every decoder runs — the sandboxed worker of
08-security.md §10 — and the mixed frames are handed
to the window process, which owns the audio device as it owns the GPU. A
sound that fails to decode is dropped with a message on the client's
console; the node stays, silent.
Three events go back, and only to a node that holds a handler for them:
-
endedwhen a sound reaches its end. Not sent for a looping source, which has no end. -
time_update,[position_ms, duration_ms], while a sound plays. The client decides how often and MUST NOT send more than ten a second; four is what the reference client sends. It is a progress bar's input, not a clock: a server that needs the exact position asks for it at the moment it matters. -
level,[peak_left, peak_right], each0..=100: how loud the sound has been since the lastlevel, so a transient between two of them is smoothed rather than missed. It is a peak-reading instrument and a meter is what it is for.levelrides the clock oftime_updateand MUST NOT bring a finer one: a client that sends one sends both on the same tick, and the reference client sends four a second. It is sent when the value changes, so a silent sound costs nothing, and a sound that stops sends one last zero — a meter that stays lit over silence is worse than no meter.The peak is measured before the viewer's own volume, on the source's own samples with only the application's
volumeapplied. This is normative and it is the whole reason the event can exist: a peak taken after the master gain could be divided by the volume the application asked for to recover the viewer's setting, and a zero would say they had muted. What a server learns from alevelis a property of the bytes it sent, at the gain it chose, and nothing about the machine.A client that settled on a protocol below 3 has never heard of this event, and a handler naming it would be a decode error that ends the session. A server MUST therefore leave the handler out of the tree it sends such a client rather than send it and lose the session: the application runs, and its meter does not move. This is the difference between an event added later and a node kind added later — a kind is a capability the manifest can declare before anything renders, an event is a key in a view that has not run yet, so the floor is enforced at encode time and not at the handshake.
A
leveldescribes sound that is about to be heard, not sound already heard: a client keeps a buffer queued ahead of the device — a fifth of a second in the reference client — so the meter leads the loudspeaker by that much. Closing that gap would mean indexing peaks by playback position, which is the finer clock08-security.md§8 refuses, so it stays open and is written down here instead.
A client MUST bound what a session may play: the reference client holds at
most eight sources at once and refuses a ninth, holds a decoded sound only
while a node names it, and counts decoded audio against the session's room
for sounds (10-budgets.md, Sound). Playing a sound needs no capability:
it is output, like drawing. The microphone is another matter and is a
capability already (01-transport.md §2.1).
The viewer's own volume is above all of this and the application cannot read it, set it, or tell that it is muted.
8. Moving pictures
A video node is a moving picture the application put in the tree. It
sizes itself to its frames unless a style says otherwise and paints the
frame the clock makes due — to a painter it is a picture, because that is
exactly what it is at any instant.
| Prop | Value | Meaning |
|---|---|---|
src | asset | The picture's BLAKE3 hash, fetched like an image's bytes |
playing | bool | Play, or hold on the frame it is on. Absent is false |
loop | bool | Start again at the end instead of stopping |
position | int ms | Where to play from. Seeks when the value changes, as audio's does |
ended goes back when a picture reaches its end, to a node that holds a
handler for it; a looping picture has no end. time_update,
[position_ms, duration_ms], goes back while it plays, under the same
rate limit as a sound's — it is what a progress bar is drawn from, and a
picture with no such handler reports nothing at all. The client owns the clock,
schedules exactly the moment the next frame is due — not a poll — and
uploads a frame only when the frame on screen must change. A paused
picture wakes nothing.
The formats are GIF and animated WebP, and the reason is the whole argument of this project: both decode in pure Rust, both are patent-free, and the decoder is the most attacked surface a browser has. H.264 needs a patent licence; AV1 needs a large library, in C or in Rust, and a client that promises a 12 MB binary does not link one casually. The node kind says nothing about the codec, so a client that one day carries a real one plays the same tree.
Several nodes may name the same picture — a feed of cards carrying one animation. They share the decoded frames and the frame on screen; a client is not required to give each node its own position, and the reference client gives the first node in tree order the say. Decoding runs in the sandboxed worker (08 §10) like every other decoder, and the frames are bounded: 1920 × 1080 pixels a frame, 3 600 frames, 96 MB of decoded frames, and a frame that claims to last no time at all is given 20 ms, because a picture must not be able to spin the client.
6. The accessibility tree
A client SHOULD expose its tree to the platform's assistive technology — AT-SPI, UIA, AX — with this mapping, and MUST NOT tell the server whether one is listening:
| Node | Exposed as |
|---|---|
any node with a click handler | a button, named by every text inside it, a leaf |
input / textarea | a text field whose value is the node's text, and whose placeholder is its placeholder (§3) |
text | a label |
image / icon | an image |
scroll / list | a scrolling container; virtualised rows are absent, as they are from layout |
| anything else | a group |
Bounds are the layout rectangles. Focus is §3's. An assistive technology's
focus action focuses as Tab would, and its click action presses as
Enter would: nothing it can do exceeds what a keyboard user can do, so
the server needs no new validation and learns nothing new.
A node carrying drag (§3.4) also offers move-before and move-after,
which do in one step what §3's grab does in three, and stand at the same
ceiling — a keyboard user reaches the same place by the longer road.
They are offered only where there is somewhere to go, and a platform that has
no vocabulary for them exposes them as the custom actions it does have: the
ones that exist — grabbed, dropeffect — were deprecated by the standard that
invented them, and a moved thing announces itself better than a held one
describes itself. The announcement is the server's, through a node with
live (§6.1), because it is prose and prose is content. The client's share is
that focus stays on the thing it moved, that pos_in_set and set_size stay
true, and that the thing is brought into view.
6.1 What a node may declare
The table above is the default. A node MAY carry accessibility semantics in its props, and where it does they take precedence over the kind. A client MUST ignore a prop it does not understand and MUST NOT refuse the batch for one: the vocabulary grows without a protocol version, so props cost nothing on the wire and an older client falls back to the mapping above.
| Prop | Value | Means |
|---|---|---|
role | one of the names below | what this node is |
label | string | the accessible name, overriding the text inside |
description | string | read after the name |
checked | true, false, "mixed" | a tick, including the third state |
expanded | boolean | open or shut |
selected | boolean | chosen within a set |
disabled | boolean | present but unavailable |
read_only | boolean | editable in principle, not now |
required | boolean | must be filled in |
invalid | boolean | filled in wrongly |
busy | boolean | working |
modal | boolean | owns the window while it is up |
value_now / value_min / value_max | number | where a value sits, and its range |
pos_in_set / set_size | integer ≥ 1 | place in a set, and the size of it including what virtualisation left out |
level | integer ≥ 1 | depth, for a heading or a tree item |
orientation | "horizontal", "vertical" | which way a set runs |
live | "polite", "assertive" | how urgently a change should be read |
active_descendant | a node's key | which node in a set this one is on, while keeping the keyboard itself |
The role names: button, link, check_box, radio, radio_group,
switch, tab, tab_list, tab_panel, menu, menu_item, menu_bar,
combo_box, list_box, option, slider, spin_button, progress,
dialog, alert_dialog, alert, status, tooltip, tree, tree_item,
toolbar, navigation, table, row, cell, grid, grid_cell,
column_header, heading, separator, password, group, label, image.
Three rules follow from the table and are normative:
- Leafness is a property of the role, not of having a handler. A node
whose resolved role is
button,link,check_box,radio,switch,tab,menu_item,option,tree_itemorcolumn_headeris named by every text inside it and exposed without children. Any other role keeps its children — so atab_list, amenuor agriddoes not swallow what it holds, which the handler-based rule alone would have done. - A disabled node keeps its role. Disabling a control in a catalogue
built on this protocol means removing its handlers, and without a
declared role there would be no button left to infer. A node carrying
disabled: trueMUST keep its role and MUST NOT accept the click or focus action. - A number that is present and zero is not an absence.
value_now: 0is a slider at the bottom of its range, not a slider without a value. active_descendantnames a node, and a name pointing at nothing is dropped. The value is a key, because a key is the only handle on a node a server has; the client resolves it and exposes the node it found. The node named need not be a descendant and usually is not — a combo box puts its options in an overlay beside the field, so that the field can keep the keyboard while the arrows walk them, and rule 1's "an editable node gets no children" does not stand in the way. A key that names nothing laid out MUST be dropped and MUST NOT refuse the batch: the panel is built and torn down as it opens and shuts, so a stale name is the ordinary case.
active_descendant is the one relation this vocabulary has, and it is here
because the alternative is worse. A live node can be told 3 of 8,
Consignment as prose, but prose is the server's and structure is the
client's — §6 already draws that line for the drag announcement — and a
highlighted option is structure: it has a role, a position in its set, and
a selected state that an assistive technology reads in the reader's own
language. controls and the rest of the ARIA relation set are deliberately
left out; each would be another way for a name to dangle, and this one
earns its place by being the only way to express the widget at all.
Rendered from spec/03-widgets.md in the repository. The
normative text is spec/; where it and a prose page
disagree the spec wins, and that is a bug worth reporting.
Edit this page