Decision
A plugin may put its own interface on screen over the running client. It does so by sending a description of what it wants shown; Cordial builds the widgets with GTK and composites them over the engine’s surface. Three rules.- A plugin never draws. It sends a declarative payload — a toast, a panel of rows, a HUD element — and receives a handle it can update or dismiss. It gets no surface, no drawing context, no pixels, and no way to ask for one.
- Overlays are split by what they cost the player, not by what they contain.
Three capabilities, granted separately:
ui.notifyfor something transient,ui.hudfor something that persists over gameplay,ui.panelfor something that takes focus. A plugin permitted to show a toast is not thereby permitted to cover the screen. - Cordial owns the frame. Position, stacking, styling, dismissal and the right to refuse are Cordial’s. A plugin asks for a region by name, not by coordinate.
Why this needs no hooking, and is not a new architecture
Corrected by ADR-029. The paragraph below says a GTK widget placed above the canvas is drawn above it. The second half of that — no cooperation from the engine — is true and is the point. The first half is not: the subsurface is placed above the parent in the ordinary case, so a widget put on top of it is drawn underneath until something lowers the engine, stops GTK painting over the hole, and decides who gets the clicks. An overlay built on the sentence as written would render nothing and look like a GTK bug. ADR-029 has the three pieces of state and the two shapes an overlay may take; a plugin overlay is an Inset or a Modal in its terms.The engine’s
wl_surface is already a wl_subsurface of Cordial’s GTK
toplevel — ADR-011 records that and the reasons it had to be. So a GTK widget
placed above it is drawn above it, by the compositor, with no cooperation from
the engine and nothing injected into its process.
That is not a theory. crates/cordial-shell/src/host_window.rs already does it:
TextOverlay puts a real gtk::Text over the engine’s output whenever a Roblox
TextBox takes focus, and the player sees the GTK widget’s glyphs rather than the
engine’s. The overlay system exists and has exactly one user.
So this ADR adds an API to a mechanism that is already shipping, rather than a
mechanism. That is the main argument for it, and it is also the reason to be
careful: the text editor took a long time to get right — input routing, focus,
IME, sizing against a surface that resizes — and every one of those problems
belongs to overlays too.
Why not a web view
It was proposed, on the reasonable grounds that somebody might want to write an overlay in React. Rejected for the overlay path specifically, on four counts. Cost per plugin. AWebKitWebView is a browser: a web process, a network
process, its own compositor. Three plugins with overlays would mean three
browser instances composited over a game that is already the thing struggling
for frames. TextOverlay is one lightweight widget, and that is the shape an
overlay wants.
It is the piece that does not travel. WebKitGTK spawns its helpers by
absolute path, which is why the AppImage’s embedded browser does not work
without WebKitGTK 6.0 installed on the host. Making overlays depend on it would
put plugin interfaces in the same condition, in the format with the most
downloads.
Input routing is the hard part and a web view makes it harder. An overlay
must decide, per region, what passes through to the game and what does not. That
is solved for the text editor and would have to be solved again for a browser
widget that grabs input on its own terms.
It breaks the capability model. ADR-007’s rule is that a plugin receives an
effect and never a channel. A web view is a network stack, a storage API and a
JavaScript runtime that Cordial does not broker. Handing one to a plugin gives
it every channel at once, which is the thing “no sockets, no file descriptors”
exists to prevent.
Where a web view is right, and is not ruled out: a plugin’s own settings or
dashboard, in a separate window, not composited over gameplay. Rich layout, no
latency budget, no passthrough problem, and the existing web view already does
it. That is a different feature and wants its own decision; nothing here forbids
it.
Why declarative, which is the part that ages well
A plugin that sends pixels or markup pins Cordial to whatever rendered them. A plugin that sends “a toast saying this” survives the renderer changing underneath it. Four things fall out of that and none of them are available otherwise:- Overlays cannot fight. Cordial places them, so two plugins cannot both own the top-right corner, and a misbehaving one cannot cover the client.
- They look like Cordial. One libadwaita style, restyled in one place, and a plugin author does not have to reimplement a theme to look native.
- They can be refused. A HUD element while the client is loading, or a panel during a join, can be declined or deferred, because Cordial knows what the client is doing and the plugin does not.
- The payload is inspectable. A grant dialogue can say what a plugin wants to show, which it cannot do for an opaque surface.
What is not decided here
The payload schema, the region names, the update and dismissal shape, and whether a HUD element may persist across a join. Those want writing against a prototype rather than in advance. Also not decided: whether overlays should be visible in a screenshot taken throughcordial_screenshot. That reads the engine’s swapchain, so today they
would not be — which is right for debugging the engine and wrong for a bug
report about an overlay.
Consequences
ui.notify supersedes nothing but overlaps notify.send, which sends a desktop
notification through the host. They are different: one is a system notification
that survives the client being minimised, the other is drawn over the game. Both
should exist and the documentation must not let them be confused.
The three capabilities are additive and default-denied like every other, so an
installed plugin gains nothing until somebody grants them.
TextOverlay should end up using the same machinery rather than sitting beside
it, but not in the first change: it is load-bearing, it is the one part of text
entry that works, and rewriting it to prove a point about symmetry is how that
stops being true.