What is not there yet.
Writing a renderer means rewriting what a browser gives away: text shaping, input methods, accessibility, selection. That is the real cost of this project and it is not hidden in a later milestone. This page is the other half of the catalogue — the things a desktop application expects, each one with where it would be fixed and whether anything is actually blocking it. Seven of them are closed now, and the page keeps those rather than deleting them: what a gap cost to shut is the most useful thing it leaves behind.
Twenty-three things a desktop application expects
Scored against what the client does now, not against what the catalogue names. A widget that exists but cannot be reached from the keyboard is partial, not present.
| need | where it stands | state |
|---|---|---|
| icons | Thirty-three icons under forty-seven names, stroked from the client's own table; an unknown name draws nothing and keeps its space | built |
| drag & drop | All three wire events are emitted, with a grabbed source and a payload measured against the node that catches it; a file dropped from the desktop arrives on Wayland and nowhere else | partial |
| context menu | The event fires on right-click, position: pointer places a panel at the hand, and the catalogue wraps both | built |
| command palette | Needs an accelerator to open it, and there is no global key capture | absent |
| focus trap | A modal prop makes its subtree the whole Tab order, and nests | built |
| escape to close | Reaches a handler on the path that asked for it; blurs only when none did | built |
| accelerators | Keys reach only a focused node holding key_down | absent |
| arrow navigation | A widget can now take the arrows and keep Enter, but there is still no roving focus and no type-ahead | partial |
| a11y roles | Declared in props and preferred over the kind; 40 role names, states, values and set positions | built |
| overlay dismissal | A press outside shuts an overlay that holds a blur handler; still no close on scroll or on focus leaving | partial |
| overlay placement | Two shapes: under its anchor — left-aligned, flipped up, clamped — or at the pointer. No start, end or centred, no offset, no arrow | partial |
| sticky headers | Sticky positioning is out of layout version 1 | absent |
| grid tracks | N equal columns only; no spanning, no named areas | partial |
| text selection | Inside an editable node only — a label cannot be selected | partial |
| horizontal scrollbar | The thumb is computed for the vertical axis alone | absent |
| exit animation | A node carrying exit keeps its painting while it leaves, on the accelerate curve | built |
| density / font scale | Plumbed through theme resolution and never set | inert |
| RTL mirroring | Shaping is bidirectional; layout mirroring is out of version 1 | absent |
| split panes | Draggable dividers on both axes, keyboard-movable, panels sized in px | built |
| virtualised lists | Windowed, with heights the tree does not hold | built |
| light / dark / palette | Resolved from roles, contrast by construction, follows the desktop | built |
| application typefaces | A DefFont binds a font role to faces the application serves as content-addressed assets; sans and mono can be replaced. No WOFF2, no variable axes | partial |
| input methods | Preedit anchored to the field, commit as one text_input | built |
The kind that used to paint nothing
icon had been one of the sixteen primitives since the first
protocol version, sized by layout like a picture and painted by nothing:
the painter’s match on node kind had no arm for it, so it fell
through and left a hole of the right size. The catalogue worked around it
with characters — a chevron was ▾, a tick was
✓ — which land on a 227 KB fallback symbols
face and are a poor icon three ways over: they cannot be sized against the
control they sit in, cannot take a colour apart from their label, and
reach a screen reader as themselves.
There is an arm now, and a table of thirty-three icons behind it, under
forty-seven names — fourteen of those names are aliases that reach for a
shape another one already draws, so a view can say sort_asc
or inventory and get arrow_up or
box. Every icon is
polylines on a 24-unit grid with a 2-unit stroke, and every segment is the
same rounded capsule a chart’s line is made of — so
it cost no new pipeline, no font, no asset fetch and no protocol version. A
run of one point is a dot, because a capsule of no length is a circle of
the stroke’s own radius.
An icon with no size of its own takes a square from the font size in force, so one beside a label needs no measurement from the server. An unknown name draws nothing and keeps its space — which is the rule that lets the set grow without a client release, and is a MUST in 03 §2 now rather than an accident of the implementation.
Nothing in the catalogue draws an icon as a character any more. The
select’s chevron, the accordion’s and the tree’s
disclosures, the calendar’s month arrows, the chip’s remove
and the checkbox’s tick and dash are icons. A few call sites still
pass a character — icon_button keeps taking
one, so an application that has not moved yet still works — but
where an icon is given beside it, the character is never
drawn. Pagination gained something else on the way: its ends are disabled
now, so the page cannot be walked past either end, which the character
version had never stopped.
Two of the thirty-two are on the wire and nowhere else
The protocol names thirty-two event kinds. Thirty are emitted by the client. The other two are decodable, documented, and never sent — a server may subscribe to them and will wait forever.
Drag and drop used to be three of the dead ones, and is not any more:
a node carrying drag is grabbed, drag_over
reaches whatever is under the hand, and a release becomes
drop — each payload measured against the node whose
handler catches it. A file dragged in from the desktop arrives too, as a
file_drag and then the same file_pick the
dialog sends, on Wayland and nowhere else yet.
It is worth being exact about which of the two left is a hole and
which is a decision. double_click is a convenience a server
can approximate from two clicks and a clock. resize cannot
be approximated by anything: the server never learns a node’s box,
so a view that needs one works it out from the viewport it is told
about.
There is no global key capture
That is the specification’s own sentence, under the heading
What the client will not report. Keystrokes reach a focused
editable node, or a focused node that explicitly holds a
key_down handler. Nothing else.
Read it together with the driver and the consequence is sharp: if nothing
has focus, no key is reported at all. Ctrl+K,
Ctrl+S, / and ? — the whole
accelerator vocabulary of a desktop application — are unreachable,
and a command palette cannot be opened by the gesture that opens command
palettes.
Escape used to have the matching problem from the other side, and this
section used to ask for three changes to fix it: Escape reaching a handler
on the path, a modal prop, an autofocus prop.
All three shipped — below is what they do —
so an overlay now closes on the key every overlay closes on.
What is left here is the paragraph above it and nothing else: no global capture, so no accelerator and no command palette opened by the gesture that opens command palettes. That one is the specification’s decision rather than unwritten work, and changing it means changing 06 §5 first.
Stated so no one goes looking
The layout specification has a section listing what it deliberately does not do in version 1. It is short, and every line of it is felt by some desktop pattern.
No sticky table header, no sticky section header in a list, no toolbar pinned inside a scroller. The data grid keeps its header outside the virtualised list instead, which works and is the pattern to copy.
No track sizing, no spanning, no named areas. A table with a 32 px checkbox column, a flexible name and a 120 px date is built from rows with per-cell widths — which is why resizing a column means re-emitting every row.
Thumbnails, media placeholders and avatars in a responsive grid need a height the server has worked out for them.
Text shaping is bidirectional; layout is not. A leading edge is always the left one.
z orders siblings within one stack.
Between two overlays, order in the tree is the order on screen.
Pushing one button to the right of a toolbar is a
spacer with grow, which the catalogue does
throughout.
Draggable dividers, on both axes
This page listed split panes as unwritten rather than blocked, and the distinction was worth something: they are written now, and nothing in the client changed to allow it.
A press on the divider captures the pointer, so a drag that runs off it still arrives. The move and release handlers sit on the container rather than the divider, and the payload of a pointer event is measured against the node whose handler catches it — so the number reaching the server is already the divider’s position inside its container, needing no arithmetic and no memory of where the drag began. Moves are coalesced to one per frame, not one per sample the mouse sends.
The divider also holds key_down, which is what puts it in the
Tab order: a split can be moved with the arrow keys, and recentred with
Home, by someone who never touches a pointer.
- Vertical and horizontal dividers, and they nest
- A minimum size for each panel, clamped in one place
- The fraction is per mille, so it survives state as an integer
- Both conversions round, so a divider dropped on a pixel rebuilds on that pixel — checked across every pixel of travel
- A separator role, an orientation, and the position as a value
- Each panel is built by a function of its own width in pixels
- So a view can branch on the panel instead of the window
bp()is no use here: its rungs are Tailwind’s and a 309 px panel and a 505 px one are bothxspane_bp(px)is the same idea at the scale a panel lives at — 200, 320, 480, 720
What is still missing is the latency: every frame of a drag is a round trip, because a local chunk cannot read the event that triggered it. On a loopback or a LAN that is invisible; over a long link it would not be. That is the chunk-payload gap below, and it is the one thing here that a protocol change would fix.
A widget says what it is, instead of being guessed at
The mapping used to be kind and handler alone: anything with a
click handler was a button named by the text inside it. So a
checkbox, a switch, a tab, a menu item and a slider all reached AT-SPI,
UIA and AX as “Button”, and none of them carried a state. A
screen reader user could not tell a switch from a link.
A node may now declare itself, in props the client reads: a role from forty names, a label that overrides the text inside, and the states — checked (with its third value), expanded, selected, disabled, read-only, required, invalid, busy, modal — plus a value with its range, a place in a set, a level, an orientation and a live urgency. Props already travel, so this cost nothing on the wire: it is a change to one file in the client and a section of the specification.
- Leafness follows the role, not the handler. A button or a tab is named by its text and exposed without children; a
tab_list, amenuor agridkeeps what it holds — which the old rule swallowed - A disabled node keeps its role. Disabling drops the handlers, so without a declaration there is no button left to infer and the control decays into an unnamed group
- A present zero is not an absence.
value_now: 0is a slider at the bottom of its range
CheckBox "Write the spec" checked=YesButton "Remove Write the spec"— where it used to sayButton "×"Tab "Overview" selected=true 1 of 3Slider "Value" value=40Button "Previous page" disabled
An unknown role name falls back to the kind rather than failing, which is
what lets the vocabulary grow without a client release. Sixteen vectors
hold the rules, including every role discriminant surviving the
to_u8/from_u8 round trip its journey across the
worker’s process boundary makes of it. The kind-mapping default is
pinned by a separate test, so a tree that declares nothing is provably
exposed exactly as it was before any of this existed.
Half-done, and worth saying so. The client reads all of
it; the catalogue is still declaring its way through. Checkbox, switch,
tabs, segmented, icon buttons, pagination, the calendar arrows, the
chip’s remove, slider, progress, toast and the split divider say
what they are — a run of the gallery answers with six
TabLists, sixteen Tabs, four
Separators, two Sliders and two
Progresses where every one of those used to be a button or a
group. Against that, three hundred and thirty-eight nodes are still plain
buttons: menus, the select, the tree, the data grid, dialogs and the day
cells have not been declared yet.
And a real screen reader remains the only honest test —
SNAPSHOT_A11Y=1 on the off-screen renderer is the one that
can run without one.
Four things a server cannot do for itself
It does not own Tab. It was never told about
Escape. And it cannot know which node the client will treat
as pressed. So a dialog could not trap focus, could not open with focus
inside it, and could not close on the key every dialog closes on. Three
props and one changed rule fix all four.
modal— while it is laid out, the Tab order is its subtree alone. Innermost wins, so a dialog over a dialog traps in the secondautofocus— focus starts here when the surface arrives, and is not reclaimed by a later batch: someone tabbing through an open dialog is not pulled back to its first fieldkeys— the keys this node wants, and it is sent no others
- A tab can take
ArrowLeftandArrowRightand still be activated byEnter— which akey_downhandler used to make impossible, because it claimed every key - A dialog can listen for
Escapewithout hearing every letter typed into the field inside it - Fewer round trips: a key a node did not ask for is not sent at all
- A node with a handler and no
keysprop still hears everything, so nothing that worked stops
Escape follows from the third. It reaches a handler on the
path that asked for it, and focus is left where it was so the surface can
put it back; with nothing listening it drops focus, which is what it has
always done. Driven through a real server: the gallery’s sheet opens
at 940 nodes carrying Dialog "A sheet" modal, and one
Escape later it is 934 and the dialog is gone.
Seventeen vectors hold it, and the off-screen renderer grew
SNAPSHOT_KEYS so a focus ring, a trapped Tab or a surface
that closes on a key can be looked at without a keyboard. What is still
missing is above this: roving focus and type-ahead are a widget’s
to implement over the wire for now, one round trip per arrow, and
accelerators need the global capture the specification still refuses.
The distinction that actually matters
Most of this page is work, not impossibility. It is worth separating the two, because the status page had one of them filed wrong: column resize was recorded as blocked, and it is not.
- Sticky headers — layout version 1 has no sticky, and faking it needs a scroll offset the server does not have
- A masked password field — nothing in the client hides an
input’s glyphs - Local keyboard navigation and local drag — a bytecode chunk cannot see the event that triggered it, so it never learns which key, or where the pointer is
- Control heights that follow density — the theme resolves them and no dimension on the wire can reference them
- Column resize — the same server-driven drag
split_panenow uses; what is left is re-emitting the row styles a width change touches - link, time_picker, list_item, panel — ordinary compositions, waiting their turn. context_menu, textarea, radio_group, combobox and rating came off this list and are in the catalogue
The rule the two columns come from: a thing is blocked when expressing it would need a new node kind, a new style key, a new event, or a chunk that can read its own event. Everything else is a function someone has not written yet.